Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining
# Conflicts: # apps/web/tests/snapshots/queue-actions/layout.expected.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/goal.i18n.yaml # docs/core-data-structures/goal.md # docs/core-data-structures/goal.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl # examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-report/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # packages/bash/tool-bash/tests/integration.spec.ts # packages/context/time-context/src/index.ts # packages/context/tmux-context/src/index.ts # packages/core/agent-loop/src/agent.ts # packages/core/system-prompt/src/index.ts # packages/goal/goal/src/domain.ts # packages/goal/goal/src/index.ts # packages/goal/goal/src/render.ts # packages/plan/plan-mode/src/index.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/README.md
|
||||
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
|
||||
README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa
|
||||
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
|
||||
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
|
||||
|
||||
@@ -6,6 +6,6 @@ The ACP group exposes harness agents to programmatic clients. It is an interoper
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. |
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server. |
|
||||
|
||||
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
|
||||
|
||||
@@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。
|
||||
|
||||
@@ -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/acp/acp/README.md
|
||||
README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740
|
||||
README.zh.md: ffffd97daa911636336ee9a515bf01bad33d31ae
|
||||
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
|
||||
README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
|
||||
|
||||
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules.
|
||||
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules.
|
||||
|
||||
## Plugin
|
||||
|
||||
@@ -24,7 +24,7 @@ Both fields are optional so another agent/request listener may supply the target
|
||||
| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. |
|
||||
| `authenticate` | No-op because the server advertises no authentication methods. |
|
||||
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and settles from that request's owning durable `turn/end`. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. |
|
||||
| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. |
|
||||
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
|
||||
@@ -37,6 +37,8 @@ Committed-message output intentionally trades token-by-token latency for a clean
|
||||
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately.
|
||||
|
||||
## Running
|
||||
|
||||
`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
|
||||
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
@@ -37,6 +37,8 @@
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。
|
||||
|
||||
@@ -17,13 +17,17 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
return 'end_turn'
|
||||
case 'max-tokens':
|
||||
return 'max_tokens'
|
||||
// `cancelled` is reserved for explicit client cancellation (`session/cancel`)
|
||||
// and disposal, both settled out of band; a turn aborted by a hook or
|
||||
// another owner is ordinary quiescence and reports `end_turn`.
|
||||
case 'aborted':
|
||||
case 'disposed':
|
||||
return 'end_turn'
|
||||
case 'interrupted':
|
||||
return 'cancelled'
|
||||
case 'blocked':
|
||||
case 'error':
|
||||
return 'end_turn'
|
||||
// TurnEndReason is merge-extensible; future variants still need a legal wire value.
|
||||
/* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
|
||||
@@ -90,13 +90,10 @@ interface SessionRecord {
|
||||
inflight: {
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
messageId: string
|
||||
turn: number | undefined
|
||||
/**
|
||||
* A failed turn's terminal reason, held until quiescence: a retry action
|
||||
* closes the failed turn and opens a successor that adopts the prompt, so
|
||||
* rejecting at `turn/end` would race the recovery.
|
||||
*/
|
||||
pendingError: Extract<TurnEndReason, { kind: 'error' }> | undefined
|
||||
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
|
||||
endReason: TurnEndReason | undefined
|
||||
} | undefined
|
||||
}
|
||||
|
||||
@@ -149,7 +146,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight: NonNullable<SessionRecord['inflight']>,
|
||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||
): void => {
|
||||
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
|
||||
inflight.reject(internalError(`turn failed: ${reason.error.message}`))
|
||||
}
|
||||
|
||||
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||
@@ -174,30 +171,33 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
} finally {
|
||||
const inflight = record.inflight
|
||||
if (inflight !== undefined && event.type === 'turn/start') {
|
||||
if (inflight.turn === undefined && event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'user') {
|
||||
inflight.turn = event.data.turn
|
||||
} else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') {
|
||||
// A recovery policy opened a retry turn on the failed history: the
|
||||
// prompt rides it instead of rejecting on the failed turn's end.
|
||||
inflight.turn = event.data.turn
|
||||
inflight.pendingError = undefined
|
||||
}
|
||||
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
if (event.data.reason.kind === 'error') {
|
||||
// Hold the rejection: request recovery may adopt the prompt with a
|
||||
// successor turn; quiescence without one delivers this error.
|
||||
inflight.turn = undefined
|
||||
inflight.pendingError = event.data.reason
|
||||
} else {
|
||||
// Model failures surface immediately as prompt errors; ordinary
|
||||
// endings wait for whole-agent idle below.
|
||||
record.inflight = undefined
|
||||
inflight.resolve(turnEndToStopReason(event.data.reason))
|
||||
rejectFromError(inflight, event.data.reason)
|
||||
} else {
|
||||
inflight.endReason = event.data.reason
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/inbox/claimed', (agent, { message, turn }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
|
||||
})
|
||||
|
||||
ctx.on('agent/error', (agent, turn, _step, error) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (record === undefined || inflight === undefined || inflight.turn === turn) return
|
||||
record.inflight = undefined
|
||||
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
|
||||
})
|
||||
|
||||
// Permission requests are a machine policy channel for ACP clients such as
|
||||
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
|
||||
// durable grant from an unknown client response.
|
||||
@@ -278,17 +278,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
// Arm the slot before followup() so a listener-driven synchronous
|
||||
// turn cannot slip past correlation; a synchronous followup()
|
||||
// failure (invalid input) must free the slot again or the session
|
||||
// would reject every later prompt as already in flight.
|
||||
const inflight: NonNullable<SessionRecord['inflight']> = {
|
||||
resolve, reject, turn: undefined, pendingError: undefined,
|
||||
resolve, reject, messageId: message.id, turn: undefined, endReason: undefined,
|
||||
}
|
||||
record.inflight = inflight
|
||||
try {
|
||||
record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
record.agent.followup(message)
|
||||
// The machine's send() contains listener failures and accepts
|
||||
// any typed input; this guards a future synchronous throw so the
|
||||
// slot cannot wedge.
|
||||
@@ -299,18 +300,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
throw internalError(`prompt was not queued: ${detail}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
// Admission is pre-turn and retries outlive their failed turn, so a
|
||||
// turnless slot settles only at quiescence: a held failure rejects
|
||||
// (no retry adopted the prompt); no turn at all means admission
|
||||
// discarded the prompt — report cancelled.
|
||||
// Settlement waits for whole-agent idle: a correlated turn/end arms
|
||||
// `endReason`, while a turnless slot (admission discarded the
|
||||
// prompt) stays cancelled. Other producers may run further turns
|
||||
// before quiescence; the prompt settles only when the agent stops.
|
||||
void record.agent.whenIdle().then(() => {
|
||||
if (record.inflight !== inflight || inflight.turn !== undefined) return
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
if (inflight.pendingError !== undefined) {
|
||||
rejectFromError(inflight, inflight.pendingError)
|
||||
return
|
||||
const end = inflight.endReason
|
||||
if (end === undefined) {
|
||||
inflight.resolve('cancelled')
|
||||
} else {
|
||||
// Token-limit and other non-terminal endings are not prompt-level
|
||||
// stop reasons (see README); only normal quiescence reports end_turn.
|
||||
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
|
||||
}
|
||||
inflight.resolve('cancelled')
|
||||
})
|
||||
})
|
||||
return { stopReason }
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('ACP machine permission policy', () => {
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from '../src/codec.ts'
|
||||
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
|
||||
|
||||
describe('ACP automation codec', () => {
|
||||
it('maps every known turn outcome to a legal stop reason', () => {
|
||||
const cases: [TurnEndReason, string][] = [
|
||||
[{ kind: 'completed' }, 'end_turn'],
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted' }, 'cancelled'],
|
||||
[{ kind: 'disposed' }, 'cancelled'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
|
||||
]
|
||||
for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
describe('ACP codec', () => {
|
||||
it.each([
|
||||
[{ kind: 'completed' }, 'end_turn'],
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'end_turn'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'blocked' }, 'end_turn'],
|
||||
[{ kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } }, 'end_turn'],
|
||||
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
|
||||
expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses a legal fallback for merge-extensible future outcomes', () => {
|
||||
expect(turnEndToStopReason({ kind: 'future' } as unknown as TurnEndReason)).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('flattens baseline blocks and rejects everything richer', () => {
|
||||
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
|
||||
expect(acpPromptToText([
|
||||
{ type: 'text', text: 'see' },
|
||||
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
|
||||
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
|
||||
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
|
||||
expect(promptHasUnsupportedContent([
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
|
||||
])).toBe(false)
|
||||
expect(promptHasUnsupportedContent([
|
||||
{ type: 'image', data: '', mimeType: 'image/png' },
|
||||
])).toBe(true)
|
||||
it('drops unsupported blocks from baseline text conversion', () => {
|
||||
expect(acpPromptToText([{
|
||||
type: 'image',
|
||||
data: '',
|
||||
mimeType: 'image/png',
|
||||
}])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,15 @@ describe('ACP connection ownership', () => {
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') })
|
||||
const cancel = agent.cancel.bind(agent)
|
||||
let cancelObserved = false
|
||||
vi.spyOn(agent, 'cancel').mockImplementation((...args) => {
|
||||
if (!cancelObserved) {
|
||||
cancelObserved = true
|
||||
order.push('parent cancelled')
|
||||
}
|
||||
cancel(...args)
|
||||
})
|
||||
|
||||
const disposal = harness.acpFiber.dispose()
|
||||
// A drain can block on persistence, so the bridge's own turn must already be
|
||||
|
||||
@@ -31,11 +31,13 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('maps a max-token turn without losing its committed text', async () => {
|
||||
it('maps a max-token turn to end_turn without losing its committed text', async () => {
|
||||
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('max_tokens')
|
||||
// A token-limit turn ending is not a prompt-level stop reason (README):
|
||||
// the prompt settles at whole-agent idle with end_turn.
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
|
||||
})
|
||||
|
||||
@@ -49,12 +51,27 @@ describe('ACP prompt lifecycle', () => {
|
||||
|
||||
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
|
||||
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
|
||||
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: plugin pre-step failed/)
|
||||
})
|
||||
|
||||
it('rejects a turn-start failure before the prompt is claimed', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
const append = agent.session.append.bind(agent.session)
|
||||
vi.spyOn(agent.session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === 'turn/start') throw new Error('turn start unavailable')
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: turn start unavailable/)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('settles even when an earlier turn observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_session, event) => {
|
||||
@@ -65,13 +82,13 @@ describe('ACP prompt lifecycle', () => {
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('ignores an injection turn while correlating the owning message turn', async () => {
|
||||
it('correlates the owning prompt when a synchronous injection joins its first step', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
let injected = false
|
||||
harness.ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
harness.ctx.on('agent/inbox/inserted', (subject, { message }) => {
|
||||
if (subject === agent && message.source.kind === 'user' && !injected) {
|
||||
injected = true
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
}
|
||||
@@ -86,30 +103,44 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
let inserted = false
|
||||
harness.ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent || item.message.source.kind !== 'user' || inserted) return
|
||||
inserted = true
|
||||
const source = { kind: 'plugin', plugin: 'test' } as const
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source,
|
||||
}), { surfaceOp: 'append' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
let autonomousStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { autonomousStarted = resolve })
|
||||
harness.ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'assistant/chunk') autonomousStarted()
|
||||
})
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await started
|
||||
|
||||
let settled = false
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
.finally(() => { settled = true })
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced'
|
||||
&& event.data.inserted.length > 0)).toHaveLength(2)
|
||||
})
|
||||
expect(settled).toBe(false)
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('correlates a prompt whose step history is replaced', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('rewritten answer')] })
|
||||
harness.ctx.on('agent/pre-step', async () => ({
|
||||
kind: 'enter',
|
||||
messages: [createUserMessage({
|
||||
content: [{ type: 'text', text: 'rewritten prompt' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'original' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('frees the prompt slot when the agent rejects the send synchronously', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
@@ -143,7 +174,39 @@ describe('ACP prompt lifecycle', () => {
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
await agent.whenIdle()
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason).toEqual({ kind: 'aborted' })
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('settles a hook-cancelled turn as end_turn, not cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
// A hook or another owner cancels the agent: the ACP client never called
|
||||
// session/cancel, so this is ordinary quiescence and reports end_turn.
|
||||
agent.cancel({ kind: 'hook', reason: 'owner intervention' })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('cancels autonomous running work without an in-flight prompt', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
|
||||
})
|
||||
|
||||
await harness.client.cancel({ sessionId })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('an idle cancel does not affect the following prompt', async () => {
|
||||
@@ -184,7 +247,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
|
||||
})
|
||||
|
||||
it('a failed turn with no retry still rejects, at quiescence', async () => {
|
||||
it('a failed turn with no retry still rejects', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
|
||||
let offered = 0
|
||||
harness.ctx.on('agent/request-error', async () => { offered += 1 })
|
||||
@@ -194,13 +257,36 @@ describe('ACP prompt lifecycle', () => {
|
||||
expect(offered).toBe(1)
|
||||
})
|
||||
|
||||
it('an admission-blocked prompt settles cancelled instead of hanging', async () => {
|
||||
it('a pre-step-rejected prompt settles instead of hanging', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' }))
|
||||
harness.ctx.on('agent/pre-step', async () => ({
|
||||
kind: 'reject' as const,
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'cancelled' })
|
||||
// The blocked prompt opened no turn and streamed nothing.
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
// The rejected prompt closed a blocked turn without streaming anything.
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
|
||||
it('cancels a prompt removed before its turn claims it', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
const dispose = harness.ctx.on('agent/inbox/inserted', (agent, { message }) => {
|
||||
if (message.source.kind === 'user') agent.inbox.remove(message.id)
|
||||
})
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'cancelled' })
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('rejects a prompt when pre-step fails inside its open turn', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
harness.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: pre-step exploded/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/bash/README.md
|
||||
README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0
|
||||
README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03
|
||||
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
|
||||
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages.
|
||||
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All are **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
|
||||
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) |
|
||||
| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) |
|
||||
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) |
|
||||
| [`bash-env/`](bash-env/README.md) | Provides the managed `DSH_*` environment shared by shell tools. | `ctx.bashEnv` |
|
||||
| [`tool-bash/`](tool-bash/README.md) | Exposes Bash execution and background-task integration to the model. | (registers on `ctx.tools`) |
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).
|
||||
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# bash/:bash 能力家族
|
||||
# bash/ — bash 能力家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
能力家族横跨规范执行器 seam、其实现、共享 shell 环境与面向模型的工具。这些全是**产品**包。
|
||||
该能力家族涵盖规范执行器 seam、其实现、共享 shell 环境和面向模型的工具。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` |
|
||||
| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) |
|
||||
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) |
|
||||
| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现(可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash`) |
|
||||
| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor) | (注册 `ctx.bashEnv`) |
|
||||
| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema(行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器契约。 | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash`) |
|
||||
| [`bash-env/`](bash-env/README.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.bashEnv` |
|
||||
| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools`) |
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c
|
||||
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## Managed environment
|
||||
|
||||
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
|
||||
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent(智能体)的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。
|
||||
|
||||
|
||||
@@ -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/bash/bash-local/README.md
|
||||
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
|
||||
README.zh.md: 8d6e3b8d2fdd92faeb7b1e6e373a938ae2064cd4
|
||||
README.md: bb87ad6fe021e3144cef4adced3d798bf3d94d67
|
||||
README.zh.md: d2f8c9091072bbf3d75909f6826432601001ab88
|
||||
|
||||
@@ -20,15 +20,13 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
## Behavior
|
||||
|
||||
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -20,15 +20,13 @@
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
```
|
||||
|
||||
## 行为(以及设计来源)
|
||||
## 行为
|
||||
|
||||
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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/bash/bash-sandbox/README.md
|
||||
README.md: 2f69ea66251f00c74779f1decc69abc6003a4398
|
||||
README.zh.md: fc4afb554442dfaf806292f30ddf9c495c427831
|
||||
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
|
||||
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977
|
||||
|
||||
@@ -36,8 +36,6 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Bash tool schema, indirectly
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 间接的 Bash 工具 schema
|
||||
|
||||
@@ -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/bash/bash/README.md
|
||||
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
|
||||
README.zh.md: 4eef38e436cb0a4bcb3505aeb228d18c4a8c170b
|
||||
README.md: 88f519a21a0889d6b7649502c51077940c23709f
|
||||
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
|
||||
|
||||
@@ -25,7 +25,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
| `BashProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
|
||||
| `BashProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
|
||||
|
||||
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit — see the HMR-safety tests.
|
||||
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
@@ -35,6 +35,8 @@ The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, th
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
|
||||
| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
|
||||
|
||||
## 词汇
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。
|
||||
|
||||
@@ -22,6 +22,8 @@ export type {
|
||||
DshEnvironment,
|
||||
DshEnvironmentKey,
|
||||
} from './types.ts'
|
||||
export { parseExitStatus } from './render.ts'
|
||||
export type { ParsedExitStatus } from './render.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
|
||||
42
packages/bash/bash/src/render.ts
Normal file
42
packages/bash/bash/src/render.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Shared rendering helpers for the shell tools (`dsh-tool-bash`,
|
||||
* `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers
|
||||
* emit and the presentation layer parses back.
|
||||
* @module @deepseek-ai/dsh-bash/render
|
||||
*/
|
||||
|
||||
/**
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered shell-tool result string into its output body and the
|
||||
* structured exit status — the inverse of the `[exit code: N]` /
|
||||
* `[killed by signal: X]` markers the shell tools' renderers append. A killed
|
||||
* marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output
|
||||
* would render the exit twice. Other markers (timeout, sandbox denial) carry
|
||||
* facts no pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
* Requiring a leading newline and the end of the string keeps ordinary output
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing shell-tool result.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
36
packages/bash/bash/tests/render.spec.ts
Normal file
36
packages/bash/bash/tests/render.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared exit-status parse contract: the inverse of the `[exit code: N]` /
|
||||
* `[killed by signal: X]` markers `dsh-tool-bash` and `dsh-tool-pwsh` append.
|
||||
* Both tools' presenter suites round-trip their own renderers through this
|
||||
* parse; this spec pins the parse's own edges (marker-like output, body
|
||||
* slicing) once, at the seam that owns it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseExitStatus } from '../src/render.ts'
|
||||
|
||||
describe('parseExitStatus', () => {
|
||||
it('recovers a clean exit 0 with the body verbatim when no marker is present', () => {
|
||||
expect(parseExitStatus('hi\n\n')).toEqual({ body: 'hi\n\n', exitCode: 0 })
|
||||
expect(parseExitStatus('')).toEqual({ body: '', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('recovers a non-zero exit and strips only its marker from the body', () => {
|
||||
expect(parseExitStatus('oops\n[exit code: 3]')).toEqual({ body: 'oops', exitCode: 3 })
|
||||
// The marker needs the leading newline and the end of the string, so a
|
||||
// clean result whose output merely ENDS in marker-like text is not read
|
||||
// as a failure and the text stays in the body.
|
||||
expect(parseExitStatus('[exit code: 5]')).toEqual({ body: '[exit code: 5]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('recovers a signal kill ahead of any non-zero exit marker', () => {
|
||||
expect(parseExitStatus('gone\n[killed by signal: SIGKILL]')).toEqual({ body: 'gone', signal: 'SIGKILL' })
|
||||
// A fake signal marker with no leading newline is output, not a kill.
|
||||
expect(parseExitStatus('[killed by signal: SIGKILL]')).toEqual({ body: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('keeps markers no pill shows (timeout) in the body', () => {
|
||||
expect(parseExitStatus('slow\n[timed out after 100ms]\n[exit code: 143]'))
|
||||
.toEqual({ body: 'slow\n[timed out after 100ms]', exitCode: 143 })
|
||||
})
|
||||
})
|
||||
@@ -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/bash/pwsh-local/README.md
|
||||
README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf
|
||||
README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62
|
||||
README.md: 2914c46ab91dd9555dab04551e52321f6eac05bf
|
||||
README.zh.md: ce9696b276a2e60acf116d7124cd5cd256d7ebde
|
||||
|
||||
@@ -46,7 +46,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`.
|
||||
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
|
||||
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
|
||||
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。
|
||||
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。
|
||||
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
|
||||
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
|
||||
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
|
||||
|
||||
@@ -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/bash/tool-bash/README.md
|
||||
README.md: 35a5647365dab6daa903c14cba8b83702b50305d
|
||||
README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754
|
||||
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
|
||||
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9
|
||||
|
||||
@@ -6,7 +6,7 @@ The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreg
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal.
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。
|
||||
|
||||
插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
|
||||
@@ -95,36 +95,9 @@ export function renderProcessRead(
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
* The exit-status parse is the shared marker-contract half of the shell-tool
|
||||
* rendering story, owned by `@deepseek-ai/dsh-bash` so `dsh-tool-pwsh` reuses
|
||||
* it (its renderer emits the same markers). Re-exported here to keep
|
||||
* `../src/render.ts` a single import root for bash-tool consumers.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered {@link renderResult} string into its output body and the
|
||||
* structured exit status — the inverse of the status markers it appends. A
|
||||
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output would
|
||||
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
|
||||
* pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
* Requiring a leading newline and the end of the string keeps ordinary output
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing bash result.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
|
||||
it('background: start ack → pending completion notice → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
@@ -194,25 +194,27 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable plugin-sourced user/message into the owning agent's session
|
||||
// (settlement may race turn end, so poll for it).
|
||||
// pending next-step message without waking the idle agent.
|
||||
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
await pollUntil(() => events(agent).some(isNotice))
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data.content.some(
|
||||
await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin'))
|
||||
const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')!
|
||||
expect(pendingNotice.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({
|
||||
expect(pendingNotice.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
|
||||
})
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
// The next turn first admits that notice as user/message, then collects
|
||||
// the output through the generic task tool.
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data).toEqual(pendingNotice)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
|
||||
@@ -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/bash/tool-pwsh/README.md
|
||||
README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7
|
||||
README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7
|
||||
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
|
||||
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
|
||||
|
||||
@@ -36,7 +36,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe.
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed foreground result is a `terminal` card too: the exit marker becomes the card's exit-status pill (`exitCode`/`signal`), and the marker-free body is the card's output — exactly the bash tool's terminal-card story, via the shared exit-status parse from `@deepseek-ai/dsh-bash`. Background acks and execution errors stay `generic` cards with the rendered output in a `console` fence. These presenters are pure and replay-safe.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -121,5 +121,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
|
||||
| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
|
||||
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `run_in_background` | boolean | 立即返回 task id;不适用超时。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## UI presentation
|
||||
|
||||
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。
|
||||
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的前台结果同样是 `terminal` 卡:退出 marker 变成卡片的退出状态 pill(`exitCode`/`signal`),去 marker 的正文成为卡片输出——与 bash 工具的 terminal 卡故事完全一致,经由 `@deepseek-ai/dsh-bash` 的共享退出状态解析。后台 ack 与执行错误保持 `generic` 卡,以 `console` 围栏包裹渲染输出。这些 presenter 是纯函数且可重放。
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -121,5 +121,4 @@ ack 是固定短行;任务输出按读取有界。
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* foreground and `run_in_background` execution (background handles register
|
||||
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
|
||||
* through the shared `bash-env` registry, and the bash marker/truncation
|
||||
* rendering story. UI presentation stays on the existing generic/terminal
|
||||
* cards; a pwsh-specific rendering twin is roadmap work.
|
||||
* rendering story. UI presentation mirrors the bash tool's too: a completed
|
||||
* foreground call is a terminal card with the parsed exit-status pill, using
|
||||
* the shared exit-status parse from `@deepseek-ai/dsh-bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
@@ -25,6 +26,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { parseExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
|
||||
|
||||
@@ -297,10 +299,20 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
}
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
/* jscpd:ignore-start -- the completed-result presentation mirrors presentBashResult's by design (Agent Note). */
|
||||
presentResult: (args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
const raw = block.text
|
||||
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
|
||||
// Background acknowledgements and errors have no terminal exit status.
|
||||
if (isBackground || result.isError) {
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
}
|
||||
// The exit marker becomes the card's exit pill, so it leaves the output body.
|
||||
const { body, ...exit } = parseExitStatus(raw)
|
||||
return { card: 'terminal', output: body, ...exit }
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderPwshProcessRead } from '../src/render.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -516,16 +516,16 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
describe('UI presentation', () => {
|
||||
it('a real execute renders the console view through the tool definition presenter', async () => {
|
||||
it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const result = await call(ctx, 'pwsh', args)
|
||||
const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: '```console\nhi\n```' }],
|
||||
})
|
||||
// A terminal result keeps the RAW bytes (newlines intact) a terminal
|
||||
// renderer needs; a clean run renders no exit marker, so the body is the
|
||||
// raw output with a clean exit-0 pill, mirroring the bash tool.
|
||||
expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
|
||||
@@ -553,6 +553,83 @@ describe('UI presentation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
||||
const { ctx } = await setup()
|
||||
const present = ctx.tools.get('pwsh')
|
||||
const args = { command: 'x', description: 'x' }
|
||||
expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }))
|
||||
.toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
|
||||
expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }))
|
||||
.toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => {
|
||||
const { ctx } = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
expect(ctx.tools.get('pwsh')?.presentResult?.(
|
||||
args,
|
||||
{ content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
|
||||
)).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
|
||||
})
|
||||
|
||||
it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const present = ctx.tools.get('pwsh')!
|
||||
const base = {
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'out', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
const cases = [
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
|
||||
{ result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
|
||||
{ result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
|
||||
// A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const rendered = renderPwshResult(c.result)
|
||||
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
||||
// Drop card + output; the remaining fields are the parsed exit.
|
||||
const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
|
||||
expect(exit).toEqual(c.expect)
|
||||
// Whatever the parse consumed is gone from the body, so a card with an
|
||||
// exit pill never shows the same status twice.
|
||||
expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
|
||||
}
|
||||
})
|
||||
|
||||
it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
|
||||
const { ctx } = await setup()
|
||||
const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' }
|
||||
// A successful command may print marker-like text. A clean result appends no marker or
|
||||
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
|
||||
const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
|
||||
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
|
||||
// Same for a fake signal marker with no leading newline.
|
||||
const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = ctx.tools.get('pwsh')!.presentResult!(
|
||||
{ command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true },
|
||||
{ content: [{ type: 'text', text: 'started background task pwsh-1' }], isError: false },
|
||||
)
|
||||
expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task pwsh-1\n```' }] })
|
||||
})
|
||||
|
||||
it('presentResult: an isError result is a generic card (no real process exit to report)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const out = ctx.tools.get('pwsh')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
|
||||
)
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
|
||||
})
|
||||
|
||||
it('presentResult falls back to undefined for multi-block or non-text content', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
|
||||
@@ -60,16 +60,16 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain
|
||||
|
||||
## Styling
|
||||
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English.
|
||||
|
||||
## Testing and coverage
|
||||
|
||||
The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
|
||||
|
||||
- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
|
||||
- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
|
||||
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
|
||||
- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
|
||||
- Client source packages are inside the per-file 100% coverage gate (`pnpm run test:coverage`). Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
|
||||
- Component specs render with realistic props or a driven fixture runtime and assert user-visible behavior, not class names, hook internals, or render counts.
|
||||
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line; the shared config stays node-env.
|
||||
- Each tier asserts its own layer. Data-layer semantics belong to the runtime and host suites; component specs cover presentation behavior.
|
||||
|
||||
## Before you push: the local check ladder
|
||||
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 31d884c04a8b0233713b77b82b3d9cc7052003ca
|
||||
README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4
|
||||
README.md: b950772d4cad6d873426f8aee6416fa56afca2ee
|
||||
README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d
|
||||
|
||||
@@ -2,33 +2,38 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
|
||||
The browser side of the dsh web GUI: shell boot, browser-host communication, shared UI services, and feature plugins. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All except `test-runtime` are **product** packages named `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
| Package | Role | ctx key / slot |
|
||||
|---|---|---|
|
||||
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
|
||||
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
|
||||
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
|
||||
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
|
||||
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | Dev-only hot reload for script-loaded client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
|
||||
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
|
||||
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
|
||||
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
|
||||
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
|
||||
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
|
||||
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
|
||||
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
|
||||
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
|
||||
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
|
||||
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
|
||||
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
|
||||
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| [`web/`](web/README.md) | Boots the browser shell from the client entry graph. |
|
||||
| [`modules/`](modules/README.md) | Loads browser-side client modules. |
|
||||
| [`web-react/`](web-react/README.md) | Connects the shell runtime to React rendering. |
|
||||
| [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. |
|
||||
| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. |
|
||||
| [`hmr/`](hmr/README.md) | Refreshes client plugins during development. |
|
||||
| [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. |
|
||||
| [`schema-form/`](schema-form/README.md) | Provides schema-backed draft handling for settings editors. |
|
||||
| [`test-runtime/`](test-runtime/README.md) | Provides shared repository test support for client feature packages. |
|
||||
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
|
||||
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
|
||||
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
|
||||
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
|
||||
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
|
||||
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
|
||||
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
|
||||
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.
|
||||
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
|
||||
|
||||
@@ -2,33 +2,38 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`。
|
||||
dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 UI 服务和特性插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。除 `test-runtime` 外,均为命名成 `@deepseek-ai/dsh-client-<name>` 的**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx 键/slot |
|
||||
|---|---|---|
|
||||
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
|
||||
| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
|
||||
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
|
||||
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
|
||||
| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | 仅开发用的外部脚本加载型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
|
||||
| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
|
||||
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
|
||||
| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) |
|
||||
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) |
|
||||
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
|
||||
| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` |
|
||||
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) |
|
||||
| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) |
|
||||
| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) |
|
||||
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) |
|
||||
| 包 | 目的 |
|
||||
|---|---|
|
||||
| [`web/`](web/README.md) | 从客户端条目图启动浏览器 shell。 |
|
||||
| [`modules/`](modules/README.md) | 加载浏览器侧客户端模块。 |
|
||||
| [`web-react/`](web-react/README.md) | 连接 shell 运行时与 React 渲染。 |
|
||||
| [`connection/`](connection/README.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 |
|
||||
| [`runtime/`](runtime/README.md) | 为会话、Workspace 和 UI 组合提供共享客户端服务。 |
|
||||
| [`hmr/`](hmr/README.md) | 在开发期间刷新客户端插件。 |
|
||||
| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 |
|
||||
| [`schema-form/`](schema-form/README.md) | 为设置编辑器提供 schema 驱动的草稿处理。 |
|
||||
| [`test-runtime/`](test-runtime/README.md) | 为客户端特性包提供共享的仓库测试支持。 |
|
||||
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 特性注册和组合扩展 slot 的方式。 |
|
||||
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
|
||||
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
|
||||
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
|
||||
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
|
||||
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
|
||||
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
|
||||
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
|
||||
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。
|
||||
每个子文档负责自身的契约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: faf093964a740092983e13bf88f2cccd853c3e36
|
||||
README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
|
||||
@@ -2,20 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## `/api` WebSocket downlinks
|
||||
|
||||
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
|
||||
@@ -26,5 +22,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
|
||||
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
|
||||
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
|
||||
|
||||
@@ -2,20 +2,16 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。真实浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;fixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
|
||||
@@ -26,5 +22,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
|
||||
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
|
||||
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。
|
||||
|
||||
@@ -12,7 +12,7 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
@@ -339,7 +339,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
}
|
||||
|
||||
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
|
||||
* mixing reasoning blocks / tool call+result / steering / context. */
|
||||
* mixing reasoning blocks / tool call+result / context. */
|
||||
function buildAlphaLog(): SessionEvent[] {
|
||||
const events: Record<string, unknown>[] = []
|
||||
let time = Date.now() - 3_600_000
|
||||
@@ -359,7 +359,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
return seq
|
||||
}
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
const userSeq = push({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)),
|
||||
@@ -393,9 +393,6 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
}
|
||||
if (turn % 13 === 6) {
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}:fixture steering 消息。`)) } })
|
||||
}
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
|
||||
@@ -403,7 +400,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// stays presenter-less as the unknown fallback.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
@@ -440,7 +437,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
+ 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n'
|
||||
+ 'return { listing, demo }'
|
||||
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
@@ -685,7 +682,7 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` set the wanted target (`off` → false, else true);
|
||||
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
|
||||
* boundary (the fixture's agent/step parallel).
|
||||
* boundary (the fixture's step/start parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
@@ -901,13 +898,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
if (!Object.hasOwn(values, 'title')) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
|
||||
}
|
||||
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
|
||||
if (type === 'user/message') {
|
||||
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
|
||||
if (source?.kind === 'goal' && source.round === 0) {
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
|
||||
}
|
||||
return []
|
||||
// The goal domain's own durable change advances its projection.
|
||||
if (type === 'goal/change') {
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
|
||||
}
|
||||
// Standing-plan fold: writes replace the list; turn/start clears it (null).
|
||||
if (type === 'todo/write' || type === 'turn/start') {
|
||||
@@ -961,7 +954,7 @@ function pageOf(
|
||||
const event = log[i]
|
||||
/* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */
|
||||
if (event === undefined) break
|
||||
if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++
|
||||
if (event.type === 'user/message' || event.type === 'assistant/message') messages++
|
||||
if (event.type === 'turn/start' && messages >= maxMessages) {
|
||||
start = i
|
||||
break
|
||||
@@ -990,11 +983,11 @@ function searchBlockText(block: ContentBlock): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** One current-surface user/assistant/steering document, if searchable. */
|
||||
/** One current-surface user/assistant document, if searchable. */
|
||||
function searchEventText(event: SessionEvent): string {
|
||||
const content = event.type === 'user/message'
|
||||
? event.data.content
|
||||
: event.type === 'assistant/message' || event.type === 'steering/message'
|
||||
: event.type === 'assistant/message'
|
||||
? event.data.message.content
|
||||
: undefined
|
||||
if (content === undefined) return ''
|
||||
@@ -1140,7 +1133,7 @@ interface FxGoalProjection {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One durable goal change riding a round-zero goal-sourced user message. */
|
||||
/** One durable goal change. */
|
||||
type FxGoalChange =
|
||||
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
|
||||
| {
|
||||
@@ -1161,14 +1154,10 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const event = log[i] as unknown as {
|
||||
type: string
|
||||
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
|
||||
data?: FxGoalChange
|
||||
} | undefined
|
||||
if (event === undefined || event.type !== 'user/message') continue
|
||||
const source = event.data?.source
|
||||
if (source?.kind !== 'goal' || source.round !== 0) continue
|
||||
const change = source.change
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (change === undefined || change.kind !== 'goal/change') continue
|
||||
if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue
|
||||
const change = event.data
|
||||
if (change.operation === 'clear') return null
|
||||
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
}
|
||||
@@ -1419,20 +1408,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
|
||||
}
|
||||
|
||||
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
|
||||
/** Append one durable goal/change (host GoalService parallel). */
|
||||
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
|
||||
const ref = change.operation === 'clear' ? change.cleared : change.goal
|
||||
const payload = change.operation === 'clear'
|
||||
? { cleared: change.cleared, clearedAt: change.clearedAt }
|
||||
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
const log = logOf(id)
|
||||
append(id, {
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(
|
||||
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
|
||||
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
|
||||
),
|
||||
type: 'goal/change',
|
||||
data: change,
|
||||
})
|
||||
return backscanGoal(logOf(id)) as FxGoalProjection
|
||||
return backscanGoal(log) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
@@ -1583,23 +1566,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
nextTurn.set(sessionId, turn + 1)
|
||||
retryScenarios.set(sessionId, { turn, stepStarted: true })
|
||||
setRunning(sessionId, true)
|
||||
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(sessionId, { type: 'turn/start', data: { turn } })
|
||||
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
|
||||
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
|
||||
},
|
||||
/** Record one retry decision, then open the next retry turn. */
|
||||
/** Record one retry decision; the next attempt remains in the same step. */
|
||||
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
if (!scenario.stepStarted) {
|
||||
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
|
||||
scenario.stepStarted = true
|
||||
}
|
||||
const failure = { code: 'TRANSPORT', message: '连接被重置' }
|
||||
@@ -1611,14 +1591,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
retry, maxRetries: 2, delayMs, failure,
|
||||
},
|
||||
})
|
||||
append(sessionId, {
|
||||
type: 'turn/end',
|
||||
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
|
||||
})
|
||||
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
|
||||
nextTurn.set(sessionId, next + 1)
|
||||
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
|
||||
scenario.turn = next
|
||||
scenario.stepStarted = false
|
||||
},
|
||||
/** Record one retry decision, then cancel its source turn before the retry starts. */
|
||||
@@ -1635,17 +1607,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
retry: 1, maxRetries: 2, delayMs, failure,
|
||||
},
|
||||
})
|
||||
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
} })
|
||||
retryScenarios.delete(sessionId)
|
||||
setRunning(sessionId, false)
|
||||
},
|
||||
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
|
||||
/** Finish the timing-hook retry with a finalized response in the open step. */
|
||||
completeModelRetry(id: string): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
retryScenarios.delete(sessionId)
|
||||
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: {
|
||||
turn: scenario.turn,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'text' },
|
||||
} })
|
||||
append(sessionId, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: 'append',
|
||||
@@ -1944,17 +1922,15 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
summary.blank = false
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: insert a steering message into the current turn; the replay continues.
|
||||
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
|
||||
const turn = (nextTurn.get(id) ?? 1) - 1
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } })
|
||||
// Steering: the durable user/message lands inside the current turn; the replay continues.
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
nextTurn.set(id, turn + 1)
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
// Boundary flush parallel (the host's agent/step seam): an outstanding
|
||||
append(id, { type: 'turn/start', data: { turn } })
|
||||
// Boundary flush parallel (the host's step/start observer): an outstanding
|
||||
// /plan selection commits as plan/mode inside the opened turn.
|
||||
const plan = foldPlan(logOf(id))
|
||||
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
||||
@@ -2390,6 +2366,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// editor; real schema-driven forms ride the HTTP transport.
|
||||
describe: request => ok(request, {
|
||||
writable: true,
|
||||
hasDocument: true,
|
||||
namespaces: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
@@ -2399,6 +2376,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
revision: 0,
|
||||
}],
|
||||
}),
|
||||
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
|
||||
openDocument: request => ok(request, { opened: true as const }),
|
||||
update: request => err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: 'fixture: the minimal readiness settings descriptor is read-only',
|
||||
@@ -2549,6 +2528,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'goal.complete': return this.api.goals.complete(request)
|
||||
case 'goal.clear': return this.api.goals.clear(request)
|
||||
case 'settings.describe': return this.api.settings.describe(request)
|
||||
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
|
||||
case 'settings.update': return this.api.settings.update(request)
|
||||
case 'settings.replace': return this.api.settings.replace(request)
|
||||
case 'settings.mutate': return this.api.settings.mutate(request)
|
||||
|
||||
@@ -18,7 +18,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
|
||||
@@ -53,6 +53,7 @@ const PRIVILEGED_METHODS = new Set([
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
'settings.openDocument',
|
||||
'settings.update',
|
||||
'settings.replace',
|
||||
'settings.mutate',
|
||||
|
||||
@@ -181,7 +181,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('createFixtureApi', () => {
|
||||
expect(idleCancel.result).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
|
||||
it('steer during a replay lands a user/message inside the current turn and the replay continues', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
@@ -324,7 +324,7 @@ describe('createFixtureApi', () => {
|
||||
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
|
||||
const frames = await framesPromise
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types).toContain('steering/message')
|
||||
expect(JSON.stringify(frames)).toContain('插话')
|
||||
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
||||
})
|
||||
|
||||
@@ -372,7 +372,7 @@ describe('createFixtureApi', () => {
|
||||
}))
|
||||
const frames = await framesPromise
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
|
||||
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert
|
||||
})
|
||||
|
||||
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
|
||||
@@ -1008,6 +1008,21 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
// complete → complete is an invalid transition.
|
||||
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
|
||||
|
||||
const goalHistory = await client.sessions.history({ sessionId: id })
|
||||
if (!goalHistory.result.ok) throw new Error('goal history failed')
|
||||
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
|
||||
type: string
|
||||
data: {
|
||||
operation?: string
|
||||
source?: { kind?: string; round?: number }
|
||||
}
|
||||
})
|
||||
const goalChanges = goalEvents.filter(event => event.type === 'goal/change')
|
||||
expect(goalChanges.map(event => event.data.operation))
|
||||
.toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear'])
|
||||
expect(goalEvents.some(event => event.type === 'user/message'
|
||||
&& event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('connection node half', () => {
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
@@ -218,7 +218,7 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
|
||||
@@ -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/hmr/README.md
|
||||
README.md: f91a6c6f685c88a1ea19312985ad3e933222192a
|
||||
README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a
|
||||
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
|
||||
README.zh.md: fc4100c48e5db9ed6781117dd652232bbd7c7aaa
|
||||
|
||||
@@ -17,5 +17,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary.
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新。
|
||||
|
||||
@@ -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/locale/README.md
|
||||
README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce
|
||||
README.zh.md: 288982b1247f93fa1e8578a9ece2fcf8ed86666d
|
||||
README.md: f1efefde4557e1c29c0556f8b670f1534430ab79
|
||||
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291
|
||||
|
||||
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
|
||||
- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly.
|
||||
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
|
||||
|
||||
@@ -14,5 +14,5 @@ locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。
|
||||
- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。
|
||||
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 0d9935571bf84a3ce299a2dd37a81894412ab536
|
||||
README.zh.md: 6b6c129ee24ee6a82a21e50924b170c4b9725445
|
||||
README.md: 45ca8d41bb6d3557cd1e476d51f4470ce73484e8
|
||||
README.zh.md: 27fd9c8bfb168e85762c2d03ce7fc13d39977a33
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
@@ -20,15 +20,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
## Pending queue projection
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot and carries both queued and pending-steering occurrences with their resolved placement. Each row carries its `InboxItemId`, stable `MessageId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection, while an accepted live `steering/message` event retires only the first matching current steering occurrence so the durable node can take over before the following Host snapshot; history replay never consumes a later occurrence that reused the same `MessageId`. Reconnect buffering retains only the latest snapshot, and neither ordinary durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`.
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
|
||||
## The human transcript
|
||||
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
|
||||
@@ -56,10 +56,6 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
|
||||
|
||||
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
|
||||
|
||||
## Addressed subagent conversations
|
||||
|
||||
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
|
||||
@@ -70,6 +66,6 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
@@ -20,15 +20,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
## 待处理队列投影
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照,携带 queued 与待处理 steering(中途引导)单次入队项及其已解析 placement。每行都携带其 `InboxItemId`、稳定的 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;已接纳的实时 `steering/message` 事件则只退役第一个匹配的当前 steering 单次入队项,让持久节点能在下一份 Host 快照之前接管,而历史回放绝不会消费后来复用同一 `MessageId` 的单次入队项。重连缓冲只保留最新快照,普通持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。
|
||||
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。
|
||||
|
||||
## 面向人的 transcript(文本记录)
|
||||
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
@@ -56,10 +56,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
|
||||
|
||||
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
|
||||
|
||||
## 已寻址的 subagent 对话
|
||||
|
||||
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;Host 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
@@ -70,6 +66,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
|
||||
- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
|
||||
baseSeq: number
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
@@ -17,11 +19,17 @@ export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the tail and exhaust every available older page.
|
||||
* @param signal - Consumer lifetime; abort is observed between page requests.
|
||||
* @returns When the available ledger is complete or stops advancing.
|
||||
* Load the current tail without reading older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When the tail is ready or loading fails.
|
||||
*/
|
||||
loadAll(signal?: AbortSignal): Promise<void>
|
||||
loadTail(signal?: AbortSignal): Promise<void>
|
||||
/**
|
||||
* Prepend one older page when the current window has a predecessor.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns Whether the loaded window advanced.
|
||||
*/
|
||||
loadOlder(signal?: AbortSignal): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
InboxItemId, QueueAction, RpcResult, SessionId,
|
||||
MessageId, QueueAction, RpcResult, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
@@ -44,7 +44,7 @@ export interface ISession {
|
||||
* @param action - requested queue operation.
|
||||
* @returns acceptance, or a business/transport error.
|
||||
*/
|
||||
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Cancel the running turn. Pending queued work remains and resumes in FIFO
|
||||
* order after the Host reaches cancellation quiescence.
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
|
||||
import { SteeringHistory } from '../sessions/steering-history.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
@@ -50,18 +51,10 @@ function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
@@ -85,27 +78,63 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']):
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
function foldContexts(
|
||||
events: readonly SessionEvent[],
|
||||
): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const originalSeqs: number[] = []
|
||||
const rebasedSeqByOriginal = new Map<number, number>()
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
const originalNodes = () => surface.nodes.map((seq) => {
|
||||
const original = originalSeqs[seq]
|
||||
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
|
||||
return original
|
||||
})
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
if (!isSurfaceEvent(event)) continue
|
||||
if (event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
nodes: originalNodes(),
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
const rebasedSeq = replay.length
|
||||
const {
|
||||
sourceEventSeqs: rawSources,
|
||||
...eventWithoutSources
|
||||
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
|
||||
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
|
||||
const rebased = rebasedSeqByOriginal.get(seq)
|
||||
return rebased === undefined ? [] : [rebased]
|
||||
})
|
||||
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
|
||||
? undefined
|
||||
: mappedSourceEventSeqs
|
||||
const surfaceOp = event.surfaceOp === 'append'
|
||||
? event.surfaceOp
|
||||
: {
|
||||
...event.surfaceOp,
|
||||
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
|
||||
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
|
||||
}
|
||||
originalSeqs.push(event.seq)
|
||||
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
|
||||
replay.push({
|
||||
...eventWithoutSources,
|
||||
seq: rebasedSeq,
|
||||
surfaceOp,
|
||||
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
|
||||
} as SessionEvent)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
nodes: originalNodes(),
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
@@ -120,6 +149,7 @@ function materializeNode(
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
steering: boolean,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -131,6 +161,13 @@ function materializeNode(
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
@@ -147,12 +184,6 @@ function materializeNode(
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.message.id,
|
||||
seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
@@ -334,11 +365,13 @@ export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const steeringHistory = new SteeringHistory()
|
||||
const steeringSeqs = new Set<number>()
|
||||
for (const event of events) {
|
||||
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
}
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const padded = [
|
||||
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
|
||||
...events,
|
||||
]
|
||||
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
@@ -408,7 +441,7 @@ export function projectConversationHistory(
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = padded[seq]
|
||||
const event = eventsBySeq.get(seq)
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
@@ -416,6 +449,7 @@ export function projectConversationHistory(
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
steeringSeqs.has(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
@@ -434,7 +468,7 @@ export function projectConversationHistory(
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(padded).map((context): ConversationContext => {
|
||||
contexts = foldContexts(events).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
@@ -447,7 +481,7 @@ export function projectConversationHistory(
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = padded[context.originSeq]
|
||||
const originEvent = eventsBySeq.get(context.originSeq)
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
|
||||
@@ -6,7 +6,9 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import {
|
||||
compactHistoryInspectionEntries, createHistoryInspection,
|
||||
} from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
@@ -18,7 +20,8 @@ function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: readonly HistoryEntry[] = []
|
||||
private entries: HistoryEntry[] = []
|
||||
private inspectionEntries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
@@ -36,7 +39,6 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private streamPublishToken: object | null = null
|
||||
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
|
||||
private streamPartial: PartialAccumulator | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
@@ -73,37 +75,29 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the tail and exhaust all available older pages.
|
||||
* Load the current tail without reading older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When paging completes, fails to advance, or is aborted.
|
||||
* @returns When the tail is ready or loading fails.
|
||||
*/
|
||||
async loadAll(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted === true) return
|
||||
async loadTail(signal?: AbortSignal): Promise<void> {
|
||||
if (isAborted(signal)) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
|
||||
private async loadForConsumers(): Promise<void> {
|
||||
/**
|
||||
* Prepend one older page when the current window has a predecessor.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns Whether the loaded window advanced.
|
||||
*/
|
||||
async loadOlder(signal?: AbortSignal): Promise<boolean> {
|
||||
if (isAborted(signal)) return false
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
this.hasConsumer()
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
if (isAborted(signal)) return false
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlderPage()
|
||||
return this.baseSeq !== previousBaseSeq
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,12 +138,13 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.inspectionEntries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.publishDirtyNow()
|
||||
void this.loadForConsumers()
|
||||
void this.open()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
@@ -161,7 +156,6 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
this.streamPublishToken = null
|
||||
this.streamBaseInspection = null
|
||||
this.streamPartial = null
|
||||
}
|
||||
|
||||
@@ -234,7 +228,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlder(): Promise<void> {
|
||||
private loadOlderPage(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
@@ -260,6 +254,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
@@ -291,6 +286,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
@@ -324,7 +320,11 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries = [...this.entries, entry]
|
||||
this.entries.push(entry)
|
||||
this.inspectionEntries = [...this.inspectionEntries, entry]
|
||||
if (entry.event.type === 'assistant/message') {
|
||||
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a chunk against the cached finalized projection; false means no visible publish. */
|
||||
@@ -336,11 +336,10 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
if (!isVisibleAssistantChunk(chunk.type)) {
|
||||
const inspection = this.currentInspection()
|
||||
this.appendLive(entry)
|
||||
this.inspectionCache = { entries: this.entries, value: inspection }
|
||||
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
|
||||
return false
|
||||
}
|
||||
const base = this.streamBaseInspection ?? this.currentInspection()
|
||||
this.streamBaseInspection = base
|
||||
const base = this.currentInspection()
|
||||
if (
|
||||
this.streamPartial === null
|
||||
|| this.streamPartial.turn !== turn
|
||||
@@ -356,7 +355,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.streamPartial.push(chunk)
|
||||
this.appendLive(entry)
|
||||
this.inspectionCache = {
|
||||
entries: this.entries,
|
||||
entries: this.inspectionEntries,
|
||||
value: { ...base, partial: this.streamPartial.toPartial() },
|
||||
}
|
||||
return true
|
||||
@@ -382,7 +381,6 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
|
||||
private publishDirtyNow(): void {
|
||||
this.streamPublishToken = null
|
||||
this.streamBaseInspection = null
|
||||
this.streamPartial = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -415,14 +413,15 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
baseSeq: this.baseSeq,
|
||||
inspection: this.currentInspection(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspection pinned to the source's current immutable entry array. */
|
||||
private currentInspection(): SessionHistorySnapshot['inspection'] {
|
||||
if (this.inspectionCache?.entries !== this.entries) {
|
||||
const entries = this.entries
|
||||
if (this.inspectionCache?.entries !== this.inspectionEntries) {
|
||||
const entries = this.inspectionEntries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
|
||||
@@ -103,15 +103,14 @@ export interface AssistantMessageNode {
|
||||
interrupted?: true
|
||||
}
|
||||
|
||||
/** A steering message injected mid-turn. */
|
||||
/** A human message admitted from the next-step inbox while a turn was running. */
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
/** Stable identity shared with its pre-admission inbox occurrence. */
|
||||
/** Stable message identity shared with its pre-admission inbox occurrence. */
|
||||
messageId: MessageId
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
@@ -281,11 +280,11 @@ export interface RunningToolCall {
|
||||
|
||||
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedMessage {
|
||||
readonly id: InboxItemId
|
||||
readonly id: MessageId
|
||||
/** Stable message identity used for transient-to-durable steering handoff. */
|
||||
readonly messageId: MessageId
|
||||
/** Agent-resolved placement; only queued rows accept queue mutations. */
|
||||
readonly placement: 'queued' | 'steering'
|
||||
readonly placement: 'queued' | 'steering' | 'context'
|
||||
/** Complete content used to render pending steering before it becomes durable. */
|
||||
readonly content: readonly ContentBlock[]
|
||||
readonly preview: string
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Convert a durable failure into copy that is safe to expose in the GUI.
|
||||
* @param failure - Structured failure preserved by the session event.
|
||||
* @param failure - Failure value preserved by the session event.
|
||||
* @returns Display-safe copy for client projections.
|
||||
*/
|
||||
export function displayFailureMessage(failure: { code?: string; message: string }): string {
|
||||
export function displayFailureMessage(failure: unknown): string {
|
||||
if (failure === null || typeof failure !== 'object') return String(failure)
|
||||
const record = failure as { code?: unknown; message?: unknown }
|
||||
// Provider AUTH messages may echo a masked or partially preserved credential.
|
||||
// Keep the raw diagnostic in the session log, but never project it into UI state.
|
||||
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
|
||||
if (record.code === 'AUTH') return 'API key is invalid'
|
||||
return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,24 @@ import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
|
||||
const event = entry.event
|
||||
if (event.type !== 'assistant/chunk') return false
|
||||
switch (event.data.chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return event.data.chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
@@ -19,6 +37,47 @@ export interface SessionHistoryInspection {
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove completed-step token payloads that no inspection projection reads.
|
||||
* The first visible token preserves timing, usage chunks preserve accounting,
|
||||
* and unfinished steps retain every chunk for live or interrupted content.
|
||||
* @param entries - Contiguous raw history entries in sequence order.
|
||||
* @returns A projection-equivalent, usually much smaller entry ledger.
|
||||
*/
|
||||
export function compactHistoryInspectionEntries(
|
||||
entries: readonly HistoryEntry[],
|
||||
): readonly HistoryEntry[] {
|
||||
const completedSteps = new Set<string>()
|
||||
for (const { event } of entries) {
|
||||
if (event.type === 'assistant/message') {
|
||||
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
|
||||
}
|
||||
}
|
||||
|
||||
const firstTokenSteps = new Set<string>()
|
||||
const compacted: HistoryEntry[] = []
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const event = entry.event
|
||||
if (event.type !== 'assistant/chunk') {
|
||||
compacted.push(entry)
|
||||
continue
|
||||
}
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
|
||||
compacted.push(entry)
|
||||
continue
|
||||
}
|
||||
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
|
||||
firstTokenSteps.add(key)
|
||||
compacted.push(entry)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? compacted : entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
|
||||
|
||||
@@ -94,7 +94,8 @@ export interface RequestInspectionSnapshot {
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection.
|
||||
* top-level collection. A leading resume/change header exposes its prompt but
|
||||
* cannot project a change until the preceding header enters the window.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
@@ -218,6 +219,7 @@ function promptChange(
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
if (previous === undefined && event.data.reason !== 'initial') return
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
@@ -240,6 +242,7 @@ function promptChange(
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
const lastStepByTurn = new Map<number, string>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
@@ -266,6 +269,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
lastStepByTurn.set(turn, key)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
@@ -358,12 +362,15 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
|
||||
const reason = sourceEvent.data.reason
|
||||
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
|
||||
})
|
||||
if (sourceEvent.type === 'turn/end') {
|
||||
const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
|
||||
if (sourceEvent.data.reason.kind === 'error') {
|
||||
updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage(sourceEvent.data.reason.error),
|
||||
})
|
||||
}
|
||||
lastStepByTurn.delete(sourceEvent.data.turn)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
@@ -98,6 +98,8 @@ export class Session implements SessionFace {
|
||||
private readonly transcript = new TranscriptAdapter()
|
||||
private partial: PartialAccumulator | null = null
|
||||
private openCalls = new Map<string, RunningToolCall>()
|
||||
/** Last entered step per turn, folded from step/start for terminal error placement. */
|
||||
private lastStepByTurn = new Map<number, number>()
|
||||
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
|
||||
* Derived from window events and rebuilt with partial/openCalls; the transcript is
|
||||
* seq-monotonic, so a plain seq merge preserves event order. */
|
||||
@@ -271,7 +273,7 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/** Apply one operation to a still-pending queue occurrence. */
|
||||
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
|
||||
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
|
||||
try {
|
||||
return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result
|
||||
} catch (error) {
|
||||
@@ -664,11 +666,12 @@ export class Session implements SessionFace {
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
|
||||
/** Retire the first matching live steering occurrence when its durable event takes over. */
|
||||
/** Retire the first matching live steering occurrence when its durable message takes over. */
|
||||
private handoffPendingSteering(event: SessionEvent): void {
|
||||
if (event.type !== 'steering/message') return
|
||||
if (event.type !== 'user/message') return
|
||||
const message = event.data
|
||||
const index = this.queued.findIndex(item =>
|
||||
item.placement === 'steering' && item.messageId === event.data.message.id)
|
||||
item.placement === 'steering' && item.messageId === message.id)
|
||||
if (index === -1) return
|
||||
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
|
||||
this.queueRev++
|
||||
@@ -803,14 +806,17 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
case 'turn/start':
|
||||
this.lastStepByTurn.set(event.data.turn, 0)
|
||||
this.turnTimings.set(event.data.turn, { startTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
|
||||
return
|
||||
}
|
||||
case 'step/start':
|
||||
this.lastStepByTurn.set(event.data.turn, event.data.step)
|
||||
return
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
this.settleScheduledRetry('started', turn)
|
||||
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
|
||||
this.partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
@@ -837,6 +843,7 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0
|
||||
const timing = this.turnTimings.get(event.data.turn)
|
||||
if (timing !== undefined) {
|
||||
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
|
||||
@@ -844,25 +851,26 @@ export class Session implements SessionFace {
|
||||
}
|
||||
this.turnEnds.set(event.data.turn, event.seq)
|
||||
this.turnEndsRev++
|
||||
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
|
||||
if (event.data.reason.kind === 'aborted') {
|
||||
this.settleScheduledRetry('cancelled', event.data.turn)
|
||||
}
|
||||
if (
|
||||
event.data.reason.kind === 'error'
|
||||
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
|
||||
) {
|
||||
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
|
||||
const failure = event.data.reason.error
|
||||
this.derivedNodes.push({
|
||||
kind: 'turn-error',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: event.data.turn,
|
||||
step: event.data.reason.step,
|
||||
step: lastStep,
|
||||
message: displayFailureMessage(failure),
|
||||
...(failure.code === undefined ? {} : { code: failure.code }),
|
||||
code: failure.code,
|
||||
})
|
||||
this.derivedRev++
|
||||
}
|
||||
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
|
||||
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
|
||||
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
|
||||
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
|
||||
@@ -897,6 +905,7 @@ export class Session implements SessionFace {
|
||||
})
|
||||
this.derivedRev++
|
||||
}
|
||||
this.lastStepByTurn.delete(event.data.turn)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -931,6 +940,7 @@ export class Session implements SessionFace {
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
this.lastStepByTurn.clear()
|
||||
this.callsRev++
|
||||
this.derivedNodes = []
|
||||
this.derivedRev++
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
type InboxTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/** Minimal pending identity retained while replaying durable inbox splices. */
|
||||
interface PendingIdentity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/** Client-side structural view of the host-owned inbox event. */
|
||||
interface InboxSplice {
|
||||
readonly target: InboxTarget
|
||||
readonly start: number
|
||||
readonly removedCount?: number
|
||||
readonly inserted: readonly PendingIdentity[]
|
||||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally identifies `user/message` events claimed from the next-step
|
||||
* inbox. The agent loop records all admitted input as `user/message`; the
|
||||
* preceding `agent/inbox/spliced` events preserve whether it came from the
|
||||
* queued-turn list or the next-step list.
|
||||
*/
|
||||
export class SteeringHistory {
|
||||
private readonly inbox: Record<InboxTarget, PendingIdentity[]> = {
|
||||
'next-turn': [],
|
||||
'next-step': [],
|
||||
}
|
||||
|
||||
private readonly claimedNextStep = new Set<string>()
|
||||
|
||||
/** Clear all replay state before rebuilding a history window. */
|
||||
reset(): void {
|
||||
this.inbox['next-turn'] = []
|
||||
this.inbox['next-step'] = []
|
||||
this.claimedNextStep.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one event and report whether it is a durable human steering message.
|
||||
* @param event - next raw session event in sequence order.
|
||||
* @returns true only for a user-origin message previously claimed from `next-step`.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if ((event.type as string) === 'agent/inbox/spliced') {
|
||||
this.applySplice(event.data as unknown as InboxSplice)
|
||||
return false
|
||||
}
|
||||
if (event.type !== 'user/message') return false
|
||||
const id = event.data.id
|
||||
if (!this.claimedNextStep.delete(id)) return false
|
||||
return event.data.source.kind === 'user'
|
||||
}
|
||||
|
||||
/** Replay one host-validated inbox splice. */
|
||||
private applySplice({ target, start, removedCount = 0, inserted, outcome }: InboxSplice): void {
|
||||
const removed = this.inbox[target].splice(start, removedCount, ...inserted)
|
||||
for (const identity of inserted) this.claimedNextStep.delete(identity.id)
|
||||
if (target !== 'next-step' || outcome === 'canceled') return
|
||||
for (const identity of removed) this.claimedNextStep.add(identity.id)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/d
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import { contextForm, contextProvenance } from './context-provenance.ts'
|
||||
import { SteeringHistory } from './steering-history.ts'
|
||||
|
||||
/**
|
||||
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
|
||||
@@ -30,7 +31,6 @@ import { contextForm, contextProvenance } from './context-provenance.ts'
|
||||
* import stays type-only because a value import would fail the client purity
|
||||
* gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
|
||||
* forbidden in a browser bundle — while an erased type never reaches it.
|
||||
* `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
|
||||
*/
|
||||
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
|
||||
@@ -46,11 +46,12 @@ interface CallIndexEntry {
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
|
||||
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
steering: boolean,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -65,6 +66,13 @@ function materializeNode(
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
@@ -75,12 +83,6 @@ function materializeNode(
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.message.id,
|
||||
seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
@@ -182,6 +184,8 @@ export class TranscriptAdapter {
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
|
||||
private readonly steeringHistory = new SteeringHistory()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so it is not a surface
|
||||
@@ -210,6 +214,8 @@ export class TranscriptAdapter {
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
this.steeringHistory.reset()
|
||||
const steeringSeqs = new Set<number>()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -217,12 +223,13 @@ export class TranscriptAdapter {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
}
|
||||
// Indexes first, then project: a tool/result materializes against the
|
||||
// complete call index, and a checkpoint against the complete event index.
|
||||
const projected: ConversationNode[] = []
|
||||
for (const event of events) {
|
||||
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
|
||||
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
|
||||
}
|
||||
this.projected = projected
|
||||
}
|
||||
@@ -239,9 +246,10 @@ export class TranscriptAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, view)
|
||||
const steering = this.steeringHistory.apply(event)
|
||||
if (this.indexCommand(event)) this.rev++
|
||||
if (!isTranscriptEvent(event)) return
|
||||
this.projected = [...this.projected, this.materialize(event)]
|
||||
this.projected = [...this.projected, this.materialize(event, steering)]
|
||||
this.rev++
|
||||
}
|
||||
|
||||
@@ -274,10 +282,10 @@ export class TranscriptAdapter {
|
||||
}
|
||||
|
||||
/** Materialize one transcript event against the complete current indexes. */
|
||||
private materialize(event: SessionEvent): ConversationNode {
|
||||
private materialize(event: SessionEvent, steering: boolean): ConversationNode {
|
||||
return isCompactCheckpoint(event)
|
||||
? materializeCompaction(event, this.eventIndex)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, steering)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,15 +94,19 @@ export class WorkspacesService implements IWorkspaces {
|
||||
// would miss the reuse scan and mint another hidden blank session.
|
||||
const inflight = this.connecting.get(workspaceId)
|
||||
if (inflight !== undefined) return inflight
|
||||
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
|
||||
// canon; summary cwd is the session header passthrough of the same canon).
|
||||
// An archived blank is never reused: reuse would open a session no
|
||||
// grouping surface can show, so New Session mints a fresh one instead.
|
||||
// Reuse requires workspace membership (id in sessionIds AND same
|
||||
// canonical cwd — the host's own membership rule), never cwd alone:
|
||||
// a cwd match can belong to no account (sessions the CLI/TUI birthed at
|
||||
// the host cwd, or a deleted/recreated registration) and reusing it
|
||||
// would open a session no grouping surface shows under this workspace.
|
||||
// An archived blank is never reused either: reuse would open a session
|
||||
// no grouping surface can show, so New Session mints a fresh one instead.
|
||||
const archived = this.list.getSnapshot().archivedSessionIds
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
for (const id of sessions.ids) {
|
||||
const summary = sessions.byId[id]
|
||||
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
|
||||
&& workspace.sessionIds.includes(summary.id)
|
||||
&& !archived.includes(summary.id)) return summary.id
|
||||
}
|
||||
const attempt = this.sessions.create({ workspaceId })
|
||||
|
||||
@@ -12,7 +12,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
|
||||
export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
at(seq, { type: 'turn/start', data: { turn } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
@@ -82,7 +82,12 @@ export const ev = {
|
||||
},
|
||||
}),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
at(seq, { type: 'turn/end', data: {
|
||||
turn,
|
||||
reason: reason === 'completed'
|
||||
? { kind: 'completed' }
|
||||
: { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
|
||||
} }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
|
||||
@@ -216,7 +216,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
@@ -31,6 +33,57 @@ describe('projectConversationHistory', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects next-step human input as durable steering', () => {
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'change course' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const events = [
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
]
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes).toMatchObject([{
|
||||
kind: 'steering', messageId: steering.id, seq: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
|
||||
const baseSeq = 400_000
|
||||
const events = [
|
||||
ev.user(baseSeq, 'loaded tail'),
|
||||
at(baseSeq + 1, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
|
||||
sourceEventSeqs: [baseSeq],
|
||||
data: {
|
||||
turn: 80,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'tail summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
|
||||
expect(projection.contexts.map(context => ({
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ originSeq: undefined, nodes: [baseSeq] },
|
||||
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
@@ -114,4 +167,35 @@ describe('projectConversationHistory', () => {
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
ev.stepStart(1, 1, 0),
|
||||
ev.chunkStart(2, 1),
|
||||
ev.chunkText(3, 1, ''),
|
||||
ev.chunkText(4, 1, 'first'),
|
||||
ev.chunkText(5, 1, ' discarded'),
|
||||
at(6, { type: 'assistant/chunk', data: {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
|
||||
} }),
|
||||
ev.assistant(7, 1, 'first discarded'),
|
||||
ev.compactSummary(8, 'summary', 0, 7),
|
||||
ev.compactCheckpoint(9, 8, 0, 7),
|
||||
ev.stepStart(10, 2, 0),
|
||||
ev.chunkStart(11, 2),
|
||||
ev.chunkText(12, 2, 'interrupted'),
|
||||
ev.turnEnd(13, 2, 'aborted'),
|
||||
]
|
||||
const raw = events.map(event => ({ event }))
|
||||
const compacted = compactHistoryInspectionEntries(raw)
|
||||
|
||||
expect(compacted.map(entry => entry.event.seq)).toEqual([
|
||||
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
])
|
||||
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
|
||||
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
InboxItemId, MuxFrame, RpcId, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
@@ -17,7 +15,7 @@ import { FakeApiClient } from './fake-api.ts'
|
||||
const SID = 'fk-q1' as SessionId
|
||||
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
const iid = (id: string): InboxItemId => id as InboxItemId
|
||||
const iid = (id: string): MessageId => id as MessageId
|
||||
|
||||
interface QueueFixture {
|
||||
id: string
|
||||
@@ -151,16 +149,16 @@ describe('queue snapshot intake', () => {
|
||||
const durable = {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'steering/message',
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: { turn: 1, message },
|
||||
data: message,
|
||||
} as SessionEvent
|
||||
|
||||
session.handleMuxEnvelope(rid('env-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1)
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
|
||||
|
||||
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
|
||||
{ id: 's-later', body: '', placement: 'steering', message },
|
||||
@@ -170,6 +168,32 @@ describe('queue snapshot intake', () => {
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
|
||||
})
|
||||
|
||||
it('hands off live steering when the agent claims it as a user message', async () => {
|
||||
const session = makeSession()
|
||||
await session.open()
|
||||
const message = createUserMessage({
|
||||
content: text('claimed steering'),
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.handleMuxEnvelope(rid('env-claimed'), queueFrame([
|
||||
{ id: 's-claimed', body: '', placement: 'steering', message },
|
||||
]))
|
||||
|
||||
session.handleMuxEnvelope(rid('env-user-message'), {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: message,
|
||||
},
|
||||
})
|
||||
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue operation transport', () => {
|
||||
|
||||
@@ -85,6 +85,56 @@ describe('inspectRequests', () => {
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('does not promote a truncated resume or change header to the initial prompt', () => {
|
||||
for (const reason of ['resume', 'change'] as const) {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(10, 'step/start', { turn: 3, step: 1 }),
|
||||
at(11, 'request/header', {
|
||||
reason,
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'tail-window prompt',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
prompt: { system: 'tail-window prompt' },
|
||||
})
|
||||
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a prompt change once the preceding header is loaded', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'before',
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 1, step: 2 }),
|
||||
at(3, 'request/header', {
|
||||
reason: 'change',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'after',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[1]).toMatchObject({
|
||||
promptChange: {
|
||||
seq: 3,
|
||||
kind: 'system',
|
||||
previous: { system: 'before' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
@@ -225,20 +275,15 @@ describe('inspectRequests', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||
at(3, 'turn/end', {
|
||||
turn: 2,
|
||||
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
|
||||
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
}),
|
||||
]))
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
it('loads the tail first and prepends older pages on demand', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
@@ -30,10 +30,21 @@ describe('SessionHistorySource', () => {
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(1)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
expect(source.getSnapshot().baseSeq).toBe(12)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([13, 15])
|
||||
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().baseSeq).toBe(0)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
@@ -42,7 +53,7 @@ describe('SessionHistorySource', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
@@ -60,7 +71,7 @@ describe('SessionHistorySource', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
@@ -132,13 +143,14 @@ describe('SessionHistorySource', () => {
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
it('finishes an already started older page after consumer cancellation', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
@@ -151,7 +163,8 @@ describe('SessionHistorySource', () => {
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await source.loadTail(controller.signal)
|
||||
const complete = source.loadOlder(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
@@ -159,7 +172,7 @@ describe('SessionHistorySource', () => {
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
expect(await complete).toBe(true)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('live event path', () => {
|
||||
expect(published).toEqual(['累计', null])
|
||||
})
|
||||
|
||||
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
|
||||
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
@@ -215,25 +215,13 @@ describe('live event path', () => {
|
||||
ev.stepStart(8, 1),
|
||||
ev.chunkStart(9, 1),
|
||||
ev.chunkText(10, 1, '不完整回复'),
|
||||
ev.stepEnd(11, 1),
|
||||
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error', step: 0,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
|
||||
ev.stepStart(15, 2),
|
||||
ev.assistant(16, 2, '完整回复'),
|
||||
ev.stepEnd(17, 2),
|
||||
ev.turnEnd(18, 2),
|
||||
ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
ev.chunkStart(12, 1),
|
||||
ev.assistant(13, 1, '完整回复'),
|
||||
ev.stepEnd(14, 1),
|
||||
ev.turnEnd(15, 1),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 7)) feed(event)
|
||||
for (const event of retryTurn.slice(0, 6)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
@@ -252,15 +240,14 @@ describe('live event path', () => {
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(7)) feed(event)
|
||||
for (const event of retryTurn.slice(6)) feed(event)
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
const retryStart = retryTurn.find(event =>
|
||||
event.type === 'turn/start' && event.data.trigger.kind === 'retry')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include a retry turn/start')
|
||||
const retryStart = retryTurn.find(event => event.type === 'turn/start')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
@@ -285,35 +272,33 @@ describe('live event path', () => {
|
||||
const failedTurns = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '鉴权失败'),
|
||||
at(8, {
|
||||
ev.stepStart(8, 1),
|
||||
at(9, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 0,
|
||||
failure: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
data: { turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ev.turnStart(9, 2),
|
||||
ev.user(10, '内部失败'),
|
||||
at(11, {
|
||||
ev.turnStart(10, 2),
|
||||
ev.user(11, '内部失败'),
|
||||
ev.stepStart(12, 2, 1),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
|
||||
data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
|
||||
}),
|
||||
]
|
||||
for (const event of failedTurns) feed(event)
|
||||
|
||||
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
|
||||
expect(errors).toMatchObject([
|
||||
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
|
||||
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
// Every failed turn carries a structured failure; unstructured errors
|
||||
// flatten to the UNKNOWN code.
|
||||
{ seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' },
|
||||
])
|
||||
expect('code' in errors[1]!).toBe(false)
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
||||
@@ -450,7 +435,7 @@ describe('live event path', () => {
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn ends %s',
|
||||
'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
@@ -470,6 +455,24 @@ describe('live event path', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('marks a scheduled retry as started when its failed turn ends with an error', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.retry(7, 1))
|
||||
feed(at(8, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
|
||||
}))
|
||||
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'started',
|
||||
})
|
||||
})
|
||||
|
||||
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
|
||||
@@ -85,29 +85,85 @@ describe('TranscriptAdapter', () => {
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
at(2, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(7, 0, 'c1', '结果'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
|
||||
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies steering on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'live steer' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([])
|
||||
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }))
|
||||
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }))
|
||||
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
|
||||
})
|
||||
|
||||
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
|
||||
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
adapter.reset([
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, inserted: [queued],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [canceled],
|
||||
} }),
|
||||
at(4, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
} }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
|
||||
at(6, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [context],
|
||||
} }),
|
||||
at(7, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
|
||||
])
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a compact/* provenance record) and a future type core
|
||||
|
||||
@@ -149,26 +149,37 @@ describe('WorkspacesService', () => {
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
|
||||
})
|
||||
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
// Blank session already parked in alpha (cwd == workspace path canon).
|
||||
// Stray blank at alpha's path but NOT accounted under alpha (a CLI
|
||||
// session birthed at the host cwd), sorted before the member blank:
|
||||
// the scan must skip it and keep looking for a member hit.
|
||||
{ sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Blank session parked in alpha (cwd == workspace path canon AND
|
||||
// accounted under alpha): the reuse hit.
|
||||
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Non-blank sibling in beta must never be reused.
|
||||
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
|
||||
// Stray blank at gamma's path but NOT accounted under gamma (a CLI
|
||||
// session birthed at the host cwd): cwd alone must not hijack it —
|
||||
// reuse would open a session gamma cannot show, so New Session mints
|
||||
// a fresh accounted one instead.
|
||||
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
|
||||
] as never[],
|
||||
}))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
|
||||
// Hit: same workspace → the parked blank session comes back, no create RPC.
|
||||
// Hit: same workspace → the parked member blank comes back (the earlier
|
||||
// cwd-matching non-member stray is skipped), no create RPC.
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
|
||||
expect(api.callsOf('session.create')).toEqual([])
|
||||
// Resolution guarantee: the id is binding-resolvable synchronously.
|
||||
@@ -181,6 +192,12 @@ describe('WorkspacesService', () => {
|
||||
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
|
||||
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
|
||||
|
||||
// Miss: the stray blank matches gamma's path but is not a gamma member →
|
||||
// never reused, a fresh accounted session is created instead.
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
|
||||
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
|
||||
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
|
||||
@@ -196,7 +213,7 @@ describe('WorkspacesService', () => {
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
|
||||
}))
|
||||
|
||||
@@ -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/schema-form/README.md
|
||||
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
|
||||
README.zh.md: ebaa9e0d0a134a6fade5729215168a1a47fd375c
|
||||
README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac
|
||||
README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46
|
||||
|
||||
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work).
|
||||
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
|
||||
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.
|
||||
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation.
|
||||
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls.
|
||||
- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off.
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
|
||||
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一受信任 host 时才安全;该协议没有跨信任边界使用的惰性表示。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。
|
||||
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)记录该权衡。
|
||||
|
||||
@@ -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/test-runtime/README.md
|
||||
README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031
|
||||
README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e
|
||||
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
|
||||
README.zh.md: a86b9e469a5632886891628267002a14588afeaa
|
||||
|
||||
@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
|
||||
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Every consumer is an in-repository Vitest suite; there is no Node-compatible runtime entry.
|
||||
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce.
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。
|
||||
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。
|
||||
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。
|
||||
|
||||
@@ -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-command/README.md
|
||||
README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4
|
||||
README.zh.md: e5d109af8ca94515e0b574c62c57968796af5ce8
|
||||
README.md: a19bfe7135acc5408448dc73d04813ed4104dd48
|
||||
README.zh.md: 79d903b916728c1200ab1311055f2be190008787
|
||||
|
||||
@@ -22,5 +22,4 @@ None directly; this package neither assembles nor sends a provider request. Comm
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The popupSelect shell has no shipped business consumer** — model selection (host `selectModel`) is the design's reference case and lands with its own feature work; until then the shell is exercised by package tests only.
|
||||
- **Detached-result notices fall back to the console off-session** — the fire-and-forget paths route results to the triggering session's composer via `SessionInput.notify`; after session teardown the console line is the only remaining surface.
|
||||
|
||||
@@ -22,5 +22,4 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **popupSelect 壳还没有已上架的业务消费方**:模型选择(host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
|
||||
- **脱离会话后,detached result 的 notice 回退到 console**:fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器;会话拆除后,console 输出行是仅剩的呈现面。
|
||||
|
||||
@@ -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: c5f655c671fd426a82b71d0c55b46f7060a9463a
|
||||
README.zh.md: 1a502c02b1dfa074cd691153e0b24f3abed0d33b
|
||||
README.md: b0f4b259d1f01dc86df8649d7466592788e1fd65
|
||||
README.zh.md: 760ad4cdb2813a93083611595f5688f19b7d9d5c
|
||||
|
||||
@@ -6,9 +6,9 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
|
||||
|
||||
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
@@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card below its summary row; tool rows are summary surfaces, so the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which keeps the summary bounded; the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Both render sites show the same complete source list — the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), [source scroll](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)).
|
||||
|
||||
@@ -32,13 +32,13 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
|
||||
|
||||
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
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: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam. 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 plain registrant plugins with `inject: ['slots', 'conversation']`. `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.
|
||||
|
||||
`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; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` 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 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -48,7 +48,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
|
||||
|
||||
The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -62,11 +62,11 @@ 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 cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **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)).
|
||||
- **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's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **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.
|
||||
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels.
|
||||
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.
|
||||
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `user/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
视图环是一个 slot:会话注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`),视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
@@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片放在摘要行下方;工具行是摘要 surface,因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,因此摘要保持有界;面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。两个渲染点显示同一份完整来源列表——工具返回、模型看到的那一份——仅受卡片自身滚动容器的高度约束,不存在行与面板的两级上限([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)、[来源滚动](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md))。
|
||||
|
||||
@@ -30,7 +30,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam。Trajectory 与 waterfall(瀑布式事件)工具视图 slot 共享此形状并使用各自的渲染点;RendersCheck 会拒绝没有任何渲染方的声明。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`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、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
@@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/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` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 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 轮次,不显示失败,也不会丢失草稿事务。
|
||||
|
||||
@@ -69,4 +69,4 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。
|
||||
- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `steering/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。
|
||||
- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `user/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Shared IconActions chrome for user, steering, and assistant messages: copy
|
||||
// Shared IconActions chrome for user and assistant messages: copy
|
||||
// live, optional branch wiring, and an optional date-aware clock.
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
|
||||
@@ -86,8 +86,7 @@ export function messageBranchSeqs(
|
||||
tail = candidate
|
||||
nodeIndex++
|
||||
}
|
||||
if (tail?.kind === 'user'
|
||||
|| (tail?.kind === 'steering' && tail.turn === turn)
|
||||
if (tail?.kind === 'user' || tail?.kind === 'steering'
|
||||
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
|
||||
result.add(tail.seq)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user