From 1f4cfad6562914cbdb11107bc8f7e8174a8a9906 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Wed, 4 Nov 2026 20:48:40 +0800 Subject: [PATCH 001/149] fix(agent): close input admission and drain work after cancellation failures --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.md | 22 +- docs/event-producer-consumer.zh.md | 22 +- docs/subsystems/core.md | 7 +- docs/subsystems/core.zh.md | 7 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 24 +- packages/core/agent-loop/src/index.ts | 9 +- packages/core/agent-loop/tests/cancel.spec.ts | 12 +- .../tests/fixtures/serial-created.mjs | 37 +- .../agent-loop/tests/input-lifecycle.spec.ts | 151 ++++++ .../agent-loop/tests/request-freeze.spec.ts | 6 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/closed-error.ts | 15 + packages/core/agent/src/index.ts | 1 + packages/core/agent/src/runtime-types.ts | 7 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/manager.ts | 4 +- scripts/smoke-python-runtime.py | 1 + .../advanced/result.json | 460 ++++++++++++++++-- .../advanced/session.1.v4.jsonl | 24 +- .../advanced/session.2.v4.jsonl | 24 +- snapshots/sdk/serial-created/session.v4.jsonl | 21 +- 31 files changed, 762 insertions(+), 124 deletions(-) create mode 100644 packages/core/agent-loop/tests/input-lifecycle.spec.ts create mode 100644 packages/core/agent/src/closed-error.ts diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 19453e15a9..83a62a5480 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -21,8 +21,8 @@ en: 20ef882e44b06a10 zh: c10c3934ed5570e9 /deepseek-harness-architecture/events: - en: ddebac7cfd41ec73 - zh: 51e6d0866a9e7839 + en: 9a7297518f6b7bdf + zh: 883364ea3f1ac82b /deepseek-harness-architecture/turn-flow: en: 1a79a70b62c04cdb zh: c86d765e21430405 diff --git a/docs/architecture.md b/docs/architecture.md index 446c50e5bd..95eeab1b42 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,7 @@ Events provide extension points in three domains. Subagent Activations own runtime tree membership. -AgentLoop rolls back failed initialization and awaits serial `agent/created` before queued work; unload retains resources through teardown ([ordering](../packages/core/agent-loop/README.md#understand-the-implementation)). +AgentLoop rolls back failed initialization and awaits serial `agent/created` before queued work; shutdown closes input admission, cancels and waits for admitted work despite queue-cleanup failures, then releases resources ([ordering](../packages/core/agent-loop/README.md#understand-the-implementation)). [Event producers and consumers](event-producer-consumer.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 275e1c863d..936a6493ff 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -76,7 +76,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 Subagent Activation 拥有运行时树成员关系。 -AgentLoop 回滚失败的初始化,并在启动已排队工作前等待串行 `agent/created`;卸载在 teardown 期间保留所需资源([顺序](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 +AgentLoop 回滚失败的初始化,并在启动已排队工作前等待串行 `agent/created`;关闭先停止接收输入,在队列清理失败时仍取消并等待已接纳工作,然后释放资源([顺序](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 [事件生产者与消费者](event-producer-consumer.zh.md)。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7e9172cc37..a32683e672 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,17 +12,17 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:404`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:295`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:434`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:341`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:394`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:314`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:422`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:705`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:712`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 326beb8853..ff27d132ae 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -14,17 +14,17 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:404`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:295`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:434`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:341`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:394`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:314`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:422`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:705`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:712`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 40264ec898..7b8503cb1a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -78,6 +78,7 @@ interface Agent { * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no * active activity, cancellation is a no-op and does not arm later work. + * Queue-write failures still abort the activity and propagate to the caller. * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ @@ -97,7 +98,7 @@ interface Agent { * inbox until the task settles, while public status stays `idle`. * `whenIdle()` follows both the task and any waking work released behind it. * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. - * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @throws AgentClosedError after disposal begins, or an Error when another activity owns the agent. * @returns the task promise. */ runMaintenance(task: (signal: AbortSignal) => Promise): Promise @@ -112,6 +113,7 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @throws AgentClosedError after disposal begins; no input is inserted. */ send(message: UserMessage, target: InboxTarget, wakeup: boolean): void @@ -119,6 +121,7 @@ interface Agent { * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ followup(message: UserMessage): void @@ -128,6 +131,7 @@ interface Agent { * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ steer(message: UserMessage): void @@ -138,6 +142,7 @@ interface Agent { * wakes them. It may miss a request whose pre-step already claimed its * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ inject(message: UserMessage): void } diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index ff1d3b579e..9f0bf51061 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -82,6 +82,7 @@ interface Agent { * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no * active activity, cancellation is a no-op and does not arm later work. + * Queue-write failures still abort the activity and propagate to the caller. * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ @@ -101,7 +102,7 @@ interface Agent { * inbox until the task settles, while public status stays `idle`. * `whenIdle()` follows both the task and any waking work released behind it. * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. - * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @throws AgentClosedError after disposal begins, or an Error when another activity owns the agent. * @returns the task promise. */ runMaintenance(task: (signal: AbortSignal) => Promise): Promise @@ -116,6 +117,7 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @throws AgentClosedError after disposal begins; no input is inserted. */ send(message: UserMessage, target: InboxTarget, wakeup: boolean): void @@ -123,6 +125,7 @@ interface Agent { * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ followup(message: UserMessage): void @@ -132,6 +135,7 @@ interface Agent { * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ steer(message: UserMessage): void @@ -142,6 +146,7 @@ interface Agent { * wakes them. It may miss a request whose pre-step already claimed its * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ inject(message: UserMessage): void } diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 08e35ca2a9..9c73797cbb 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -39,8 +39,8 @@ en: ec2bf804bd1a5439 zh: 316e767507e859ef /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: - en: e2cb7b6101f20425 - zh: a056c43886207083 + en: e66e878db8193219 + zh: 11a79267958b2f51 /deepseek-ai-dsh-agent-loop/understand-the-implementation/persistence-integration: en: 3b48a480036bd5df zh: 28cea2036b0dccee diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 03fc5b2e16..c021ad2513 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -109,7 +109,7 @@ The loop deep-freezes each derived message identity on its first request and reu Fresh creation and resume share one rollback-covered initialization: construct the concrete agent and scoped context around the acquired Session; store constructor history, enter the SessionStore, and complete `session/created`; await optional setup and its commit; enter the AgentRegistry; await serial `agent/created` listeners; then release queued input. The caller Context owns the transaction and live handle; delegation services bind their runtime relationships during setup. Setup, commit, or listener failure and owner disposal roll back the prepared resources. The Session is writable during setup, and every removal emits `session/disposed`, including setup failure before Agent creation is announced. Failed initialization preserves restored inbox input and any events already stored; it rejects only after releasing its scope and acquired write handle. Cancellation while persistence is still creating a write handle can reject earlier; that handle closes when creation settles. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement. -The loop awaits its required registrations before publishing its factory. It calls `readerFor(session)` on its model-selection and inbox handles and passes the callbacks to the driver in one typed object; the model and inbox collaborators receive their respective callbacks. During factory unload, global registrations may disappear before teardown finishes; retained handles remain readable through scope cleanup, including cancellation from scoped abort listeners. Caller unload, factory unload, and handle disposal await the same teardown. +The loop awaits its required registrations before publishing its factory. It calls `readerFor(session)` on its model-selection and inbox handles and passes the callbacks to the driver in one typed object; the model and inbox collaborators receive their respective callbacks. During factory unload, global registrations may disappear before teardown finishes; retained handles remain readable through scope cleanup, including cancellation from scoped abort listeners. Caller unload, factory unload, and handle disposal await the same teardown. New input is rejected when teardown begins; queue-clear errors do not skip cancellation or the wait for admitted work, and resources remain available until that work settles. ### Persistence integration diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 977911bbbe..394130f0c4 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -109,7 +109,7 @@ const handle = await ctx.agents.create({ 新建和恢复共用一个受回滚保护的初始化流程:围绕已取得的 Session 构造具象 agent 与带作用域上下文;存储构造历史、进入 SessionStore 并完成 `session/created`;等待可选 setup 及其 commit;进入 AgentRegistry;等待串行 `agent/created` 监听器;随后释放已排队输入。调用方 Context 拥有事务和存活句柄;委派服务在 setup 期间绑定自己的运行时关系。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。setup 期间 Session 可写;每次移除都会发出 `session/disposed`,包括 Agent 创建宣告前的 setup 失败。初始化失败保留恢复出的收件箱输入及已存储事件,并在释放自身作用域及已取得的写句柄后才拒绝。持久化层仍在创建写句柄时发生取消,可以更早拒绝;该句柄在创建完成后关闭。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 -loop 在发布工厂前等待必需的注册完成。它对模型选择和 inbox 句柄调用 `readerFor(session)`,将读取回调放入一个类型化对象传给驱动器;模型和 inbox 小组件接收各自的回调。工厂卸载时,全局注册可以先于 teardown 完成而消失;保留句柄在整个作用域清理期间仍可读取,包括作用域内 abort 监听器发起取消的时刻。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 +loop 在发布工厂前等待必需的注册完成。它对模型选择和 inbox 句柄调用 `readerFor(session)`,将读取回调放入一个类型化对象传给驱动器;模型和 inbox 小组件接收各自的回调。工厂卸载时,全局注册可以先于 teardown 完成而消失;保留句柄在整个作用域清理期间仍可读取,包括作用域内 abort 监听器发起取消的时刻。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。teardown 开始后拒绝新输入;清队列错误不会跳过取消或等待已接纳工作,资源会保留到该工作结束。 ### 持久化集成 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c4daa50c47..c23cc401e3 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -18,7 +18,7 @@ import type { PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { AgentModel, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { AgentClosedError, AgentModel, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { LlmError, @@ -141,6 +141,7 @@ export class ReactLoopAgent implements Agent { public readonly session: Session, projections: LoopProjections, lastTurn: number, + private readonly lifetimeSignal: AbortSignal, ) { this.requestSurfaceGeneration = session.surface.contentGeneration this.dispatch = agentEvents(loopCtx, this) @@ -168,6 +169,7 @@ export class ReactLoopAgent implements Agent { } send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { + this.assertOpen() // Waking input cannot join an aborted activity, so it starts the next turn. // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted @@ -189,14 +191,19 @@ export class ReactLoopAgent implements Agent { } cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { - if (!options.keepInbox) { - this.inbox.clear() - if (this.phase.kind !== 'idle') this.phase.wakeRequested = false + try { + if (!options.keepInbox) this.inbox.clear() + } finally { + // Queue persistence must not prevent cancellation of admitted work. + if (this.phase.kind !== 'idle') { + if (!options.keepInbox) this.phase.wakeRequested = false + this.phase.abort.abort(cause) + } } - if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } runMaintenance(job: (signal: AbortSignal) => Promise): Promise { + this.assertOpen() if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`) const done = Promise.withResolvers() const maintenance: Phase = { @@ -228,6 +235,7 @@ export class ReactLoopAgent implements Agent { * the inbox insertion so a reentrant cancel cannot reclassify it. */ private wakeDriver(wakeAfterAbort = false): void { + if (this.lifetimeSignal.aborted) return if (this.phase.kind !== 'idle') { // Maintenance and aborted drivers cannot deliver the wake: latch it for // replay at convergence. Live drivers claim queued work themselves; @@ -257,6 +265,12 @@ export class ReactLoopAgent implements Agent { } while (activity !== this.activityDone) } + private assertOpen(): void { + if (this.lifetimeSignal.aborted) { + throw new AgentClosedError(this.id, { cause: this.lifetimeSignal.reason }) + } + } + /** Report one failure at its live boundary, then preserve it for driver containment. */ private throwError(error: unknown): never { const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 22b3192588..a0b02d4eee 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -514,7 +514,12 @@ export class AgentLoop extends Service implements AgentFactory { if (machine === undefined) await machineReady.promise /* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */ if (machine !== undefined) { - machine.cancel({ kind: 'disposed' }, { keepInbox: !ready }) + try { + machine.cancel({ kind: 'disposed' }, { keepInbox: !ready }) + } catch (error: unknown) { + failures.push(error) + } + // A failed queue write must not release resources underneath work. await machine.whenIdle() } } catch (error: unknown) { @@ -573,6 +578,7 @@ export class AgentLoop extends Service implements AgentFactory { loopCtx, id, agentOptions, session, projections, lastTurn, + abort.signal, ) machineReady.resolve() // Adopt the scope on its original fiber so it cannot unload before teardown. @@ -636,6 +642,7 @@ export class AgentLoop extends Service implements AgentFactory { await loopCtx.agents.announce(agent, source, abort.signal) assertLive() ready = true + callerSignal?.removeEventListener('abort', onCallerAbort) return { agent, dispose } } catch (error: unknown) { // Failed initialization must not release queued input into a new turn. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c702b64e89..2ca5eb0299 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -16,7 +16,7 @@ import SessionStore, { Session, SessionId, SessionLogOffset, TurnEndReason } fro import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' -import AgentRegistry, { type Agent, type AgentCancelCause } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentClosedError, type Agent, type AgentCancelCause } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -319,14 +319,12 @@ describe('Agent.cancel()', () => { }) const agent = handle.agent + const streaming = Promise.withResolvers() + ctx.on('agent/assistant-stream', ({ frame }) => { if (frame.type === 'chunk') streaming.resolve(undefined) }) send(agent, 'active') - await new Promise(resolve => setTimeout(resolve, 30)) - - // Dispose cancels with `{ kind: 'disposed' }`; a wake landing in the - // abort-to-idle window must not latch, so `whenIdle()` does not wait on - // a model turn over the session being torn down. + await streaming.promise const disposal = handle.dispose() - setTimeout(() => { send(agent, 'late wake') }, 10) + expect(() =>{ send(agent, 'late wake') }).toThrow(AgentClosedError) await disposal expect(adapter.requests).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs index 8cc624caae..138fd7d4b6 100644 --- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -6,7 +6,42 @@ export const name = 'serial-created-fixture' export const inject = ['agents', 'sessions'] /** Install ordered creation listeners whose context must precede the first turn. */ -export function apply(ctx) { +export function apply(ctx, config = {}) { + const checkedCancellation = new WeakSet() + ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => { + if (checkedCancellation.has(agent) + || (config.checkRootCancellation === false && agent.session.header.origin !== 'subagent')) return next() + checkedCancellation.add(agent) + const pending = { + id: randomUUID(), role: 'user', + content: [{ type: 'text', text: 'Cancellation checkpoint probe.' }], + source: { kind: `plugin:${name}` }, + } + agent.inject(pending) + const failure = new Error('fixture queue-clear refusal') + const stopRejecting = ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args + if (session === agent.session && event.type === 'agent/inbox/spliced' + && event.data.outcome === 'canceled' && (event.data.removedCount ?? 0) > 0) throw failure + }) + try { + try { agent.cancel({ kind: 'user' }) } + catch (error) { if (error !== failure) throw error } + } finally { + stopRejecting() + } + if (!signal.aborted) throw new Error('queue-clear failure prevented cancellation') + agent.inbox.remove(pending.id) + for (const message of messages) { + const ordinary = message.source.kind === 'user' + const restored = message.source.kind === `plugin:${name}` + ? { ...message, content: [{ type: 'text', text: message.content[0].text + ' Queue-clear failures still cancel active work.' }] } + : message + agent.send(restored, ordinary ? 'next-turn' : 'next-step', ordinary) + } + return next() + }) const ready = new WeakSet() ctx.on('agent/created', async ({ agent }) => { await setImmediate() diff --git a/packages/core/agent-loop/tests/input-lifecycle.spec.ts b/packages/core/agent-loop/tests/input-lifecycle.spec.ts new file mode 100644 index 0000000000..2e87517e1f --- /dev/null +++ b/packages/core/agent-loop/tests/input-lifecycle.spec.ts @@ -0,0 +1,151 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { afterEach, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { AgentClosedError, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as CheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +const fixtures: { ctx: Context; agent?: Agent; root: string }[] = [] +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + try { + fixture.agent?.cancel({ kind: 'disposed' }, { keepInbox: true }) + await fixture.agent?.whenIdle() + await fixture.ctx.fiber.dispose() + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + } +}) + +const input = (text: string) => createUserMessage({ content: [{ type: 'text' as const, text }], source: { kind: 'user' as const } }) + +async function harness() { + const ctx = new Context() + const root = await mkdtemp(join(tmpdir(), 'dsh-input-atomicity-')) + const fixture: { ctx: Context; agent?: Agent; root: string } = { ctx, root } + fixtures.push(fixture) + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) + await ctx.plugin(AgentRegistry) + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + await ctx.plugin(CheckpointPolicy) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter(Array.from({ length: 5 }, () => textResponse('ok'))) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ sessionId: SessionId('diagnostic'), agentOptions: { provider: 'mock', model: 'mock' } }) + fixture.agent = handle.agent + return { ctx, root, adapter, handle, agent: handle.agent } +} + + +it('cancellation still aborts the running request when queue clearing fails', async () => { + const { ctx, agent } = await harness() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const hold = ctx.on('agent/pre-step', async ({ signal }, next) => { entered.resolve(signal); await release.promise; return next() }) + let stopRejecting = () => {} + try { + agent.followup(input('running')) + const signal = await entered.promise + agent.inject(input('steering')) + const queued = input('queued') + agent.followup(queued) + stopRejecting = ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (session === agent.session && event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn' + && (event.data.removedCount ?? 0) > 0) throw new Error('injected queue clear failure') + }) + expect(() =>{ agent.cancel({ kind: 'user' }) }).toThrow('injected queue clear failure') + expect(signal.aborted).toBe(true) + } finally { + stopRejecting() + hold() + agent.cancel({ kind: 'disposed' }, { keepInbox: true }) + release.resolve(undefined) + await agent.whenIdle() + } +}) + + +it('an Agent still in the registry rejects new input while its owner is closing', async () => { + const { ctx, agent, handle } = await harness() + const release = Promise.withResolvers() + const aborted = Promise.withResolvers() + const work = agent.runMaintenance(async (signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + await release.promise + }) + const closing = handle.dispose() + try { + await aborted.promise + expect(ctx.agents.get(agent.id)).toBe(agent) + const message = input('during closing') + for (const submit of [() =>{ agent.followup(message) }, () =>{ agent.steer(message) }, () =>{ agent.inject(message) }, () =>{ agent.send(message, 'next-turn', true) }, () => agent.runMaintenance(async () => {})]) { + expect(submit).toThrow(AgentClosedError) + } + const pending = [...agent.inbox.nextTurn, ...agent.inbox.nextStep].some(entry => entry.id === message.id) + expect(pending).toBe(false) + } finally { + release.resolve(undefined) + await work + await closing + } +}) + + + +it('waits for admitted maintenance after a queue-clear failure before releasing its Session', async () => { + const { ctx, agent, handle } = await harness() + const release = Promise.withResolvers() + const waiting = Promise.withResolvers<'waiting'>() + const disposed = Promise.withResolvers<'disposed'>() + let scopeDisposed = false + agent.ctx.effect(() => () => { scopeDisposed = true; disposed.resolve('disposed') }) + const work = agent.runMaintenance(async () => { + await release.promise + agent.session.append('request/context', { provider: 'mock', model: 'mock' }) + }) + agent.inject(input('pending')) + const stopRejecting = ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (session === agent.session && event.type === 'agent/inbox/spliced' && (event.data.removedCount ?? 0) > 0) { + throw new Error('queue clear failed') + } + }) + const whenIdle = agent.whenIdle.bind(agent) + const spy = vi.spyOn(agent, 'whenIdle').mockImplementation(() => { waiting.resolve('waiting'); return whenIdle() }) + const closing = handle.dispose().then(() => undefined, (error: unknown) => error) + try { + expect(await Promise.race([waiting.promise, disposed.promise])).toBe('waiting') + expect(scopeDisposed).toBe(false) + expect(ctx.sessions.get(agent.id)).toBe(agent.session) + release.resolve(undefined) + await work + expect(await closing).toEqual(new Error('queue clear failed')) + expect(scopeDisposed).toBe(true) + expect(agent.session.snapshotEvents().at(-1)?.type).toBe('request/context') + } finally { + stopRejecting() + spy.mockRestore() + release.resolve(undefined) + await work + await closing + } +}) diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index 8ed1c4d138..f4ef29eea3 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -79,7 +79,8 @@ describe('loop-owned request freezing', () => { const inbox = await loopCtx.sessionProjections.register(inboxProjectionDefinition) const model = await loopCtx.sessionProjections.register(modelSelectionProjectionDefinition) const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, - { modelSelection: model.readerFor(session), inbox: inbox.readerFor(session) }, ctx.agents.turnBoundaryOf(session).lastTurn) + { modelSelection: model.readerFor(session), inbox: inbox.readerFor(session) }, ctx.agents.turnBoundaryOf(session).lastTurn, + new AbortController().signal) cleanups.push(async () => { agent.cancel({ kind: 'disposed' }) await agent.whenIdle() @@ -129,7 +130,8 @@ describe('loop-owned request freezing', () => { expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1) expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1) const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, - { modelSelection: model.readerFor(session), inbox: inbox.readerFor(session) }, ctx.agents.turnBoundaryOf(session).lastTurn) + { modelSelection: model.readerFor(session), inbox: inbox.readerFor(session) }, ctx.agents.turnBoundaryOf(session).lastTurn, + new AbortController().signal) cleanups.push(async () => { resumed.cancel({ kind: 'disposed' }) await resumed.whenIdle() diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 358d56ae02..df3e29c8a9 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -24,8 +24,8 @@ en: 85b0ff72a187110f zh: 3f61c1568d27b8bc /deepseek-ai-dsh-agent/use-this-package/drive-an-agent-s-conversation: - en: 71e258897e2e7d73 - zh: abf0991012faa17e + en: ff2b530bb20bcfe2 + zh: 4f9024c4881a4ba5 /deepseek-ai-dsh-agent/use-this-package/scope-registrations-to-one-agent: en: 2594a1e02adea801 zh: cae8860369728efb diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c84ac3d237..9772578fcc 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -55,7 +55,7 @@ For scoped switch notices, call `installModelSelection(agentCtx)` from `dsh-agen ### Drive an agent's conversation -The handle's methods route identified user-role messages into the agent's inbox. `followup()` queues an ordinary next-turn prompt and wakes the driver; `steer()` submits next-step input and wakes it; `inject()` adds model-facing context without waking the driver, so it lands in the next admitted step. `cancel(cause)` aborts the active activity and, unless `keepInbox` is set, clears pending work; `whenIdle()` resolves after the whole agent reaches quiescence. +The handle's methods route identified user-role messages into the agent's inbox. `followup()` queues an ordinary next-turn prompt and wakes the driver; `steer()` submits next-step input and wakes it; `inject()` adds model-facing context without waking the driver, so it lands in the next admitted step. `cancel(cause)` aborts the active activity and, unless `keepInbox` is set, clears pending work; `whenIdle()` resolves after the whole agent reaches quiescence. Disposal rejects new input and maintenance with `AgentClosedError` before mutation. A queue-write failure during cancellation still aborts active work and propagates to the caller. ```text handle.agent.followup({ diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index a2fae55b56..b5b2af52d4 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -55,7 +55,7 @@ await handle.dispose() // stops the loop, unregisters, removes the session, un ### 驱动 agent 的对话 -句柄的方法把带标识的 user 角色消息路由进 agent 的收件箱。`followup()` 排队一条普通的下一个轮次提示词并唤醒驱动器;`steer()` 提交下一步输入并唤醒它;`inject()` 添加面向模型的上下文但不唤醒驱动器,因此它落在下一个被接纳的步骤中。`cancel(cause)` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;`whenIdle()` 会在整个 agent 达到完全停稳后完成。 +句柄的方法把带标识的 user 角色消息路由进 agent 的收件箱。`followup()` 排队一条普通的下一个轮次提示词并唤醒驱动器;`steer()` 提交下一步输入并唤醒它;`inject()` 添加面向模型的上下文但不唤醒驱动器,因此它落在下一个被接纳的步骤中。`cancel(cause)` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;`whenIdle()` 会在整个 agent 达到完全停稳后完成。释放开始后,新输入和维护任务会在修改状态前抛出 `AgentClosedError`。取消期间的队列写入失败仍会中止当前工作,并把错误传给调用方。 ```text handle.agent.followup({ diff --git a/packages/core/agent/src/closed-error.ts b/packages/core/agent/src/closed-error.ts new file mode 100644 index 0000000000..abcb335320 --- /dev/null +++ b/packages/core/agent/src/closed-error.ts @@ -0,0 +1,15 @@ +/** Rejection of new work after an Agent starts closing. @module @deepseek-ai/dsh-agent/closed-error */ + +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** An Agent has stopped accepting new input and maintenance work. */ +export class AgentClosedError extends Error { + /** + * @param agentId - closing Agent identity. + * @param options - original lifecycle cancellation, when available. + */ + constructor(readonly agentId: SessionId, options?: ErrorOptions) { + super(`agent "${agentId}" is closing`, options) + this.name = 'AgentClosedError' + } +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 374be30180..35fbdd6b1d 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -19,6 +19,7 @@ import type { AgentOptions, SessionStartSource } from './runtime-types.ts' import { turnBoundaryProjectionDefinition } from './projection.ts' export * from './runtime-types.ts' +export { AgentClosedError } from './closed-error.ts' export * from './types.ts' export { AgentModel, modelSelectionProjectionDefinition, installModelSelection } from './model-selection.ts' export type { ModelSelectionProjectionState } from './model-selection.ts' diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 2b1698e39c..aa5efe6d0a 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -206,6 +206,7 @@ declare module './types.ts' { * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no * active activity, cancellation is a no-op and does not arm later work. + * Queue-write failures still abort the activity and propagate to the caller. * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ @@ -225,7 +226,7 @@ declare module './types.ts' { * inbox until the task settles, while public status stays `idle`. * `whenIdle()` follows both the task and any waking work released behind it. * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. - * @throws synchronously when turn-driving or another maintenance task already owns the agent. + * @throws AgentClosedError after disposal begins, or an Error when another activity owns the agent. * @returns the task promise. */ runMaintenance(task: (signal: AbortSignal) => Promise): Promise @@ -240,6 +241,7 @@ declare module './types.ts' { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @throws AgentClosedError after disposal begins; no input is inserted. */ send(message: UserMessage, target: InboxTarget, wakeup: boolean): void @@ -247,6 +249,7 @@ declare module './types.ts' { * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ followup(message: UserMessage): void @@ -256,6 +259,7 @@ declare module './types.ts' { * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ steer(message: UserMessage): void @@ -266,6 +270,7 @@ declare module './types.ts' { * wakes them. It may miss a request whose pre-step already claimed its * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. + * @throws AgentClosedError after disposal begins; no input is inserted. */ inject(message: UserMessage): void } diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 4ff564cb25..3b160b2782 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -54,8 +54,8 @@ en: 61cddba23f4e7d1c zh: 052c8331a747403f /deepseek-ai-dsh-subagent/understand-the-implementation/activation-settlement: - en: 9543250098033347 - zh: 3721af3e31409a32 + en: 152d969b7214b4cd + zh: 2ba2a43599b009d9 /deepseek-ai-dsh-subagent/understand-the-implementation/restoring-child-composition: en: 843f9f4319ca2ae3 zh: 218bf355816d4ff6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index b8fd869989..c3da369bed 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -124,7 +124,7 @@ External providers mint a fresh `SubagentRun.id` for each execution in the runti ### Activation settlement -The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local admission closes after Agent activity and pending input finish, with activity revalidated after the final Session flush. Both local and external activations deliver their result after handle cleanup, capacity release, and settlement notifications. Host waits on the subagent tree root join progressing members and their cleanup, including failed executions; idle members with parked input remain resident without delaying host completion. Ordinary members do not wait for descendants. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement only if the creator is still online; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForTreeWork(agent)` until no child work remains and the parent stays idle with an unchanged Session sequence across the child check, so completion notices can produce the final parent answer. +The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local admission closes after Agent activity and pending input finish, with activity revalidated after the final Session flush. Both local and external activations deliver their result after handle cleanup, capacity release, and settlement notifications. Host waits on the subagent tree root join progressing members and their cleanup, including failed executions; idle members with parked input remain resident without delaying host completion. Ordinary members do not wait for descendants. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement only if the creator is still online; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForTreeWork(agent)` until no child work remains and the parent stays idle with an unchanged Session sequence across the child check, so completion notices can produce the final parent answer. An Agent that has begun closing ends the local settlement observer rather than retrying maintenance admission. Settlement attribution records the exact execution stop reason without altering model-facing notice content. Historical notices can omit this field; readers must not infer it from the English summary. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index e0f8f35bda..ed72da6c0a 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -124,7 +124,7 @@ kind: "package-reference" ### Activation 结算 -管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动和待处理输入结束后,管理器在最终 Session flush 后重新验证活动状态并关闭准入。本地与外部 activation 都在句柄清理、容量归还及结算通知结束后交付结果。宿主在 subagent 树根上等待仍在推进的成员及其清理,包括失败的执行;有停放输入的空闲成员继续驻留,但不延迟宿主完成。普通成员不等待后代。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递仅在创建者仍在线时于结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForTreeWork(agent)`,直至没有子级工作,且父级在检查子级期间保持空闲、Session 序号不变,让完成通知可驱动父级生成最终答案。 +管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动和待处理输入结束后,管理器在最终 Session flush 后重新验证活动状态并关闭准入。本地与外部 activation 都在句柄清理、容量归还及结算通知结束后交付结果。宿主在 subagent 树根上等待仍在推进的成员及其清理,包括失败的执行;有停放输入的空闲成员继续驻留,但不延迟宿主完成。普通成员不等待后代。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递仅在创建者仍在线时于结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForTreeWork(agent)`,直至没有子级工作,且父级在检查子级期间保持空闲、Session 序号不变,让完成通知可驱动父级生成最终答案。 Agent 开始关闭后,本地结算观察器会结束,不再重试维护准入。 结算归属信息记录确切的执行停止原因,不改变面向模型的通知内容。历史通知可以缺少该字段;读取方不得从英文摘要推断它。 diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index db6f04b019..e1b0bc6e68 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -11,6 +11,7 @@ import { isAbsolute, resolve } from 'node:path' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { AgentClosedError } from '@deepseek-ai/dsh-agent' import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' @@ -991,7 +992,8 @@ export class SubagentManager { done = this.close(activation, () => this.finishDisposal(activation, false)) return Promise.resolve() }) - } catch (_error: unknown) { + } catch (error: unknown) { + if (error instanceof AgentClosedError) return Promise.resolve('closed') // Another activity already owns the Agent's idle phase. return Promise.resolve('retry') } diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 311fa10ff6..49dca3772a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1787,6 +1787,7 @@ def smoke_sdk_snapshot_mode( {"insert": [{ "id": "serial-created-fixture", "name": str(Path(__file__).resolve().parents[1] / "packages/core/agent-loop/tests/fixtures/serial-created.mjs"), + "config": {"checkRootCancellation": False}, }]}, ]) with DeepSeekHarness( diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 47c767ccd9..68401cbcf2 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -2880,11 +2880,205 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "step/start", + "type": "agent/inbox/spliced", "seq": 9, "time": 0, + "data": { + "target": "next-step", + "start": 0, + "inserted": [ + { + "id": "{{messageId}}", + "role": "user", + "content": [ + { + "type": "text", + "text": "Cancellation checkpoint probe." + } + ], + "source": { + "kind": "plugin:serial-created-fixture" + } + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 10, + "time": 0, + "data": { + "target": "next-step", + "start": 0, + "removedCount": 1, + "inserted": [], + "outcome": "canceled" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 11, + "time": 0, + "data": { + "target": "next-step", + "start": 0, + "inserted": [ + { + "id": "{{messageId}}", + "role": "user", + "content": [ + { + "type": "text", + "text": "Serial agent creation completed. Queue-clear failures still cancel active work." + } + ], + "source": { + "kind": "plugin:serial-created-fixture" + } + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 12, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + }, + { + "type": "text", + "text": "Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/end", + "seq": 13, + "time": 0, "data": { "turn": 1, + "reason": { + "kind": "aborted", + "reason": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "idle" + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 14, + "time": 0, + "data": { + "turn": 2 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 15, + "time": 0, + "data": { + "target": "next-step", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 16, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 17, + "time": 0, + "data": { + "turn": 2, "step": 1 } } @@ -2896,10 +3090,10 @@ "sessionId": "{{child-1}}", "event": { "type": "system/message", - "seq": 10, + "seq": 18, "time": 0, "data": { - "turn": 1, + "turn": 2, "step": 1, "message": { "role": "system", @@ -2925,7 +3119,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 11, + "seq": 19, "time": 0, "data": { "id": "{{messageId}}", @@ -2933,7 +3127,7 @@ "content": [ { "type": "text", - "text": "Serial agent creation completed." + "text": "Serial agent creation completed. Queue-clear failures still cancel active work." } ], "source": { @@ -2950,7 +3144,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 12, + "seq": 20, "time": 0, "data": { "content": [ @@ -2979,7 +3173,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 13, + "seq": 21, "time": 0, "data": { "content": [ @@ -3027,7 +3221,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 14, + "seq": 22, "time": 0, "data": { "header": { @@ -3063,7 +3257,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/context", - "seq": 15, + "seq": 23, "time": 0, "data": { "provider": "deepseek-official", @@ -3079,12 +3273,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 16, + "seq": 24, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 12 + 20 ], "source": { "kind": "fallback" @@ -3099,12 +3293,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 17, + "seq": 25, "time": 0, "data": { "sessionId": "{{child-1}}", "sessionFormatVersion": 4, - "throughSeq": 16 + "throughSeq": 24 } } } @@ -3115,10 +3309,10 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 18, + "seq": 26, "time": 0, "data": { - "turn": 1, + "turn": 2, "step": 1, "message": { "role": "assistant", @@ -3229,10 +3423,10 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 19, + "seq": 27, "time": 0, "data": { - "turn": 1, + "turn": 2, "step": 1 } } @@ -3244,10 +3438,10 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 20, + "seq": 28, "time": 0, "data": { - "turn": 1, + "turn": 2, "reason": { "kind": "completed" } @@ -3712,11 +3906,201 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "step/start", + "type": "agent/inbox/spliced", "seq": 9, "time": 0, + "data": { + "target": "next-step", + "start": 0, + "inserted": [ + { + "id": "{{messageId}}", + "role": "user", + "content": [ + { + "type": "text", + "text": "Cancellation checkpoint probe." + } + ], + "source": { + "kind": "plugin:serial-created-fixture" + } + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 10, + "time": 0, + "data": { + "target": "next-step", + "start": 0, + "removedCount": 1, + "inserted": [], + "outcome": "canceled" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 11, + "time": 0, + "data": { + "target": "next-step", + "start": 0, + "inserted": [ + { + "id": "{{messageId}}", + "role": "user", + "content": [ + { + "type": "text", + "text": "Serial agent creation completed. Queue-clear failures still cancel active work." + } + ], + "source": { + "kind": "plugin:serial-created-fixture" + } + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 12, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/end", + "seq": 13, + "time": 0, "data": { "turn": 1, + "reason": { + "kind": "aborted", + "reason": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "idle" + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 14, + "time": 0, + "data": { + "turn": 2 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 15, + "time": 0, + "data": { + "target": "next-step", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 16, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 17, + "time": 0, + "data": { + "turn": 2, "step": 1 } } @@ -3728,10 +4112,10 @@ "sessionId": "{{child-2}}", "event": { "type": "system/message", - "seq": 10, + "seq": 18, "time": 0, "data": { - "turn": 1, + "turn": 2, "step": 1, "message": { "role": "system", @@ -3757,7 +4141,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 11, + "seq": 19, "time": 0, "data": { "id": "{{messageId}}", @@ -3765,7 +4149,7 @@ "content": [ { "type": "text", - "text": "Serial agent creation completed." + "text": "Serial agent creation completed. Queue-clear failures still cancel active work." } ], "source": { @@ -3782,7 +4166,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 12, + "seq": 20, "time": 0, "data": { "content": [ @@ -3807,7 +4191,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 13, + "seq": 21, "time": 0, "data": { "content": [ @@ -3855,7 +4239,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 14, + "seq": 22, "time": 0, "data": { "header": { @@ -3891,7 +4275,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/context", - "seq": 15, + "seq": 23, "time": 0, "data": { "provider": "deepseek-official", @@ -3907,12 +4291,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 16, + "seq": 24, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 12 + 20 ], "source": { "kind": "fallback" @@ -3927,12 +4311,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 17, + "seq": 25, "time": 0, "data": { "sessionId": "{{child-2}}", "sessionFormatVersion": 4, - "throughSeq": 16 + "throughSeq": 24 } } } @@ -3943,10 +4327,10 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 18, + "seq": 26, "time": 0, "data": { - "turn": 1, + "turn": 2, "step": 1, "message": { "role": "assistant", @@ -4057,10 +4441,10 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 19, + "seq": 27, "time": 0, "data": { - "turn": 1, + "turn": 2, "step": 1 } } @@ -4072,10 +4456,10 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 20, + "seq": 28, "time": 0, "data": { - "turn": 1, + "turn": 2, "reason": { "kind": "completed" } diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl index 46fbdba75d..e15e79f377 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl @@ -8,15 +8,23 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Cancellation checkpoint probe."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","snapshot_double","subagent","workflow","working_directory"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[12],"source":{"kind":"fallback"}}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","sessionFormatVersion":4,"throughSeq":16}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DIRECT_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":1}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[20],"source":{"kind":"fallback"}}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","sessionFormatVersion":4,"throughSeq":24}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DIRECT_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl index 9c7213f1fd..817f66b355 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl @@ -8,15 +8,23 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Cancellation checkpoint probe."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","snapshot_double","subagent","workflow","working_directory"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[12],"source":{"kind":"fallback"}}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","sessionFormatVersion":4,"throughSeq":16}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["WORKFLOW_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":1}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[20],"source":{"kind":"fallback"}}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","sessionFormatVersion":4,"throughSeq":24}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["WORKFLOW_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/serial-created/session.v4.jsonl b/snapshots/sdk/serial-created/session.v4.jsonl index 93cc0348f2..5109ba2c62 100644 --- a/snapshots/sdk/serial-created/session.v4.jsonl +++ b/snapshots/sdk/serial-created/session.v4.jsonl @@ -7,15 +7,12 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:3}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{message:1}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[11],"source":{"kind":"fallback"}}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":15}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19},"stream":[{"type":"chunk","time":1788943826008,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788943826008,"index":0,"dt":[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]},{"type":"chunk","time":1788943826009,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788943826009,"index":1,"dt":[0,0,0],"texts":["SD","K"," snapshot"," OK"]},{"type":"chunk","time":1788943826009,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}},{"type":"chunk","time":1788943826009,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}},{"type":"chunk","time":1788943826009,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}},{"type":"chunk","time":1788943826009,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":1}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{message:3}}","role":"user","content":[{"type":"text","text":"Cancellation checkpoint probe."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{message:1}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:2}}"}]}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"aborted","reason":{"kind":"disposed"}}}} From 5a537ce4865b130badf25163ceda875977540345 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Wed, 4 Nov 2026 21:42:43 +0800 Subject: [PATCH 002/149] test(sdk): keep root snapshot runs active through completion --- .../tests/fixtures/serial-created.mjs | 2 +- snapshots/sdk/serial-created/session.v4.jsonl | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs index 138fd7d4b6..1295611023 100644 --- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -10,7 +10,7 @@ export function apply(ctx, config = {}) { const checkedCancellation = new WeakSet() ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => { if (checkedCancellation.has(agent) - || (config.checkRootCancellation === false && agent.session.header.origin !== 'subagent')) return next() + || (config.checkRootCancellation !== true && agent.session.header.origin !== 'subagent')) return next() checkedCancellation.add(agent) const pending = { id: randomUUID(), role: 'user', diff --git a/snapshots/sdk/serial-created/session.v4.jsonl b/snapshots/sdk/serial-created/session.v4.jsonl index 5109ba2c62..93cc0348f2 100644 --- a/snapshots/sdk/serial-created/session.v4.jsonl +++ b/snapshots/sdk/serial-created/session.v4.jsonl @@ -7,12 +7,15 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{message:3}}","role":"user","content":[{"type":"text","text":"Cancellation checkpoint probe."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{message:1}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:2}}"}]}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} -{"type":"turn/start","data":{"turn":2}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"turn/end","data":{"turn":2,"reason":{"kind":"aborted","reason":{"kind":"disposed"}}}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:3}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{message:1}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[11],"source":{"kind":"fallback"}}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":15}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19},"stream":[{"type":"chunk","time":1788943826008,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788943826008,"index":0,"dt":[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]},{"type":"chunk","time":1788943826009,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788943826009,"index":1,"dt":[0,0,0],"texts":["SD","K"," snapshot"," OK"]},{"type":"chunk","time":1788943826009,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}},{"type":"chunk","time":1788943826009,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}},{"type":"chunk","time":1788943826009,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}},{"type":"chunk","time":1788943826009,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 6e1eebe215583a08d87225c328e834c307983e50 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 03:11:46 +0800 Subject: [PATCH 003/149] fix(subagent): settle activations after agent factory unload --- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/manager.ts | 5 +- .../subagent/tests/factory-settlement.spec.ts | 50 +++++++++++++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packages/subagent/subagent/tests/factory-settlement.spec.ts diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 3b160b2782..4248dbcefa 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -54,8 +54,8 @@ en: 61cddba23f4e7d1c zh: 052c8331a747403f /deepseek-ai-dsh-subagent/understand-the-implementation/activation-settlement: - en: 152d969b7214b4cd - zh: 2ba2a43599b009d9 + en: e8b2853fe4a5105d + zh: fb0fdeeefee852ce /deepseek-ai-dsh-subagent/understand-the-implementation/restoring-child-composition: en: 843f9f4319ca2ae3 zh: 218bf355816d4ff6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index c3da369bed..a2012acd79 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -124,7 +124,7 @@ External providers mint a fresh `SubagentRun.id` for each execution in the runti ### Activation settlement -The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local admission closes after Agent activity and pending input finish, with activity revalidated after the final Session flush. Both local and external activations deliver their result after handle cleanup, capacity release, and settlement notifications. Host waits on the subagent tree root join progressing members and their cleanup, including failed executions; idle members with parked input remain resident without delaying host completion. Ordinary members do not wait for descendants. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement only if the creator is still online; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForTreeWork(agent)` until no child work remains and the parent stays idle with an unchanged Session sequence across the child check, so completion notices can produce the final parent answer. An Agent that has begun closing ends the local settlement observer rather than retrying maintenance admission. +The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local admission closes after Agent activity and pending input finish, with activity revalidated after the final Session flush. Both local and external activations deliver their result after handle cleanup, capacity release, and settlement notifications. Host waits on the subagent tree root join progressing members and their cleanup, including failed executions; idle members with parked input remain resident without delaying host completion. Ordinary members do not wait for descendants. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement only if the creator is still online; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForTreeWork(agent)` until no child work remains and the parent stays idle with an unchanged Session sequence across the child check, so completion notices can produce the final parent answer. When an Agent has begun closing, the manager settles its Activation through the shared close transaction without retrying maintenance admission. Settlement attribution records the exact execution stop reason without altering model-facing notice content. Historical notices can omit this field; readers must not infer it from the English summary. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index ed72da6c0a..b5159612d0 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -124,7 +124,7 @@ kind: "package-reference" ### Activation 结算 -管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动和待处理输入结束后,管理器在最终 Session flush 后重新验证活动状态并关闭准入。本地与外部 activation 都在句柄清理、容量归还及结算通知结束后交付结果。宿主在 subagent 树根上等待仍在推进的成员及其清理,包括失败的执行;有停放输入的空闲成员继续驻留,但不延迟宿主完成。普通成员不等待后代。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递仅在创建者仍在线时于结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForTreeWork(agent)`,直至没有子级工作,且父级在检查子级期间保持空闲、Session 序号不变,让完成通知可驱动父级生成最终答案。 Agent 开始关闭后,本地结算观察器会结束,不再重试维护准入。 +管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动和待处理输入结束后,管理器在最终 Session flush 后重新验证活动状态并关闭准入。本地与外部 activation 都在句柄清理、容量归还及结算通知结束后交付结果。宿主在 subagent 树根上等待仍在推进的成员及其清理,包括失败的执行;有停放输入的空闲成员继续驻留,但不延迟宿主完成。普通成员不等待后代。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递仅在创建者仍在线时于结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForTreeWork(agent)`,直至没有子级工作,且父级在检查子级期间保持空闲、Session 序号不变,让完成通知可驱动父级生成最终答案。 Agent 开始关闭后,管理器通过共享关闭事务结算其 Activation,不再重试维护准入。 结算归属信息记录确切的执行停止原因,不改变面向模型的通知内容。历史通知可以缺少该字段;读取方不得从英文摘要推断它。 diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index e1b0bc6e68..ba0b8f4c29 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -993,7 +993,10 @@ export class SubagentManager { return Promise.resolve() }) } catch (error: unknown) { - if (error instanceof AgentClosedError) return Promise.resolve('closed') + if (error instanceof AgentClosedError) { + done = this.close(activation, () => this.finishDisposal(activation, false)) + return Promise.resolve({ done }) + } // Another activity already owns the Agent's idle phase. return Promise.resolve('retry') } diff --git a/packages/subagent/subagent/tests/factory-settlement.spec.ts b/packages/subagent/subagent/tests/factory-settlement.spec.ts new file mode 100644 index 0000000000..6b5bb37324 --- /dev/null +++ b/packages/subagent/subagent/tests/factory-settlement.spec.ts @@ -0,0 +1,50 @@ +import { expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentRuntime from '../src/index.ts' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' +import { managerState } from './manager-internals.ts' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +it('settles an activation when its factory unloads during the final flush', async () => { + const ctx = new Context() + const release = Promise.withResolvers() + const flushing = Promise.withResolvers() + try { + await mountAgentLoopTestDependencies(ctx) + const loop = await ctx.plugin(AgentLoop, { agents: [] }) + await mountWorkingDirectoryFixture(ctx) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('answer')])) + const parent = await ctx.agentLoop.create(SessionId('factory-unload-parent'), { provider: 'mock', model: 'mock' }) + ctx.on('session/flush', async (session) => { + if (session.header.origin !== 'subagent') return + flushing.resolve(undefined) + await release.promise + }) + const started = await ctx.subagents.startActivation({ + provider: 'spawn', label: 'child', prompt: [{ type: 'text', text: 'task' }], + parent, signal: new AbortController().signal, delivery: 'caller', + }) + await flushing.promise + const state = managerState(ctx) + const activation = state.resident.get(started.childId)! + const released = vi.spyOn(activation, 'releaseSlot') + const ended = vi.fn() + ctx.on('subagent/end', ended) + await loop.dispose() + expect(ctx.agents.get(started.childId)).toBeUndefined() + release.resolve(undefined) + await vi.waitFor(() => { expect(state.resident.has(started.childId)).toBe(false) }, { timeout: 1000 }) + expect(await started.result).toMatchObject({ stopReason: 'completed' }) + expect(released).toHaveBeenCalledTimes(1) + expect(ended).toHaveBeenCalledTimes(1) + } finally { + release.resolve(undefined) + await ctx.fiber.dispose() + } +}) From a11de46bfd0f33efe211a392926c8440aa447461 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 16:16:47 +0800 Subject: [PATCH 004/149] fix(agent): guard inbox admission and settle disposed activations --- packages/core/agent-loop/src/agent.ts | 6 +++--- packages/core/agent-loop/src/inbox.ts | 12 ++++++++++++ .../agent-loop/tests/input-lifecycle.spec.ts | 19 ++++++++++++++++++- packages/subagent/subagent/src/manager.ts | 7 +++++++ .../subagent/tests/factory-settlement.spec.ts | 16 ++++++++++++++-- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 76b2c2b80e..12464720ae 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -148,7 +148,7 @@ export class ReactLoopAgent implements Agent { this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx this.model = new AgentModel(projections.modelSelection, session, options) - this.inbox = new ReactLoopInbox(projections.inbox, session, this.dispatch) + this.inbox = new ReactLoopInbox(projections.inbox, session, this.dispatch, () => this.assertOpen()) this.phase = { kind: 'idle', lastTurn } this.runtimeContext = new RuntimeContextProjection(this.ctx, session) this.systemPrompt = new SystemPromptProjection(session) @@ -606,13 +606,13 @@ export class ReactLoopAgent implements Agent { maxParallelToolCalls, toolSet: decision.toolSet, }) for (const context of result.additionalContexts) { - this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]) + this.inbox.retainContext(context) } return result.concluded ? { kind: 'completed' } : null } catch (error: unknown) { if (!(error instanceof ToolBatchExecutionError)) throw error for (const context of error.partial.additionalContexts) { - this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]) + this.inbox.retainContext(context) } throw error.cause } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index affe0c6b2f..8972667b10 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -69,12 +69,14 @@ export const inboxProjectionDefinition = { * @param readState - reader for the live Inbox projection state. * @param session - session whose durable events store pending input. * @param dispatch - agent-scoped notifications for Inbox lifecycle events. + * @param assertAdmission - rejects public insertions and replacements after admission closes. */ export class ReactLoopInbox implements InboxContract { constructor( private readonly readState: () => InboxState, private readonly session: Session, private readonly dispatch: AgentEventDispatch, + private readonly assertAdmission: () => void = () => {}, ) {} /** Prompts awaiting individual turns. */ @@ -137,6 +139,7 @@ export class ReactLoopInbox implements InboxContract { * @returns whether the message was still pending. */ replace(messageId: MessageId, newMessage: UserMessage): boolean { + this.assertAdmission() const location = this.locate(messageId) if (location === undefined) return false this.splice(location.target, location.index, 1, [newMessage]) @@ -169,9 +172,18 @@ export class ReactLoopInbox implements InboxContract { deleteCount: number, inserted: UserMessage[], ): UserMessage[] { + if (inserted.length > 0) this.assertAdmission() return this.mutate(target, start, deleteCount, inserted, true) } + /** + * Retain context finalized by admitted work, including during shutdown. + * @param message - context produced by the active driver. + */ + retainContext(message: UserMessage): void { + this.mutate('next-step', this.nextStep.length, 0, [message], true) + } + /** Locate one pending identity across both owned lists. */ private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { const state = this.readState() diff --git a/packages/core/agent-loop/tests/input-lifecycle.spec.ts b/packages/core/agent-loop/tests/input-lifecycle.spec.ts index 2e87517e1f..3cdb06da2c 100644 --- a/packages/core/agent-loop/tests/input-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/input-lifecycle.spec.ts @@ -96,7 +96,7 @@ it('an Agent still in the registry rejects new input while its owner is closing' await aborted.promise expect(ctx.agents.get(agent.id)).toBe(agent) const message = input('during closing') - for (const submit of [() =>{ agent.followup(message) }, () =>{ agent.steer(message) }, () =>{ agent.inject(message) }, () =>{ agent.send(message, 'next-turn', true) }, () => agent.runMaintenance(async () => {})]) { + for (const submit of [() =>{ agent.followup(message) }, () =>{ agent.steer(message) }, () =>{ agent.inject(message) }, () =>{ agent.send(message, 'next-turn', true) }, () => agent.runMaintenance(async () => {}), () => agent.inbox.append('next-turn', message), () => agent.inbox.prepend('next-step', message), () => agent.inbox.splice('next-turn', 0, 0, [message]), () => agent.inbox.replace(message.id, message)]) { expect(submit).toThrow(AgentClosedError) } const pending = [...agent.inbox.nextTurn, ...agent.inbox.nextStep].some(entry => entry.id === message.id) @@ -149,3 +149,20 @@ it('waits for admitted maintenance after a queue-clear failure before releasing await closing } }) + +it('does not start a driver when an inbox observer closes its owner during admission', async () => { + const { ctx, agent, handle, adapter } = await harness() + let closing: Promise | undefined + const stop = ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.source.kind === 'user') closing = handle.dispose() + }) + try { + agent.followup(input('close during admission')) + expect(closing).toBeDefined() + await closing + expect(adapter.requests).toHaveLength(0) + } finally { + stop() + await closing + } +}) diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index ba0b8f4c29..f4a2a3d652 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -174,6 +174,12 @@ export class SubagentManager { }, session => host.identityOf(session)) ctx.on('agent/disposed', ({ agent }) => { this.closingRoots.delete(agent) + const activation = this.resident.get(agent.id) + if (activation?.kind === 'local' && activation.handle.agent === agent) { + void this.close(activation, () => this.finishDisposal(activation, false)).catch((error: unknown) => { + this.reportTeardownFailure(activation, error) + }) + } }) ctx.effect(function* (this: SubagentManager) { yield scope.dispose @@ -1127,6 +1133,7 @@ export class SubagentManager { } this.sendWaking(parent, message, parent.status === 'idle' ? 'queue' : 'steer') } catch (error: unknown) { + if (error instanceof AgentClosedError) return this.ctx.logger.warn( `subagent "${activation.childId}" settlement notice was not delivered to its parent: ` + errorChain(error), diff --git a/packages/subagent/subagent/tests/factory-settlement.spec.ts b/packages/subagent/subagent/tests/factory-settlement.spec.ts index 6b5bb37324..ed339efca6 100644 --- a/packages/subagent/subagent/tests/factory-settlement.spec.ts +++ b/packages/subagent/subagent/tests/factory-settlement.spec.ts @@ -2,14 +2,15 @@ import { expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import SubagentRuntime from '../src/index.ts' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { managerState } from './manager-internals.ts' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -it('settles an activation when its factory unloads during the final flush', async () => { +it.each([false, true])('settles an activation after factory unload with queue-clear failure=%s', async (rejectQueueClear) => { const ctx = new Context() const release = Promise.withResolvers() const flushing = Promise.withResolvers() @@ -36,7 +37,18 @@ it('settles an activation when its factory unloads during the final flush', asyn const released = vi.spyOn(activation, 'releaseSlot') const ended = vi.fn() ctx.on('subagent/end', ended) + const child = ctx.agents.get(started.childId)! + if (rejectQueueClear) child.inject(createUserMessage({ content: [{ type: 'text', text: 'pending' }], source: { kind: 'user' } })) + const rejectClear = ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (rejectQueueClear && session === child.session && event.type === 'agent/inbox/spliced' && (event.data.removedCount ?? 0) > 0) { + throw new Error('queue clear rejected') + } + }) await loop.dispose() + expect(child.inbox.hasPending).toBe(rejectQueueClear) + rejectClear() expect(ctx.agents.get(started.childId)).toBeUndefined() release.resolve(undefined) await vi.waitFor(() => { expect(state.resident.has(started.childId)).toBe(false) }, { timeout: 1000 }) From 1e90c99c48cb9e6bc2f3fafbfbea85ec122de9fa Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 16:17:45 +0800 Subject: [PATCH 005/149] test(subagent): inspect pending input through public Inbox fields --- packages/subagent/subagent/tests/factory-settlement.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent/tests/factory-settlement.spec.ts b/packages/subagent/subagent/tests/factory-settlement.spec.ts index ed339efca6..0228655cc5 100644 --- a/packages/subagent/subagent/tests/factory-settlement.spec.ts +++ b/packages/subagent/subagent/tests/factory-settlement.spec.ts @@ -47,7 +47,7 @@ it.each([false, true])('settles an activation after factory unload with queue-cl } }) await loop.dispose() - expect(child.inbox.hasPending).toBe(rejectQueueClear) + expect(child.inbox.nextStep.length + child.inbox.nextTurn.length > 0).toBe(rejectQueueClear) rejectClear() expect(ctx.agents.get(started.childId)).toBeUndefined() release.resolve(undefined) From 477ca41b357b1a05d0d11f79f7c838f4004bab80 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 16:23:44 +0800 Subject: [PATCH 006/149] fix(ci): use block bodies for void admission callbacks --- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/tests/input-lifecycle.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 12464720ae..874739d7e1 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -148,7 +148,7 @@ export class ReactLoopAgent implements Agent { this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx this.model = new AgentModel(projections.modelSelection, session, options) - this.inbox = new ReactLoopInbox(projections.inbox, session, this.dispatch, () => this.assertOpen()) + this.inbox = new ReactLoopInbox(projections.inbox, session, this.dispatch, () => { this.assertOpen() }) this.phase = { kind: 'idle', lastTurn } this.runtimeContext = new RuntimeContextProjection(this.ctx, session) this.systemPrompt = new SystemPromptProjection(session) diff --git a/packages/core/agent-loop/tests/input-lifecycle.spec.ts b/packages/core/agent-loop/tests/input-lifecycle.spec.ts index 3cdb06da2c..e3f095da28 100644 --- a/packages/core/agent-loop/tests/input-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/input-lifecycle.spec.ts @@ -71,7 +71,7 @@ it('cancellation still aborts the running request when queue clearing fails', as if (session === agent.session && event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn' && (event.data.removedCount ?? 0) > 0) throw new Error('injected queue clear failure') }) - expect(() =>{ agent.cancel({ kind: 'user' }) }).toThrow('injected queue clear failure') + expect(() => { agent.cancel({ kind: 'user' }) }).toThrow('injected queue clear failure') expect(signal.aborted).toBe(true) } finally { stopRejecting() @@ -96,7 +96,7 @@ it('an Agent still in the registry rejects new input while its owner is closing' await aborted.promise expect(ctx.agents.get(agent.id)).toBe(agent) const message = input('during closing') - for (const submit of [() =>{ agent.followup(message) }, () =>{ agent.steer(message) }, () =>{ agent.inject(message) }, () =>{ agent.send(message, 'next-turn', true) }, () => agent.runMaintenance(async () => {}), () => agent.inbox.append('next-turn', message), () => agent.inbox.prepend('next-step', message), () => agent.inbox.splice('next-turn', 0, 0, [message]), () => agent.inbox.replace(message.id, message)]) { + for (const submit of [() => { agent.followup(message) }, () => { agent.steer(message) }, () => { agent.inject(message) }, () => { agent.send(message, 'next-turn', true) }, () => agent.runMaintenance(async () => {}), () => { agent.inbox.append('next-turn', message) }, () => { agent.inbox.prepend('next-step', message) }, () => agent.inbox.splice('next-turn', 0, 0, [message]), () => agent.inbox.replace(message.id, message)]) { expect(submit).toThrow(AgentClosedError) } const pending = [...agent.inbox.nextTurn, ...agent.inbox.nextStep].some(entry => entry.id === message.id) From 7512ac4ce4ad63c8cdf940a8141308abd8f247d6 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 16:29:26 +0800 Subject: [PATCH 007/149] test(subagent): cover closure before settlement maintenance --- .../subagent/tests/continuation.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index a3cb8d3b93..627f0f085e 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import type { Agent } from '@deepseek-ai/dsh-agent' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { AgentClosedError, agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' @@ -2570,6 +2570,22 @@ describe('continuable review regressions', () => { expect(managerState(ctx).resident.get(started.childId)).toBeUndefined() }) + it('settles when Agent admission closes before settlement maintenance starts', async () => { + const release = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const maintenance = vi.spyOn(child, 'runMaintenance').mockImplementation(() => { + throw new AgentClosedError(child.id) + }) + release.resolve(undefined) + await waitNoActivation(ctx, started.childId) + expect(maintenance).toHaveBeenCalledTimes(1) + expect(await started.result).toMatchObject({ stopReason: 'completed' }) + }) + it('keeps a maintenance task that claimed the idle phase after whenIdle resolved', async () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) From fe57dfce386d35c61ab1001722ec78f3dc0fe921 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 16:40:17 +0800 Subject: [PATCH 008/149] test(agent): cover quiescence failure during disposal --- .../core/agent-loop/tests/input-lifecycle.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core/agent-loop/tests/input-lifecycle.spec.ts b/packages/core/agent-loop/tests/input-lifecycle.spec.ts index e3f095da28..9a61005689 100644 --- a/packages/core/agent-loop/tests/input-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/input-lifecycle.spec.ts @@ -166,3 +166,16 @@ it('does not start a driver when an inbox observer closes its owner during admis await closing } }) + +it('reports a quiescence failure while releasing the Agent and Session', async () => { + const { ctx, agent, handle } = await harness() + const failure = new Error('injected quiescence failure') + const idle = vi.spyOn(agent, 'whenIdle').mockRejectedValueOnce(failure) + try { + await expect(handle.dispose()).rejects.toThrow('injected quiescence failure') + expect(ctx.agents.get(agent.id)).toBeUndefined() + expect(ctx.sessions.get(agent.id)).toBeUndefined() + } finally { + idle.mockRestore() + } +}) From e530b606e5a0c3f2d92a15f7a1c8e4c23120be5d Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 16:45:56 +0800 Subject: [PATCH 009/149] test(agent): retain resources through interrupted creation listeners --- .../agent-loop/tests/input-lifecycle.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/core/agent-loop/tests/input-lifecycle.spec.ts b/packages/core/agent-loop/tests/input-lifecycle.spec.ts index 9a61005689..560fea3ca1 100644 --- a/packages/core/agent-loop/tests/input-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/input-lifecycle.spec.ts @@ -179,3 +179,30 @@ it('reports a quiescence failure while releasing the Agent and Session', async ( idle.mockRestore() } }) + +it('keeps creation resources alive until an interrupted creation listener finishes', async () => { + const { ctx } = await harness() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const signal = new AbortController() + const id = SessionId('interrupted-announcement') + const stop = ctx.on('agent/created', async ({ agent }) => { + if (agent.id !== id) return + entered.resolve(agent) + await release.promise + }) + const creation = ctx.agents.create({ sessionId: id, agentOptions: { provider: 'mock', model: 'mock' }, signal: signal.signal }) + .then(handle => handle.dispose(), (error: unknown) => error) + try { + const agent = await entered.promise + signal.abort(new Error('creation interrupted')) + expect(ctx.sessions.get(id)).toBe(agent.session) + release.resolve(undefined) + expect(await creation).toEqual(new Error('creation interrupted')) + expect(ctx.sessions.get(id)).toBeUndefined() + } finally { + release.resolve(undefined) + await creation + stop() + } +}) From 9dc9f76702eef1dbd0474908591acf75567f7cb8 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Thu, 5 Nov 2026 17:06:51 +0800 Subject: [PATCH 010/149] test(session): refresh terminal panel runtime context reminder --- snapshots/session/terminal-panel-tools/session.v4.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/session/terminal-panel-tools/session.v4.jsonl b/snapshots/session/terminal-panel-tools/session.v4.jsonl index 01574413c3..290e17f9f6 100644 --- a/snapshots/session/terminal-panel-tools/session.v4.jsonl +++ b/snapshots/session/terminal-panel-tools/session.v4.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"My tests fail in my terminal. What does it say?"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} {"type":"session/title","data":{"title":"My tests fail in my","messageSeqs":[8],"source":{"kind":"fallback"}}} From 8fb05c7f79552ec2442c060ce7760d3eadf5b656 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 5 Nov 2026 22:53:30 +0800 Subject: [PATCH 011/149] test(ci): remove two flaky-test causes in gh fixture and Web e2e The fake gh `sleep` case ran sleep as a child of /bin/sh. execa's deadline killed only the shell, and the orphaned sleep held stdout/stderr open, so execa waited about 5s for close and the 5000ms Vitest case timed out. Exec the sleep so the killed process is the one holding the pipes. The fresh round trip e2e read the pre-accept composer state one frame after dispatching Enter, racing the Host's acceptance of the prompt POST under load. Hold /api/session/prompt at the browser boundary, read and compare the snapshot, then release the POST and assert the accepted state. --- apps/web/tests/replay-round-trip.e2e.ts | 29 ++++++++++++++----- .../forge-github/tests/gh.spec.ts | 2 +- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 82f436c837..adfea25d10 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -124,14 +124,24 @@ describe('web e2e: fresh round trip through the real assembly', () => { let accepted = false try { await input.fill(PROMPT) + // Hold the prompt POST at the browser boundary so the Host cannot accept + // the message before the pre-accept snapshot is read. + const promptSent = Promise.withResolvers() + const releasePrompt = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptSent.resolve(undefined) + await releasePrompt.promise + await route.continue() + } + await page.route('**/api/session/prompt', holdPrompt, { times: 1 }) const admitted = page.waitForResponse('**/api/session/prompt') - const [echoSnapshot, response] = await Promise.all([ - input.evaluate(async (element, prompt) => { + try { + const echoCapture = input.evaluate(async (element, prompt) => { element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, })) - // sendSession registered its paint yield first. This frame observes the - // composer holding the message while the Host has not accepted it yet. + // sendSession registered its paint yield first; this frame observes the + // composer holding the message while the POST is still held. await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) const echo = document.querySelector('[data-submission-echo]') return [ @@ -139,12 +149,15 @@ describe('web e2e: fresh round trip through the real assembly', () => { `composer: ${JSON.stringify(element.textContent ?? '')}`, `contenteditable: ${element.getAttribute('contenteditable')}`, ].join('\n') - }, PROMPT), - admitted, - ]) + }, PROMPT) + await promptSent.promise + await compareOrRefreshGolden(ECHO_EXPECTED, await echoCapture, MODE) + } finally { + releasePrompt.resolve(undefined) + } + const response = await admitted expect(await response.json()).toMatchObject({ result: { ok: true, value: { accepted: true } } }) accepted = true - await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) // Acceptance publishes the echo and clears the composer; maintenance keeps the Turn from starting. await page.locator('[data-submission-echo]', { hasText: PROMPT }).waitFor() await expect.poll(() => input.textContent()).toBe('') diff --git a/packages/experimental/forge-github/tests/gh.spec.ts b/packages/experimental/forge-github/tests/gh.spec.ts index 84246e36ac..e732b009d5 100644 --- a/packages/experimental/forge-github/tests/gh.spec.ts +++ b/packages/experimental/forge-github/tests/gh.spec.ts @@ -24,7 +24,7 @@ async function fakeGh(): Promise { ' echo) printf "%s|" "$@"; printf "\\nprompt=%s token=%s pager=[%s]\\n" "$GH_PROMPT_DISABLED" "${GH_TOKEN:-unset}" "$GH_PAGER" ;;', ' input) cat ;;', ' fail) echo oops >&2; exit 3 ;;', - ' sleep) sleep 5 ;;', + ' sleep) exec sleep 5 ;;', ' big) head -c 5000 /dev/zero | tr "\\0" x ;;', 'esac', '', From 4e4b4eb9b6e67942b866a637f64a0e9165eb7aef Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:29 +0800 Subject: [PATCH 012/149] feat(fs): classify host errno failures as FsError dsh-fs gains fsErrorFromErrno, which classifies a host errno failure: ENOENT/ENOTDIR -> FS_NOT_FOUND, EACCES/EPERM -> FS_PERMISSION_DENIED, EISDIR -> FS_NOT_REGULAR_FILE, other errno -> FS_IO_ERROR, keeping the original error as cause. fs-local uses it for resolution, metadata, reads, and listing, and fs-sandbox for its containment check, so tool results persist error.info instead of a raw Node error. FsError assigns its literal name, and FS_ERROR_CODES gains FS_ALREADY_EXISTS for create-only commands. --- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 2 +- docs/subsystems/filesystem.zh.md | 2 +- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/README.zh.md | 2 +- packages/fs/fs-local/src/fsio.ts | 59 ++++++++----------- packages/fs/fs-local/tests/fsio.spec.ts | 41 +++++++++++++ packages/fs/fs-sandbox/src/containment.ts | 6 +- .../fs/fs-sandbox/tests/containment.spec.ts | 8 +++ packages/fs/fs/README.i18n.yaml | 8 +-- packages/fs/fs/README.md | 3 +- packages/fs/fs/README.zh.md | 3 +- packages/fs/fs/src/errno.ts | 33 +++++++++++ packages/fs/fs/src/index.ts | 1 + packages/fs/fs/src/types.ts | 2 + packages/fs/fs/tests/errno.spec.ts | 30 ++++++++++ 17 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 packages/fs/fs/src/errno.ts create mode 100644 packages/fs/fs/tests/errno.spec.ts diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 03d12b8456..09a886a568 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -30,8 +30,8 @@ en: 7b462db42ab89b10 zh: 968219ded2b55d69 /filesystem/error-taxonomy-provider-contract: - en: ca206569d4e2528d - zh: e36d70ad39a73570 + en: dbab88fbb60fcb8f + zh: f90be1352b1e05f8 /filesystem/no-timeouts-on-file-io: en: ae18af906f3f7e50 zh: 24a1aa6f903fe08d diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index fcbbec29cd..f19f09ab43 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -375,7 +375,7 @@ import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs' type FsErrorCode = typeof FS_ERROR_CODES[number] ``` -`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. `FS_ALREADY_EXISTS` means a create-only tool command found an existing target; the caller chooses another path or edits the existing file. `dsh-fs-local` and the `dsh-fs-sandbox` containment check classify errno failures from resolution, metadata, reads, and listing with the `dsh-fs` function `fsErrorFromErrno`: `ENOENT` and `ENOTDIR` report `FS_NOT_FOUND`, `EACCES` and `EPERM` report `FS_PERMISSION_DENIED`, `EISDIR` reports `FS_NOT_REGULAR_FILE`, and other errno values report `FS_IO_ERROR`, each with the Node error as `cause`. ## No timeouts on file IO diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 22f59a7884..8de900b9bd 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -375,7 +375,7 @@ import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs' type FsErrorCode = typeof FS_ERROR_CODES[number] ``` -目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。`FS_ALREADY_EXISTS` 表示仅创建的工具命令遇到了已存在的目标;调用方应换一个路径或编辑现有文件。`dsh-fs-local` 与 `dsh-fs-sandbox` 的包含性检查用 `dsh-fs` 的函数 `fsErrorFromErrno` 分类解析、元数据、读取与目录列表中的 errno 失败:`ENOENT` 与 `ENOTDIR` 报告 `FS_NOT_FOUND`,`EACCES` 与 `EPERM` 报告 `FS_PERMISSION_DENIED`,`EISDIR` 报告 `FS_NOT_REGULAR_FILE`,其他 errno 报告 `FS_IO_ERROR`,均以 Node 错误为 `cause`。 ## 文件 IO 不设超时 diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index ca5f575769..4c9e8ec1ef 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -24,8 +24,8 @@ en: ca0cfe83eca367c0 zh: 9d3a6d61952f180e /deepseek-ai-dsh-fs-local/use-this-package/what-you-can-do: - en: 04b6889eec28bd96 - zh: e94e2787de0ba704 + en: 0f05ec25037a6d39 + zh: d67d80a04c148f3b /deepseek-ai-dsh-fs-local/understand-the-implementation: en: 6ffc93e09381bd3d zh: 57c3192c26a080ef diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 132dc626e6..f2846682d7 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -52,7 +52,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Read regular UTF-8 files whole or as streams, read bounded bytes, and list one directory level. Writes, literal edits, patches, and deletions serialize per target. Moves lock both targets in lexical order. `patchText` checks the version before calling `applyHunks`; `deleteFile` removes only a regular file. Missing sources use `FS_STALE_VERSION`, unmatched hunks use `FS_PATCH_MISMATCH`, and identical move targets use `FS_SAME_TARGET`. -Patch and move text is LF-normalized internally and restored using the source line-ending style; outcome text uses LF. Delete bases and overwritten destination bases follow `diffBasisMaxBytes`. A move checks cancellation before staging the destination, then completes both effects; a source-removal failure returns the published destination with `sourceDeleted: false`. Atomic write failures map denied directory or staging creation to `FS_PERMISSION_DENIED` and other Node I/O errors to `FS_IO_ERROR`. +Patch and move text is LF-normalized internally and restored using the source line-ending style; outcome text uses LF. Delete bases and overwritten destination bases follow `diffBasisMaxBytes`. A move checks cancellation before staging the destination, then completes both effects; a source-removal failure returns the published destination with `sourceDeleted: false`. Atomic write failures map denied directory or staging creation to `FS_PERMISSION_DENIED` and other Node I/O errors to `FS_IO_ERROR`. Resolution, metadata, read, and listing failures pass through `fsErrorFromErrno` from `dsh-fs`, so every errno becomes an `FsError` code; a thrown value without an errno code is a defect and propagates unchanged. Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index edbf6a7da1..d1abde6841 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" 完整或流式读取普通 UTF-8 文件、读取有界字节,并列出一层目录。写入、字面量编辑、补丁和删除按目标串行化。移动按字典序锁定两端。`patchText` 在调用 `applyHunks` 前检查版本;`deleteFile` 只删除普通文件。源缺失使用 `FS_STALE_VERSION`,hunk 不匹配使用 `FS_PATCH_MISMATCH`,移动两端相同使用 `FS_SAME_TARGET`。 -补丁与移动的文本在内存中规范化为 LF,写回时还原源文件的行尾风格;结果文本使用 LF。删除基线和被覆盖目标的基线遵循 `diffBasisMaxBytes`。移动在暂存目标前检查取消,随后完成两个效果;源删除失败返回已发布目标及 `sourceDeleted: false`。原子写入失败时,目录或临时目录创建被拒绝映射为 `FS_PERMISSION_DENIED`,其他 Node I/O 错误映射为 `FS_IO_ERROR`。 +补丁与移动的文本在内存中规范化为 LF,写回时还原源文件的行尾风格;结果文本使用 LF。删除基线和被覆盖目标的基线遵循 `diffBasisMaxBytes`。移动在暂存目标前检查取消,随后完成两个效果;源删除失败返回已发布目标及 `sourceDeleted: false`。原子写入失败时,目录或临时目录创建被拒绝映射为 `FS_PERMISSION_DENIED`,其他 Node I/O 错误映射为 `FS_IO_ERROR`。解析、元数据、读取与目录列表失败经由 `dsh-fs` 的 `fsErrorFromErrno` 分类,因此每个 errno 都变为 `FsError` 错误码;不带 errno 码的抛出值属于缺陷,原样传播。 读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index a6e3da2bba..63da0a95f0 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -12,7 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs' import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path' import { TextDecoder, promisify } from 'node:util' import { homedir } from 'node:os' -import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey, FsVersion, fsErrorFromErrno } from '@deepseek-ai/dsh-fs' import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts' const BINARY_SAMPLE_BYTES = 8192 @@ -62,13 +62,13 @@ function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { * `readFile` with a bare `AbortError`, which would otherwise escape the seam's * error taxonomy — the streaming/write paths translate it the same way). */ -async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise { +async function readFileAbortable(absolutePath: string, displayPath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise { try { return await readFile(absolutePath, signal ? { signal } : {}) } catch (error: unknown) { - /* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */ - if (!isAbortError(error)) throw error - throw new FsError(`${verb} aborted`, 'FS_ABORTED') + /* v8 ignore next 2 -- a non-abort rejection needs a permission fault after a successful stat; POSIX covers it. */ + if (isAbortError(error)) throw new FsError(`${verb} aborted`, 'FS_ABORTED') + throw fsErrorFromErrno(verb, displayPath, error) } } @@ -188,8 +188,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise( // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean // the target is absent; any other metadata failure is a real permission/IO // fault. - /* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */ - if (!isENOENT(error) && !isENOTDIR(error)) throw error + if (!isENOENT(error) && !isENOTDIR(error)) throw fsErrorFromErrno('stat', absolutePath, error) return null } } @@ -291,16 +289,6 @@ export async function probeNoFollow(absolutePath: string): Promise { const identity = await resolveLocalTarget(parent.targetKey, name) return { displayPath: localDisplayPath(parent.displayPath, name), targetKey: identity.targetKey } @@ -320,7 +308,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): try { info = await probe(target.targetKey) } catch (error: unknown) { - throw listingIoError(target.displayPath, error) + throw fsErrorFromErrno('list', target.displayPath, error) } if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY') @@ -330,7 +318,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' }) } catch (error: unknown) { /* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */ - throw listingIoError(target.displayPath, error) + throw fsErrorFromErrno('list', target.displayPath, error) } throwIfAborted(signal, 'list') @@ -348,7 +336,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): ...(childInfo?.type === 'file' ? { size: childInfo.size } : {}), }) } catch (error: unknown) { - throw listingIoError(localDisplayPath(target.displayPath, entry.name), error) + throw fsErrorFromErrno('list', localDisplayPath(target.displayPath, entry.name), error) } throwIfAborted(signal, 'list') } @@ -392,9 +380,7 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort try { info = await stat(target.targetKey) } catch (error: unknown) { - /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */ - if (!isENOENT(error)) throw error - throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND') + throw fsErrorFromErrno(verb, target.displayPath, error) } if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') return info @@ -409,7 +395,7 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort */ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise { await statRegularFile(target, 'read', signal) - const raw = await readFileAbortable(target.targetKey, 'read', signal) + const raw = await readFileAbortable(target.targetKey, target.displayPath, 'read', signal) throwIfAborted(signal, 'read') if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) { throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') @@ -454,9 +440,9 @@ export async function readWholeBytes( chunks.push(chunk) } } catch (error: unknown) { - /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ + /* v8 ignore next -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') - throw error + throw fsErrorFromErrno('read', target.displayPath, error) } return Buffer.concat(chunks, bytes) } @@ -491,10 +477,10 @@ export async function readByteWindow( bytes += chunk.length } } catch (error: unknown) { - /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ + /* v8 ignore next -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') - /* v8 ignore next -- any other stream failure needs an I/O fault after a successful stat. */ - throw error + /* v8 ignore next -- needs a permission fault after a successful stat; Windows chmod cannot deny reads, POSIX covers it. */ + throw fsErrorFromErrno('read', target.displayPath, error) } return Buffer.concat(chunks, bytes) } @@ -529,9 +515,10 @@ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal } yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath) } catch (error: unknown) { - /* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */ + /* v8 ignore next -- a mid-stream abort needs cancellation racing an active read; pre-abort is caught by throwIfAborted. */ if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') - throw error + /* v8 ignore next -- needs a permission fault after a successful stat; Windows chmod cannot deny reads, POSIX covers it. */ + throw fsErrorFromErrno('read', target.displayPath, error) } } @@ -776,7 +763,7 @@ export async function readForEdit( signal?: AbortSignal, ): Promise<{ content: string; lineEndings: LineEndings }> { throwIfAborted(signal, 'edit') - const buffer = await readFileAbortable(absolutePath, 'edit', signal) + const buffer = await readFileAbortable(absolutePath, displayPath, 'edit', signal) throwIfAborted(signal, 'edit') if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') const raw = decodeUtf8(buffer, 'edit', displayPath) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index e53e1e4b37..980f7a722c 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -15,8 +15,10 @@ import { listDirectory, probe, probeNoFollow, + readByteWindow, readForEdit, readTextForDiff, + readWholeBytes, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -1035,3 +1037,42 @@ describe('atomic write failures', () => { }, ) }) + +describe('Node errno classification', () => { + it('classifies metadata, resolution, and read faults on a symlink loop', async () => { + const loop = join(dir, 'loop') + await symlink(loop, loop) + const ioError = { name: 'FsError', code: 'FS_IO_ERROR' } + await expect(probe(loop)).rejects.toMatchObject(ioError) + await expect(resolveLocalTarget(dir, 'loop')).rejects.toMatchObject(ioError) + await expect(readWholeText(localTarget(loop))).rejects.toMatchObject(ioError) + }) + + it('classifies a regular file replaced by a directory after the raw-read stat', async () => { + const file = join(dir, 'swap.bin') + await writeFile(file, 'bytes') + const failure = await readWholeBytes(localTarget(file), undefined, 1024, { + inspectReadBytesAfterStat: async () => { + await unlink(file) + await mkdir(file) + }, + }).then(() => undefined, (caught: unknown) => caught) + expect(failure).toBeInstanceOf(FsError) + expect(['FS_NOT_REGULAR_FILE', 'FS_IO_ERROR']).toContain((failure as FsError).code) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('maps unreadable file content to FS_PERMISSION_DENIED', async () => { + const file = join(dir, 'locked.txt') + await writeFile(file, 'secret') + await chmod(file, 0o000) + try { + const denied = { name: 'FsError', code: 'FS_PERMISSION_DENIED', message: `cannot read "${file}": permission denied` } + await expect(readWholeText(localTarget(file))).rejects.toMatchObject(denied) + await expect(collect(streamWholeText(localTarget(file)))).rejects.toMatchObject(denied) + await expect(readByteWindow(localTarget(file), { offset: 0, length: 4 })).rejects.toMatchObject(denied) + await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_PERMISSION_DENIED', message: `cannot edit "${file}": permission denied` }) + } finally { + await chmod(file, 0o600) + } + }) +}) diff --git a/packages/fs/fs-sandbox/src/containment.ts b/packages/fs/fs-sandbox/src/containment.ts index 41b9bdd08a..d3706c3b9d 100644 --- a/packages/fs/fs-sandbox/src/containment.ts +++ b/packages/fs/fs-sandbox/src/containment.ts @@ -8,6 +8,7 @@ import type { BigIntStats } from 'node:fs' import { stat } from 'node:fs/promises' import { dirname, sep } from 'node:path' +import { fsErrorFromErrno } from '@deepseek-ai/dsh-fs' const MISSING_CODES: ReadonlySet = new Set(['ENOENT', 'ENOTDIR']) @@ -32,10 +33,8 @@ async function statIfPresent(path: string): Promise { try { return await stat(path, { bigint: true }) } catch (error: unknown) { - /* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */ if (isMissing(error)) return undefined - /* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */ - throw error + throw fsErrorFromErrno('stat', path, error) } } @@ -54,6 +53,7 @@ function sameIdentity(left: BigIntStats, right: BigIntStats): boolean { * @param caseSensitive - whether lexical comparison preserves case; defaults * to the host filesystem convention used by supported platforms. * @returns whether the target is the root or a descendant of it. + * @throws FsError when an ancestor or the root cannot be inspected, coded by the Node errno. */ export async function isPathUnder( path: string, diff --git a/packages/fs/fs-sandbox/tests/containment.spec.ts b/packages/fs/fs-sandbox/tests/containment.spec.ts index 35dc52029b..c77fbcf4d2 100644 --- a/packages/fs/fs-sandbox/tests/containment.spec.ts +++ b/packages/fs/fs-sandbox/tests/containment.spec.ts @@ -54,4 +54,12 @@ describe('filesystem sandbox containment', () => { await writeFile(blocker, 'not a directory') expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false) }) + + it('classifies an ancestor inspection fault as a filesystem error', async () => { + const root = join(base, 'root') + const loop = join(base, 'loop') + await mkdir(root) + await symlink(loop, loop) + await expect(isPathUnder(loop, root)).rejects.toMatchObject({ name: 'FsError', code: 'FS_IO_ERROR' }) + }) }) diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 7e3bfe3472..8f15755d9e 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8e2f904a21ba4dfb zh: 76d9ce7503c84a5c /deepseek-ai-dsh-fs/use-this-package/what-the-service-lets-you-do: - en: 637fc69ab6f6d19f - zh: 0fceafe5b38a06a1 + en: 5781e5a173192374 + zh: a3fa379ea951921d /deepseek-ai-dsh-fs/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 @@ -30,8 +30,8 @@ en: 955232f787df6d64 zh: ab572b040ab3554e /deepseek-ai-dsh-fs/understand-the-implementation/source-map: - en: 9507d7d9259fc89e - zh: aa500435e02e6de3 + en: 8ab63fdfa9f95759 + zh: bb88918900d7458b /deepseek-ai-dsh-fs/understand-the-implementation/how-a-call-flows: en: 3cefede64907731c zh: 529ef3851c2fb5c1 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 949a453d2c..e2fe18a41c 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -33,7 +33,7 @@ Pick [`fs-local`](../fs-local/README.md) for ordinary host files or [`fs-sandbox ### What the service lets you do -Through `ctx.fs`, resolve paths, read text or bounded bytes, list direct children, and perform guarded mutations. `patchText` applies parsed hunks under the same lock as its version check. `deleteFile` removes a regular file. `moveFile` locks both targets, optionally patches the source text, publishes the destination, then removes the source. Guards are optional: omission mutates the current state unconditionally. Failures carry stable `FsError` codes from the runtime `FS_ERROR_CODES` list, which transport validators reuse. +Through `ctx.fs`, resolve paths, read text or bounded bytes, list direct children, and perform guarded mutations. `patchText` applies parsed hunks under the same lock as its version check. `deleteFile` removes a regular file. `moveFile` locks both targets, optionally patches the source text, publishes the destination, then removes the source. Guards are optional: omission mutates the current state unconditionally. Failures carry stable `FsError` codes from the runtime `FS_ERROR_CODES` list, which transport validators reuse. Host-backed providers, currently `dsh-fs-local` and the `dsh-fs-sandbox` containment check, classify errno failures with `fsErrorFromErrno`: `ENOENT` and `ENOTDIR` become `FS_NOT_FOUND`, `EACCES` and `EPERM` become `FS_PERMISSION_DENIED`, `EISDIR` where a file is required becomes `FS_NOT_REGULAR_FILE`, and other errno values become `FS_IO_ERROR`, with the original error as `cause`. `watch(target, changed, signal)` reports invalidations for one file or a directory's direct entries. It resolves once observation is ready with an asynchronous close function that the caller must await. The signal cancels initialization; unsupported providers reject without polling. @@ -62,6 +62,7 @@ The contract is built on one separation and three commitments: |---|---| | [`src/index.ts`](src/index.ts) | Service definition: the abstract `FileSystem` class, the `ctx.fs` declaration, and the `fs/*` event vocabulary | | [`src/types.ts`](src/types.ts) | Vocabulary: `FsTarget`/`FsTargetKey`, `FsVersion`, `FsObservation`, `FsWriteIntent`, `FsError` and its codes | +| [`src/errno.ts`](src/errno.ts) | `fsErrorFromErrno`: errno classification into `FsError` codes | ### How a call flows diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 90118d2ecf..5056198f1b 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 服务能做什么 -通过 `ctx.fs` 可解析路径、读取文本或有界字节、列出直接子项并执行受防护变更。`patchText` 在版本检查的同一把锁内应用解析后的 hunk。`deleteFile` 删除普通文件。`moveFile` 锁定两端,按需修改源文本,发布目标后再删除源。防护是可选的:省略时无条件修改当前状态。失败携带运行时列表 `FS_ERROR_CODES` 中的稳定 `FsError` 错误码,传输校验器复用该列表。 +通过 `ctx.fs` 可解析路径、读取文本或有界字节、列出直接子项并执行受防护变更。`patchText` 在版本检查的同一把锁内应用解析后的 hunk。`deleteFile` 删除普通文件。`moveFile` 锁定两端,按需修改源文本,发布目标后再删除源。防护是可选的:省略时无条件修改当前状态。失败携带运行时列表 `FS_ERROR_CODES` 中的稳定 `FsError` 错误码,传输校验器复用该列表。基于宿主文件系统的提供方(目前是 `dsh-fs-local` 与 `dsh-fs-sandbox` 的包含性检查)用 `fsErrorFromErrno` 分类 errno 失败:`ENOENT` 与 `ENOTDIR` 变为 `FS_NOT_FOUND`,`EACCES` 与 `EPERM` 变为 `FS_PERMISSION_DENIED`,需要文件却遇到目录的 `EISDIR` 变为 `FS_NOT_REGULAR_FILE`,其他 errno 变为 `FS_IO_ERROR`,原错误作为 `cause`。 `watch(target, changed, signal)` 报告单个文件或目录直接子项的失效通知。观察就绪后,它返回调用方必须等待完成的异步关闭函数。signal 取消初始化;不支持监听的提供方直接拒绝,不使用轮询。 @@ -62,6 +62,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 服务定义:抽象 `FileSystem` 类、`ctx.fs` 声明与 `fs/*` 事件词汇 | | [`src/types.ts`](src/types.ts) | 词汇:`FsTarget`/`FsTargetKey`、`FsVersion`、`FsObservation`、`FsWriteIntent`、`FsError` 及其错误码 | +| [`src/errno.ts`](src/errno.ts) | `fsErrorFromErrno`:把 errno 分类为 `FsError` 错误码 | ### 调用流程 diff --git a/packages/fs/fs/src/errno.ts b/packages/fs/fs/src/errno.ts new file mode 100644 index 0000000000..5378ef58bd --- /dev/null +++ b/packages/fs/fs/src/errno.ts @@ -0,0 +1,33 @@ +/** + * Classification of host errno failures into the shared filesystem codes. + * @module @deepseek-ai/dsh-fs/errno + */ + +import { FsError } from './types.ts' + +/** + * Classify a host filesystem rejection by its errno `code`: ENOENT and ENOTDIR become `FS_NOT_FOUND`, + * EACCES and EPERM `FS_PERMISSION_DENIED`, EISDIR (a directory where a file is required) + * `FS_NOT_REGULAR_FILE`, and any other errno `FS_IO_ERROR`, each with the original error as `cause`. + * Callers that give an errno a narrower meaning, such as absence during a probe, test for it first. + * @param operation - verb shown in the diagnostic, such as `read`, `stat`, or `list`. + * @param displayPath - caller-facing path shown in the diagnostic. + * @param error - the caught value. + * @returns the classified error; an `FsError` and a value without a string errno `code` (a defect) are returned unchanged. + */ +export function fsErrorFromErrno(operation: string, displayPath: string, error: unknown): unknown { + if (error instanceof FsError || !(error instanceof Error) || !('code' in error) || typeof error.code !== 'string') return error + const failure = `cannot ${operation} "${displayPath}"` + switch (error.code) { + case 'ENOENT': + case 'ENOTDIR': + return new FsError(`${failure}: not found`, 'FS_NOT_FOUND', { cause: error }) + case 'EACCES': + case 'EPERM': + return new FsError(`${failure}: permission denied`, 'FS_PERMISSION_DENIED', { cause: error }) + case 'EISDIR': + return new FsError(`${failure}: not a regular file`, 'FS_NOT_REGULAR_FILE', { cause: error }) + default: + return new FsError(`${failure}: ${error.message}`, 'FS_IO_ERROR', { cause: error }) + } +} diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index a49e7ff0e2..8baff464f9 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -27,6 +27,7 @@ import type { FsWriteOutcome, } from './types.ts' +export { fsErrorFromErrno } from './errno.ts' export { FS_ERROR_CODES, FsError, diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 6ece2c04e4..ecc8b182e5 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -203,6 +203,7 @@ export const FS_ERROR_CODES = [ 'FS_ABORTED', 'FS_PATCH_MISMATCH', 'FS_SAME_TARGET', + 'FS_ALREADY_EXISTS', ] as const /** One machine-routable filesystem failure code from FS_ERROR_CODES. */ @@ -219,6 +220,7 @@ export class FsError extends HarnessError { constructor(message: string, code: FsErrorCode, options?: ErrorOptions) { super(message, code, options) + this.name = 'FsError' this.code = code } } diff --git a/packages/fs/fs/tests/errno.spec.ts b/packages/fs/fs/tests/errno.spec.ts new file mode 100644 index 0000000000..be52ee66ed --- /dev/null +++ b/packages/fs/fs/tests/errno.spec.ts @@ -0,0 +1,30 @@ +/** Errno classification into the shared filesystem codes. */ +import { describe, expect, it } from 'vitest' +import { FsError, fsErrorFromErrno } from '@deepseek-ai/dsh-fs' + +describe('fsErrorFromErrno', () => { + const errno = (code: string): Error => Object.assign(new Error(`${code}: injected`), { code }) + + it.each([ + ['ENOENT', 'FS_NOT_FOUND', 'not found'], + ['ENOTDIR', 'FS_NOT_FOUND', 'not found'], + ['EACCES', 'FS_PERMISSION_DENIED', 'permission denied'], + ['EPERM', 'FS_PERMISSION_DENIED', 'permission denied'], + ['EISDIR', 'FS_NOT_REGULAR_FILE', 'not a regular file'], + ['ELOOP', 'FS_IO_ERROR', 'ELOOP: injected'], + ])('maps %s to %s with the Node error as cause', (code, fsCode, detail) => { + const cause = errno(code) + expect(fsErrorFromErrno('read', '~/a', cause)).toMatchObject({ + name: 'FsError', code: fsCode, message: `cannot read "~/a": ${detail}`, cause, + }) + }) + + it.each([ + new FsError('already typed', 'FS_TOO_LARGE'), + new Error('defect'), + Object.assign(new Error('numeric code'), { code: 1 }), + 'not an error', + ])('returns typed failures and defects unchanged: %s', (value) => { + expect(fsErrorFromErrno('read', 'a', value)).toBe(value) + }) +}) From 36d45eca9cd87f4ef05f9527c02e2bb5b9283e16 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:43 +0800 Subject: [PATCH 013/149] feat(str-replace-editor): give argument and create failures error codes Hand-checked argument failures (relative or empty path, missing command parameters, invalid view_range, out-of-range insert_line, null new_str) throw ToolArgsError (INVALID_ARGS), and create on an existing path throws FsError FS_ALREADY_EXISTS, so tool results persist error.info. --- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 36 +++++------ .../tests/tools.spec.ts | 60 ++++++++++--------- 5 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 7bd12ad505..b1cc3a78da 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -27,8 +27,8 @@ en: 554e8446946fbd96 zh: 3af12627c19a0111 /deepseek-ai-dsh-tool-str-replace-editor/use-this-package/failures-and-recovery: - en: 2b249fcb0b5621aa - zh: d172b3de43c337c4 + en: 68d14f5993c734d5 + zh: 36eab11474947735 /deepseek-ai-dsh-tool-str-replace-editor/understand-the-implementation: en: b0b36a22cd4421eb zh: db47ef5b71dd51df diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index f1880a5217..563ddf6ce1 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -52,7 +52,7 @@ A backend, optionally the policy plugin, then the tool; the editor composes with ### Failures and recovery -A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Guarded mutations inherit the policy plugin's codes and remedies — `FS_NOT_OBSERVED` (read the file, then retry), `FS_STALE_VERSION` (re-read, then retry) — and sandbox denials surface as the `[sandbox: file access denied under mode]` marker. Paths must be absolute; a relative path is refused with a hint. +A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Guarded mutations inherit the policy plugin's codes and remedies — `FS_NOT_OBSERVED` (read the file, then retry), `FS_STALE_VERSION` (re-read, then retry) — and sandbox denials surface as the `[sandbox: file access denied under mode]` marker. Paths must be absolute; a relative path is refused with a hint. Argument mistakes the schema cannot express, such as a relative path, a missing command parameter, or an out-of-range `view_range` or `insert_line`, throw `ToolArgsError` with `INVALID_ARGS`; `create` on an existing path reports `FS_ALREADY_EXISTS`. ----- diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index 9c69505356..5182960718 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" ### 失败与恢复 -`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。防护变更继承策略插件的错误码与恢复指令——`FS_NOT_OBSERVED`(先读取文件再重试)、`FS_STALE_VERSION`(先重新读取再重试)——沙箱拒绝则表现为 `[sandbox: file access denied under mode]` 标记。路径必须是绝对路径;相对路径会被拒绝并给出提示。 +`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。防护变更继承策略插件的错误码与恢复指令——`FS_NOT_OBSERVED`(先读取文件再重试)、`FS_STALE_VERSION`(先重新读取再重试)——沙箱拒绝则表现为 `[sandbox: file access denied under mode]` 标记。路径必须是绝对路径;相对路径会被拒绝并给出提示。参数模式无法表达的参数错误,例如相对路径、缺少命令参数或 `view_range`、`insert_line` 越界,抛出 `ToolArgsError`,错误码为 `INVALID_ARGS`;对已存在路径执行 `create` 报告 `FS_ALREADY_EXISTS`。 ----- diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 3542ee1770..099d1a6d73 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -12,7 +12,7 @@ import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-r import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' @@ -92,9 +92,9 @@ async function resolveTarget( path: string, signal: AbortSignal, ): Promise { - if (path.trim().length === 0) throw new Error('path must be a non-empty string') + if (path.trim().length === 0) throw new ToolArgsError(['path must be a non-empty string']) if (!isAbsolute(path)) { - throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) + throw new ToolArgsError([`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`]) } return ctx.fs.resolve(path, { signal }) } @@ -128,9 +128,9 @@ function requiredForCommand( command: string, allowEmpty = true, ): string { - if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`) + if (value === undefined) throw new ToolArgsError([`Parameter \`${parameter}\` is required for command: ${command}`]) if (!allowEmpty && value.length === 0) { - throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`) + throw new ToolArgsError([`Parameter \`${parameter}\` is empty for command: ${command}`]) } return value } @@ -154,24 +154,24 @@ function formatFileView( || requestedFinalLine === undefined || !viewRange.every(Number.isInteger) ) { - throw new Error('Invalid `view_range`. It should be a list of two integers.') + throw new ToolArgsError(['Invalid `view_range`. It should be a list of two integers.']) } initialLine = requestedInitialLine finalLine = requestedFinalLine if (initialLine < 1 || initialLine > allLines.length) { - throw new Error( + throw new ToolArgsError([ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`, - ) + ]) } if (finalLine > allLines.length) { - throw new Error( + throw new ToolArgsError([ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``, - ) + ]) } if (finalLine !== -1 && finalLine < initialLine) { - throw new Error( + throw new ToolArgsError([ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``, - ) + ]) } lines = finalLine === -1 ? allLines.slice(initialLine - 1) @@ -226,7 +226,7 @@ async function viewPath( const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { - throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.') + throw new ToolArgsError(['The `view_range` parameter is not allowed when `path` points to a directory.']) } return listDirectory(ctx, target, maxOutputChars, exec) } @@ -249,7 +249,7 @@ async function createFile( const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) if (await ctx.fs.stat(target, exec.signal) !== undefined) { - throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) + throw new FsError(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`, 'FS_ALREADY_EXISTS') } const intent = await ctx.waterfall( 'fs/write-intent', @@ -282,7 +282,7 @@ async function replaceInFile( exec: ToolRunContext, ): Promise { if (newStr === null) { - throw new Error('Parameter `new_str` must be omitted or contain a string for command: str_replace') + throw new ToolArgsError(['Parameter `new_str` must be omitted or contain a string for command: str_replace']) } const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) @@ -335,7 +335,7 @@ async function insertInFile( newStr: string | undefined, exec: ToolRunContext, ): Promise { - if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') + if (insertLine === undefined) throw new ToolArgsError(['Parameter `insert_line` is required for command: insert']) const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) @@ -347,9 +347,9 @@ async function insertInFile( const before = await ctx.fs.readText(target, exec.signal) const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { - throw new Error( + throw new ToolArgsError([ `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, - ) + ]) } const after = [ ...lines.slice(0, insertLine), diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 14de14c4cd..e08c539c59 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -460,6 +460,7 @@ describe('tool-str-replace-editor', () => { const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') + expect(relative.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta') }) @@ -476,35 +477,38 @@ describe('tool-str-replace-editor', () => { await writeFile(threeLines, 'one\ntwo\nthree') await mkdir(directory) - const cases = [ - { command: null, path: ambiguous }, - { command: 'view', path: null }, - { command: 'view', path: '' }, - { command: 'view', path: join(root, 'missing.txt') }, - { command: 'view', path: ambiguous, view_range: [1] }, - { command: 'view', path: ambiguous, view_range: [0, 1] }, - { command: 'view', path: ambiguous, view_range: [1.5, 2] }, - { command: 'view', path: threeLines, view_range: [1, 99] }, - { command: 'view', path: threeLines, view_range: [2, 1] }, - { command: 'view', path: directory, view_range: [1, 1] }, - { command: 'create', path: join(root, 'new.txt') }, - { command: 'create', path: join(root, 'new.txt'), file_text: null }, - { command: 'create', path: ambiguous, file_text: 'overwrite' }, - { command: 'str_replace', path: ambiguous, new_str: 'x' }, - { command: 'str_replace', path: ambiguous, old_str: null, new_str: 'x' }, - { command: 'str_replace', path: ambiguous, old_str: 'same same', new_str: null }, - { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, - { command: 'insert', path: ambiguous, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: null, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: 0, new_str: null }, - { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, - { command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, - { command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, + const args = { name: 'ToolArgsError', code: 'INVALID_ARGS' } + const cases: [Record, { name: string; code: string }][] = [ + [{ command: null, path: ambiguous }, args], + [{ command: 'view', path: null }, args], + [{ command: 'view', path: '' }, args], + [{ command: 'view', path: join(root, 'missing.txt') }, { name: 'FsError', code: 'FS_NOT_FOUND' }], + [{ command: 'view', path: ambiguous, view_range: [1] }, args], + [{ command: 'view', path: ambiguous, view_range: [0, 1] }, args], + [{ command: 'view', path: ambiguous, view_range: [1.5, 2] }, args], + [{ command: 'view', path: threeLines, view_range: [1, 99] }, args], + [{ command: 'view', path: threeLines, view_range: [2, 1] }, args], + [{ command: 'view', path: directory, view_range: [1, 1] }, args], + [{ command: 'create', path: join(root, 'new.txt') }, args], + [{ command: 'create', path: join(root, 'new.txt'), file_text: null }, args], + [{ command: 'create', path: ambiguous, file_text: 'overwrite' }, { name: 'FsError', code: 'FS_ALREADY_EXISTS' }], + [{ command: 'str_replace', path: ambiguous, new_str: 'x' }, args], + [{ command: 'str_replace', path: ambiguous, old_str: null, new_str: 'x' }, args], + [{ command: 'str_replace', path: ambiguous, old_str: 'same same', new_str: null }, args], + [{ command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: null, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: 0, new_str: null }, args], + [{ command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, args], + [{ command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, args], + [{ command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, { name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }], ] - for (const args of cases) { - expect((await call(ctx, owner, args)).isError).toBe(true) + for (const [callArgs, info] of cases) { + const result = await call(ctx, owner, callArgs) + expect(result.isError).toBe(true) + expect({ args: callArgs, info: result.error?.info }).toEqual({ args: callArgs, info }) } expect(await readFile(ambiguous, 'utf8')).toBe('same same') From 7d6b1197ddf6d938c2583b0a6a037305ade2b335 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:43 +0800 Subject: [PATCH 014/149] feat(fs-search): report glob and grep argument failures as ToolArgsError Blank pattern or path and invalid include filters throw ToolArgsError (INVALID_ARGS) instead of a plain Error. --- packages/fs/tool-fs-search/README.i18n.yaml | 4 +-- packages/fs/tool-fs-search/README.md | 2 +- packages/fs/tool-fs-search/README.zh.md | 2 +- packages/fs/tool-fs-search/src/glob.ts | 10 +++---- packages/fs/tool-fs-search/src/grep.ts | 14 +++++----- .../fs/tool-fs-search/tests/tools.spec.ts | 27 ++++++++++++++----- 6 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index b1c75185bb..df28d1dc0a 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4aa9b74deef586bc zh: f855f815829d4e1e /deepseek-ai-dsh-tool-fs-search/use-this-package/failures-and-recovery: - en: d7a59e296308b2bd - zh: 66243fcd932fe49a + en: ce6065aa8f2a04b6 + zh: bc862345e48e52d4 /deepseek-ai-dsh-tool-fs-search/understand-the-implementation: en: 7546db09c46181e0 zh: 3aae6dc6a773511c diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 4c26ffa018..008741bb5d 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -76,7 +76,7 @@ Node deployments receive the `@vscode/ripgrep` platform package on supported mac ### Failures and recovery -Search failures carry the package-owned codes `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex or glob), `SEARCH_FAILED` (a failed launch, inaccessible target, signal kill, or malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over the cap), and `SEARCH_ABORTED` (cooperative timeout or caller cancellation). Exit 0 is success with results and exit 1 is a successful empty search; model argument mistakes stay ordinary tool argument errors. +Search failures carry the package-owned codes `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex or glob), `SEARCH_FAILED` (a failed launch, inaccessible target, signal kill, or malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over the cap), and `SEARCH_ABORTED` (cooperative timeout or caller cancellation). Exit 0 is success with results and exit 1 is a successful empty search; model argument mistakes throw `ToolArgsError` with `INVALID_ARGS`. ----- diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 10cae42308..2f38854de2 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -76,7 +76,7 @@ Node 部署在受支持的 macOS、Linux 与 Windows 目标上获得 `@vscode/ri ### 失败与恢复 -搜索失败携带本包定义的错误码:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则或 glob)、`SEARCH_FAILED`(启动失败、目标不可访问、信号终止或 `--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过上限)与 `SEARCH_ABORTED`(协作式超时或调用方取消)。退出 0 表示成功且有结果,退出 1 表示成功的空搜索;模型参数错误仍是普通工具参数错误。 +搜索失败携带本包定义的错误码:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则或 glob)、`SEARCH_FAILED`(启动失败、目标不可访问、信号终止或 `--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过上限)与 `SEARCH_ABORTED`(协作式超时或调用方取消)。退出 0 表示成功且有结果,退出 1 表示成功的空搜索;模型参数错误抛出 `ToolArgsError`,错误码为 `INVALID_ARGS`。 ----- diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 31df85aa53..99af2c5b75 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis' import { sep } from 'node:path' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' @@ -62,15 +62,15 @@ export interface GlobInput { /** * Validate value constraints the schema DSL can't express: a non-blank - * `pattern`, and a non-blank `path` when given. Throws a plain `Error` (an - * ordinary tool argument error) otherwise. + * `pattern`, and a non-blank `path` when given. Throws `ToolArgsError` + * (`INVALID_ARGS`) otherwise. * * @param args - the schema-validated `glob` arguments. * @returns the accepted input, unchanged. */ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInput { - if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string') - if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + if (args.pattern.trim().length === 0) throw new ToolArgsError(['pattern must be a non-empty string']) + if (args.path !== undefined && args.path.trim().length === 0) throw new ToolArgsError(['path must be a non-empty string when given']) return { pattern: args.pattern, ...args.path !== undefined ? { path: args.path } : {} } } diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index e0246d6b76..1a6fd00bd8 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -12,7 +12,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-output-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -65,14 +65,14 @@ export interface GrepInput { * group is fine — `*.{ts,tsx}` is one glob with alternation, not a list. */ function validateInclude(include: string): void { - if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given') - if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported') + if (include.trim().length === 0) throw new ToolArgsError(['include must be a non-empty glob when given']) + if (include.startsWith('!')) throw new ToolArgsError(['include must be a positive glob filter; negated patterns ("!…") are not supported']) let braceDepth = 0 for (const char of include) { if (char === '{') braceDepth++ else if (char === '}') braceDepth = Math.max(0, braceDepth - 1) else if (char === ',' && braceDepth === 0) { - throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)') + throw new ToolArgsError(['include must be one glob, not a comma-separated list (use {a,b} alternation instead)']) } } } @@ -82,14 +82,14 @@ function validateInclude(include: string): void { * `pattern` (whitespace is a legitimate regex), a non-blank `path` when given, * and a single positive `include` glob ({@link GrepInput}). An exact empty * `include` is treated as omitted; whitespace-only values remain invalid. - * Throws a plain `Error` (an ordinary tool argument error) otherwise. + * Throws `ToolArgsError` (`INVALID_ARGS`) otherwise. * * @param args - the schema-validated `grep` arguments. * @returns the accepted input with an empty `include` omitted. */ export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput { - if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string') - if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + if (args.pattern.length === 0) throw new ToolArgsError(['pattern must be a non-empty string']) + if (args.path !== undefined && args.path.trim().length === 0) throw new ToolArgsError(['path must be a non-empty string when given']) const include = args.include === '' ? undefined : args.include if (include !== undefined) validateInclude(include) return { diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5763aacc15..529d93805a 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -754,8 +754,14 @@ describe('glob results', () => { it('validates arguments (blank pattern, blank path)', async () => { const { ctx } = await setup() - expect(text(await call(ctx, 'glob', { pattern: ' ' }))).toContain('pattern must be a non-empty string') - expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string') + for (const [args, message] of [ + [{ pattern: ' ' }, 'pattern must be a non-empty string'], + [{ pattern: '*', path: ' ' }, 'path must be a non-empty string'], + ] as const) { + const result = await call(ctx, 'glob', args) + expect(text(result)).toContain(message) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + } }) it('threads a valid path through to the spawn as the plain search root element', async () => { @@ -1100,16 +1106,23 @@ describe('grep results', () => { const result = await call(ctx, 'grep', args) expect(result.isError).toBe(true) expect(text(result)).toContain(message) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) expect(subprocess.spawns).toHaveLength(0) }) it('validates arguments (empty pattern, blank path, bad include)', async () => { const { ctx } = await setup() - expect(text(await call(ctx, 'grep', { pattern: '' }))).toContain('pattern must be a non-empty string') - expect(text(await call(ctx, 'grep', { pattern: 'x', path: ' ' }))).toContain('path must be a non-empty string') - expect(text(await call(ctx, 'grep', { pattern: 'x', include: ' ' }))).toContain('include must be a non-empty glob') - expect(text(await call(ctx, 'grep', { pattern: 'x', include: '!*.ts' }))).toContain('negated patterns') - expect(text(await call(ctx, 'grep', { pattern: 'x', include: '*.ts,*.js' }))).toContain('comma-separated list') + for (const [args, message] of [ + [{ pattern: '' }, 'pattern must be a non-empty string'], + [{ pattern: 'x', path: ' ' }, 'path must be a non-empty string'], + [{ pattern: 'x', include: ' ' }, 'include must be a non-empty glob'], + [{ pattern: 'x', include: '!*.ts' }, 'negated patterns'], + [{ pattern: 'x', include: '*.ts,*.js' }, 'comma-separated list'], + ] as const) { + const result = await call(ctx, 'grep', args) + expect(text(result)).toContain(message) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + } }) it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => { From a0766d7573dff6789e3a8b8d303b64800215b22a Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:44 +0800 Subject: [PATCH 015/149] feat(tool-fs): report read, write, and edit argument failures as ToolArgsError read offset/limit/file_path, write file_path, edit file_path/old_string checks, and an unsupported sandbox_permissions request throw ToolArgsError (INVALID_ARGS). Integration tests pin that host metadata and permission faults now reach read results as FsError codes. --- packages/fs/tool-fs/src/edit.ts | 8 +++---- packages/fs/tool-fs/src/read.ts | 8 +++---- packages/fs/tool-fs/src/sandbox.ts | 3 ++- packages/fs/tool-fs/src/write.ts | 4 ++-- packages/fs/tool-fs/tests/integration.spec.ts | 23 ++++++++++++++++++- packages/fs/tool-fs/tests/tools.spec.ts | 10 ++++++++ 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index f927ccb1cb..8de03306ca 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -6,7 +6,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' @@ -45,9 +45,9 @@ interface EditToolArgs { * @returns the camelCased input with `replace_all` defaulted to false. */ export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') - if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string') - if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) + if (args.old_string.length === 0) throw new ToolArgsError(['old_string must be a non-empty string']) + if (args.old_string === args.new_string) throw new ToolArgsError(['old_string and new_string must differ']) return { filePath: args.file_path, oldString: args.old_string, diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 22c2dc52c9..fcd42c3e07 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -5,7 +5,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' @@ -41,7 +41,7 @@ interface ReadInput { function parsePositiveInteger(value: number, name: string): number { if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer`) + throw new ToolArgsError([`${name} must be a positive integer`]) } return value } @@ -53,10 +53,10 @@ function parsePositiveInteger(value: number, name: string): number { * @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`. */ export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit') - if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`) + if (limit > maxLimit) throw new ToolArgsError([`limit must be less than or equal to ${maxLimit}`]) return { filePath: args.file_path, offset, limit } } diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index c962d6a147..08b776fd92 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -11,6 +11,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { ToolArgsError } from '@deepseek-ai/dsh-tools' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' @@ -96,7 +97,7 @@ export class FsSandboxController { return standingPolicy } if (this.escalationModes.length === 0) { - throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)') + throw new ToolArgsError(['sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)']) } const policy = standingPolicy as SandboxExecutionPolicy const approvedMode = await approveEscalation( diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 0745ffc98d..37d1263d20 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -6,7 +6,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' @@ -23,7 +23,7 @@ import type { FsSandboxController } from './sandbox.ts' * @returns the camelCased input; `content` passes through untouched. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) return { filePath: args.file_path, content: args.content } } diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 25d428c8e6..32d42fd4bb 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -10,7 +10,7 @@ import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mountAgentLoopTestDependencies, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -355,6 +355,27 @@ describe('bare provider (no dsh-fs-observation-policy)', () => { expect(text(result)).toContain('1: alpha') }) + it('reports a host metadata fault as a typed filesystem failure', async () => { + await symlink(join(dir, 'loop'), join(dir, 'loop')) + const result = await call('read', { file_path: 'loop' }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_IO_ERROR' }) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('reports unreadable content as FS_PERMISSION_DENIED', async () => { + const file = join(dir, 'locked.txt') + await writeFile(file, 'secret') + await chmod(file, 0o000) + try { + const result = await call('read', { file_path: 'locked.txt' }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_PERMISSION_DENIED' }) + expect(text(result)).toContain('permission denied') + } finally { + await chmod(file, 0o600) + } + }) + it('write unconditionally creates a new file', async () => { const result = await call('write', { file_path: 'new.txt', content: 'fresh' }) expect(result.isError).toBe(false) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 8ba5a105cd..f1082845f2 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -287,6 +287,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 }) expect(result.isError).toBe(true) expect(text(result)).toContain('offset must be a positive integer') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects a fractional offset and a zero/negative limit', async () => { @@ -299,6 +300,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', args) expect(result.isError, JSON.stringify(args)).toBe(true) expect(text(result)).toMatch(/must be a positive integer/) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) } }) @@ -314,6 +316,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 }) expect(result.isError).toBe(true) expect(text(result)).toContain('less than or equal to 2000') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects a blank file_path', async () => { @@ -321,6 +324,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', { file_path: ' ' }) expect(result.isError).toBe(true) expect(text(result)).toContain('file_path must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('records observed state so a follow-up edit by the same session is authorized', async () => { @@ -456,6 +460,7 @@ describe('write tool', () => { const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' }) expect(result.isError).toBe(true) expect(text(result)).toContain('file_path must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('propagates a backend FsError as an isError result carrying its code and remedy', async () => { @@ -494,6 +499,7 @@ describe('edit tool', () => { const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' }) expect(result.isError).toBe(true) expect(text(result)).toContain('must differ') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects an empty old_string', async () => { @@ -501,6 +507,7 @@ describe('edit tool', () => { const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' }) expect(result.isError).toBe(true) expect(text(result)).toContain('old_string must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects a blank file_path', async () => { @@ -508,6 +515,7 @@ describe('edit tool', () => { const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) expect(text(result)).toContain('file_path must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => { @@ -788,6 +796,7 @@ describe('read caps are plugin config', () => { const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 }) expect(overCap.isError).toBe(true) expect(text(overCap)).toContain('less than or equal to 2') + expect(overCap.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) const readSchema = ctx.tools.schemas().find(s => s.name === 'read') expect(JSON.stringify(readSchema)).toContain('Defaults to 2.') }) @@ -1141,6 +1150,7 @@ describe('sandbox escalation API (write/edit)', () => { const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent()) expect(result.isError).toBe(true) expect(text(result)).toContain('not available in this composition') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) }) From b605b3dab2d4e5138a96d65e27e1537af3fe1f5a Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:45 +0800 Subject: [PATCH 016/149] feat(present): give present failures structured error codes A missing agent Session or open turn throws ToolContextUnavailableError, an empty path or out-of-range file count throws ToolArgsError, and a directory or final symlink throws FsError FS_NOT_REGULAR_FILE. --- .../tool-present/README.i18n.yaml | 4 +-- packages/deliverables/tool-present/README.md | 2 +- .../deliverables/tool-present/README.zh.md | 2 +- .../deliverables/tool-present/src/index.ts | 14 +++++----- .../tool-present/tests/present.spec.ts | 26 ++++++++++++++++--- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/deliverables/tool-present/README.i18n.yaml b/packages/deliverables/tool-present/README.i18n.yaml index 7de5647086..d5b2ce4e60 100644 --- a/packages/deliverables/tool-present/README.i18n.yaml +++ b/packages/deliverables/tool-present/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-present/use-this-package: - en: d1a1e22686f9041d - zh: 8053a913199195fa + en: aecb160dd6ee85ba + zh: 148288a2e92236fa /deepseek-ai-dsh-tool-present/understand-the-implementation: en: 6e73ca41dddc096e zh: b59fdfa3f3cc47e1 diff --git a/packages/deliverables/tool-present/README.md b/packages/deliverables/tool-present/README.md index 7439bda570..031cf546d4 100644 --- a/packages/deliverables/tool-present/README.md +++ b/packages/deliverables/tool-present/README.md @@ -29,7 +29,7 @@ Shared output and file-link guidance comes from [`dsh-deliverable-file-reference `present` validates paths against the Session's current working directory and records absolute paths, so later directory changes do not redirect delivered files. -The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. +The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call with `FsError` codes such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, and `FS_PERMISSION_DENIED`; an empty path or a file count outside 1 to `maxFiles` reports `ToolArgsError` (`INVALID_ARGS`), and a call without an agent Session or open turn reports `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`). Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available: diff --git a/packages/deliverables/tool-present/README.zh.md b/packages/deliverables/tool-present/README.zh.md index 40b81571aa..08410efe05 100644 --- a/packages/deliverables/tool-present/README.zh.md +++ b/packages/deliverables/tool-present/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `present` 按 Session 的当前工作目录校验路径并记录绝对路径,因此后续目录切换不会让已交付文件指向其他位置。 -`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 +`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用以 `FsError` 失败,错误码如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 与 `FS_PERMISSION_DENIED`;路径为空或文件数量不在 1 到 `maxFiles` 之间时报告 `ToolArgsError`(`INVALID_ARGS`),缺少 agent Session 或打开的 turn 时报告 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影: diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 393a95d8c8..fac6ab0378 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -3,7 +3,7 @@ import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' -import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, ToolContextUnavailableError, defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' @@ -76,21 +76,21 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }], }, async execute(args, exec) { - if (exec.agent === undefined) throw new Error('present requires an agent Session') + if (exec.agent === undefined) throw new ToolContextUnavailableError('present requires an agent Session') const boundary = ctx.agents.turnBoundaryOf(exec.agent.session) - if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') - if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) + if (boundary.openTurnStartSeq === null) throw new ToolContextUnavailableError('present requires an open turn') + if (args.files.length === 0 || args.files.length > config.maxFiles) throw new ToolArgsError([`present accepts 1 to ${config.maxFiles} files`]) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) const options = { cwd, signal: exec.signal } const files: PresentedFile[] = [] for (const file of args.files) { - if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path') + if (file.path.trim().length === 0) throw new ToolArgsError(['present requires a non-empty file path']) const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal) - if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + if (entry !== undefined && entry.type !== 'file') throw new FsError(`Cannot present ${file.path}: not a regular file`, 'FS_NOT_REGULAR_FILE') const target = await ctx.fs.resolve(file.path, options) const info = await ctx.fs.stat(target, exec.signal) if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND') - if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + if (info.type !== 'file') throw new FsError(`Cannot present ${file.path}: not a regular file`, 'FS_NOT_REGULAR_FILE') files.push({ ...file, path: ctx.fs.processPath(target) }) } exec.signal.throwIfAborted() diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 18da272027..6492740c99 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -140,9 +140,19 @@ describe('present', () => { const { root, owner, execute } = await setup() await writeFile(join(root, 'large'), 'four') await symlink(tmpdir(), join(root, 'outside')) - for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) { + const invalidArgs = { name: 'ToolArgsError', code: 'INVALID_ARGS' } + const notRegular = { name: 'FsError', code: 'FS_NOT_REGULAR_FILE' } + for (const [files, info] of [ + [[], invalidArgs], + [[{ path: '' }], invalidArgs], + [[{ path: 'missing' }], { name: 'FsError', code: 'FS_NOT_FOUND' }], + [[{ path: '.' }], notRegular], + [[{ path: 'outside' }], notRegular], + [[{ path: 'large' }, { path: 'large' }, { path: 'large' }], invalidArgs], + ] as const) { const result = await execute(files) expect(result.isError, JSON.stringify(files)).toBe(true) + expect(result.error?.info, JSON.stringify(files)).toEqual(info) } expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) @@ -163,9 +173,13 @@ it('validates deployment limits before registering the tool', async () => { it('requires an agent, an open turn, and a workspace', async () => { const { ctx, owner, execute } = await setup() const detached = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } }) + const contextUnavailable = { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } expect(detached.isError).toBe(true) + expect(detached.error?.info).toEqual(contextUnavailable) owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect((await execute([{ path: 'a' }])).isError).toBe(true) + const closed = await execute([{ path: 'a' }]) + expect(closed.isError).toBe(true) + expect(closed.error?.info).toEqual(contextUnavailable) const noWorkspace = await agent(ctx, undefined) noWorkspace.session.append('turn/start', { turn: 1 }) const absent = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) @@ -188,7 +202,9 @@ it('refuses a final symlink to an ordinary file', async () => { const { root, execute } = await setup() await writeFile(join(root, 'source'), 'source') await symlink(join(root, 'source'), join(root, 'link')) - expect((await execute([{ path: 'link' }])).isError).toBe(true) + const result = await execute([{ path: 'link' }]) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) @@ -197,5 +213,7 @@ it('refuses a file replaced by a directory after inspecting its final component' await writeFile(join(root, 'source'), 'source') const directory = await ctx.fs.stat(await ctx.fs.resolve(root)) vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory) - expect((await execute([{ path: 'source' }])).isError).toBe(true) + const result = await execute([{ path: 'source' }]) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) From ea2e1163c088292e25558527477cddff5bb63750 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:30:56 +0800 Subject: [PATCH 017/149] feat(tool-fs): add ImageInputError for read_image failures read_image refusals throw ImageInputError with a closed code list (IMAGE_UNSUPPORTED_MODEL, IMAGE_UNSUPPORTED_FORMAT, IMAGE_TYPE_MISMATCH, IMAGE_INVALID, IMAGE_TOO_LARGE, IMAGE_CONVERSION_FAILED, IMAGE_STORE_FAILED). Attachment-store rejections are classified at the tool boundary, keeping the AttachmentError as cause; HarnessError and AbortError pass through. An empty file_path throws ToolArgsError, and a missing model route or attachment service throws ToolContextUnavailableError. The README records that argument-check messages now carry the ToolArgsError "invalid arguments: " prefix. --- packages/fs/tool-fs/README.i18n.yaml | 12 +- packages/fs/tool-fs/README.md | 7 +- packages/fs/tool-fs/README.zh.md | 7 +- packages/fs/tool-fs/src/image-error.ts | 46 +++++ packages/fs/tool-fs/src/index.ts | 2 + packages/fs/tool-fs/src/read-image.ts | 144 ++++++++++------ packages/fs/tool-fs/tests/read-image.spec.ts | 163 +++++++++++++----- .../read-image-text-route/session.v4.jsonl | 2 +- 8 files changed, 274 insertions(+), 109 deletions(-) create mode 100644 packages/fs/tool-fs/src/image-error.ts diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index a421441ff4..15125f4f7e 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -30,8 +30,8 @@ en: 9bddebaf4aff0a8d zh: fea0196162e3fcd1 /deepseek-ai-dsh-tool-fs/use-this-package/failures-and-recovery: - en: f7c7834de37e1ece - zh: ff08efcf7c08379a + en: 177d25a1e8f7afbd + zh: 0303980d6b2789c3 /deepseek-ai-dsh-tool-fs/understand-the-implementation: en: e30f7038c28e93f8 zh: a3868dbd90367b13 @@ -39,8 +39,8 @@ en: 1f4d7213aa3d0d60 zh: aa6573f96c8b5220 /deepseek-ai-dsh-tool-fs/understand-the-implementation/source-map: - en: 459b773aafc6c163 - zh: ccd729aded7d98ba + en: 791043de5ce46710 + zh: c5315d0195908f83 /deepseek-ai-dsh-tool-fs/understand-the-implementation/per-tool-flow: en: 35fbee1ef9811806 zh: 17c725e2b742c073 @@ -126,8 +126,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-fs/model-experience/tool-errors/what-the-model-sees: - en: 4e8fac7bac8ced61 - zh: 7ba921dbcddc9f97 + en: 816a5ac724377eeb + zh: 6833892230a1264c /deepseek-ai-dsh-tool-fs/model-experience/tool-errors/token-effect: en: 681cadd5be7b8ac1 zh: 65e483f62fb819d9 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index aabec263d2..d7e519d577 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -83,7 +83,9 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` For `apply_patch`, syntax and preflight refusals throw with `isError: true` before any disk change. `FsError` preserves filesystem codes; `PatchToolError extends HarnessError` carries `PATCH_SYNTAX` and `PATCH_SYMLINK_SOURCE`. Once commit starts, the tool returns a value with `isError: false` even on failure or uncertainty, so known partial effects retain their diff metadata; a call cancelled during commit receives the tool registry's abort result like every other tool, and its completed changes remain visible through Review. `phase`, `cancelled`, ordered `operations` and `affectedPaths` describe the commit; each operation retains its index, kind, display paths, patch line, status, diagnostic and known changes. Full `before`/`after` text serves rendering and PTC; the Session stores the rendered result plus `meta.diffs` and `meta.stopped`. Creations store no diff, as for `write`; deletions and move sources store a removal diff only when the provider returned a bounded basis, using `newText: ''` because `FileDiff.newText` cannot be null. -Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include `file_path must be a non-empty string`, `limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. +`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; an `AbortError` or another `HarnessError` from the store passes through unchanged. + +Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include the `ToolArgsError` (`INVALID_ARGS`) messages `invalid arguments: file_path must be a non-empty string` and `invalid arguments: limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. ----- @@ -106,6 +108,7 @@ The tools are the executor; policy is an event gate. The tools inject no policy | [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, tool composition, `read_image` attachments gate | | [`src/read.ts`](src/read.ts) | `read` executor: one stat, streaming decision, window build, observation | | [`src/read-image.ts`](src/read-image.ts) | `read_image` executor: route and media-type gates, bounded bytes, attachment save | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` and its closed `read_image` failure codes | | [`src/write.ts`](src/write.ts) | `write` executor: intent waterfall, atomic write, observation | | [`src/apply-patch.ts`](src/apply-patch.ts), [`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` registration, strict commit values and compact diagnostics | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | Pure call previews and known result diffs | @@ -246,7 +249,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. The model-facing error wrapper normalizes every `FS_NOT_OBSERVED` source to `cannot modify "": file has not been read — read the file, then retry`; `FS_STALE_VERSION` keeps the provider's reason and adds `— re-read the file, then retry`. Both retain the structured error code and original cause. After that reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation messages are `ToolArgsError` (`INVALID_ARGS`) messages prefixed with `invalid arguments: `: `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, and `old_string and new_string must differ`. Its stable read messages are `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. The model-facing error wrapper normalizes every `FS_NOT_OBSERVED` source to `cannot modify "": file has not been read — read the file, then retry`; `FS_STALE_VERSION` keeps the provider's reason and adds `— re-read the file, then retry`. Both retain the structured error code and original cause. After that reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 2f5f153ed0..7e81223b57 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -83,7 +83,9 @@ kind: "package-reference" `apply_patch` 的语法与预检拒绝在磁盘变更前抛错,结果为 `isError: true`。`FsError` 保留文件系统错误码;`PatchToolError extends HarnessError` 携带 `PATCH_SYNTAX` 和 `PATCH_SYMLINK_SOURCE`。进入提交后,即使失败或结果不确定也返回 `isError: false` 的值,确保已知部分效果的 diff 元数据得以保留;提交期间被取消的调用与其他工具一样收到工具注册表的中止结果,已完成的变更仍可通过 Review 查看。`phase`、`cancelled`、有序 `operations` 与 `affectedPaths` 描述提交;每条操作保留索引、种类、展示路径、补丁行号、状态、诊断与已知变更。完整 `before`/`after` 文本供渲染和 PTC 使用;Session 保存渲染文本以及 `meta.diffs` 与 `meta.stopped`。新建与 `write` 一样不保存 diff;删除与移动源仅在提供方返回有界基线时保存删除 diff,并使用 `newText: ''`,因为 `FileDiff.newText` 不可为 null。 -失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `file_path must be a non-empty string`、`limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 +`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AbortError` 或其他 `HarnessError` 原样传递。 + +失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `ToolArgsError`(`INVALID_ARGS`)消息 `invalid arguments: file_path must be a non-empty string` 与 `invalid arguments: limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 ----- @@ -106,6 +108,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`Config`、工具组合、`read_image` 附件门禁 | | [`src/read.ts`](src/read.ts) | `read` 执行器:一次 stat、流式决策、窗口构建、观察 | | [`src/read-image.ts`](src/read-image.ts) | `read_image` 执行器:路由与媒体类型门禁、有界字节、附件保存 | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` 及其封闭的 `read_image` 失败码 | | [`src/write.ts`](src/write.ts) | `write` 执行器:意图 waterfall、原子写入、观察 | | [`src/apply-patch.ts`](src/apply-patch.ts)、[`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` 注册、严格提交值与精简诊断 | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | 纯调用预览与已知结果 diff | @@ -246,7 +249,7 @@ Read a file before editing it unless you just created or edited it in this sessi #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。模型侧错误包装把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。两者都保留结构化错误码和原始原因。该次重新读取确认缺失后,`edit` 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;`write` 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验消息是带 `invalid arguments: ` 前缀的 `ToolArgsError`(`INVALID_ARGS`)消息:`file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string` 与 `old_string and new_string must differ`。稳定的读取消息是 `cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。模型侧错误包装把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。两者都保留结构化错误码和原始原因。该次重新读取确认缺失后,`edit` 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;`write` 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts new file mode 100644 index 0000000000..102942e9c6 --- /dev/null +++ b/packages/fs/tool-fs/src/image-error.ts @@ -0,0 +1,46 @@ +/** Typed `read_image` failures. @module @deepseek-ai/dsh-tool-fs/src/image-error */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every machine-routable `read_image` failure code: + * + * - `IMAGE_UNSUPPORTED_MODEL`: the calling route does not declare image input; switch to an image-capable model. + * - `IMAGE_UNSUPPORTED_FORMAT`: the extension, the file signature, or the deployment's accepted media types + * exclude the format; convert the file to an accepted format or read another file. + * - `IMAGE_TYPE_MISMATCH`: the extension or file signature declares one format but the bytes decode as another; rename or convert the file. + * - `IMAGE_INVALID`: bytes in a supported format do not decode; the file is truncated or corrupt. + * - `IMAGE_TOO_LARGE`: a side, the decoded pixel count, or the stored byte budget exceeds the deployment limit; + * downscale the image and read the smaller copy. + * - `IMAGE_CONVERSION_FAILED`: a 16-bit PNG could not be normalized to 8-bit sRGB; convert it to an 8-bit format. + * - `IMAGE_STORE_FAILED`: the attachment store could not commit the image for another reason; report the cause + * instead of retrying unchanged. + */ +export const IMAGE_INPUT_ERROR_CODES = [ + 'IMAGE_UNSUPPORTED_MODEL', + 'IMAGE_UNSUPPORTED_FORMAT', + 'IMAGE_TYPE_MISMATCH', + 'IMAGE_INVALID', + 'IMAGE_TOO_LARGE', + 'IMAGE_CONVERSION_FAILED', + 'IMAGE_STORE_FAILED', +] as const + +/** One `read_image` failure code. */ +export type ImageInputErrorCode = typeof IMAGE_INPUT_ERROR_CODES[number] + +/** Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. */ +export class ImageInputError extends HarnessError { + override readonly code: ImageInputErrorCode + + /** + * @param message - model-facing refusal naming the path and the safe next step. + * @param code - stable failure code. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: ImageInputErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'ImageInputError' + this.code = code + } +} diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 41e3be251c..9e3e3095d5 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -17,6 +17,8 @@ import { applyReadImageTool } from './read-image.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxController } from './sandbox.ts' +export { ImageInputError, type ImageInputErrorCode } from './image-error.ts' + /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 4a452e21c4..b8bacbcba4 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -16,9 +16,12 @@ import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { ImageInputError } from './image-error.ts' import { resolveRegularReadTarget } from './read-target.ts' /** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */ @@ -116,6 +119,8 @@ export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefi * @param ctx - the plugin context used to resolve the optional `llm` service. * @param exec - the tool-execution context supplying the calling agent. * @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages. + * @throws ToolContextUnavailableError when no Agent route or `llm` service is available. + * @throws ImageInputError `IMAGE_UNSUPPORTED_MODEL` when the route does not declare image input. */ export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, requestedPath: string): Promise { const routed = exec.agent?.session.requestHeader()?.config @@ -123,18 +128,21 @@ export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, const model = routed?.model ?? exec.agent?.options.model const llm = ctx.get('llm') if (provider === undefined || model === undefined || llm === undefined) { - throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`) + throw new ToolContextUnavailableError(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`) } const active = await llm.resolveModelInfo(provider, model, exec.signal) if (active.inputModalities === undefined || !active.inputModalities.includes('image')) { - throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) + throw new ImageInputError( + `cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`, + 'IMAGE_UNSUPPORTED_MODEL', + ) } } /** Refuse a media type outside the deployment's accepted set, naming the offending path. */ function assertDeploymentAccepts(attachments: AttachmentStore, mediaType: ImageMediaType, displayPath: string): void { if (!attachments.imageLimits.mediaTypes.includes(mediaType)) { - throw new Error(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`) + throw new ImageInputError(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`, 'IMAGE_UNSUPPORTED_FORMAT') } } @@ -197,6 +205,72 @@ function imageReadContent(value: ImageReadValue): ContentBlock[] { ] } +/** Facts about one image read that a save-failure report names. */ +interface SaveFailureFacts { + displayPath: string + /** Lower-cased path extension; empty for an extension-less path. */ + extension: string + mediaType: ImageMediaType + /** Whether `mediaType` came from the file signature instead of the extension. */ + sniffed: boolean +} + +/** + * Classify one `saveImage` rejection as the error `read_image` throws. + * An `AbortError` and an already classified `HarnessError` pass through + * unchanged; attachment admission codes become {@link ImageInputError} codes, + * and every other failure becomes `IMAGE_STORE_FAILED` with the original as `cause`. + * @param error - the value `saveImage` rejected with. + * @param attachments - the store whose limits the refusal names. + * @param facts - the path and media-type facts the refusal names. + * @returns the error to throw from the tool body. + */ +function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFailureFacts): unknown { + if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error + const { displayPath, extension, mediaType, sniffed } = facts + const refusal = (detail: string, code: ImageInputError['code']) => + new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) + const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') + if (!(error instanceof AttachmentError)) return storeFailed(error instanceof Error ? error.message : String(error)) + // Size refusals stay recoverable tool errors: an oversized image must never + // enter durable history, where it would ride every later model request past + // provider-side dimension rejections. + switch (error.code) { + case 'IMAGE_DIMENSION_TOO_LARGE': + return refusal(`at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + case 'IMAGE_TOO_MANY_PIXELS': + return refusal(`the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + case 'IMAGE_TOO_LARGE': + case 'IMAGES_TOO_LARGE': + return refusal('the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy', 'IMAGE_TOO_LARGE') + case 'UNSUPPORTED_IMAGE_TYPE': + return refusal(`${mediaType} images are not accepted by this deployment`, 'IMAGE_UNSUPPORTED_FORMAT') + case 'INVALID_IMAGE': + return refusal('the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt', 'IMAGE_INVALID') + case 'IMAGE_TYPE_MISMATCH': + return sniffed + ? refusal(`the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, 'IMAGE_TYPE_MISMATCH') + : refusal(`the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, 'IMAGE_TYPE_MISMATCH') + case 'ATTACHMENT_WRITE_FAILED': + return /16-bit PNG/iu.test(error.message) + ? refusal('the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry', 'IMAGE_CONVERSION_FAILED') + : storeFailed(error.message) + case 'TOO_MANY_IMAGES': + case 'INVALID_IMAGE_BASE64': + case 'INVALID_FILE_BASE64': + case 'INVALID_ATTACHMENT_REF': + case 'ATTACHMENT_CORRUPT': + case 'ATTACHMENT_NOT_FOUND': + case 'ATTACHMENT_READ_FAILED': + case 'ATTACHMENT_PROJECTION_UNSUPPORTED': + case 'ATTACHMENT_FILES_UNSUPPORTED': + return storeFailed(error.message) + /* v8 ignore next -- Closed AttachmentErrorCode union; assertNever checks exhaustiveness at compile time. */ + default: + return assertNever(error.code) + } +} + /** * Register the `read_image` tool into the given context. The composing plugin * owns the attachments gate: `src/index.ts` calls this inside @@ -238,7 +312,7 @@ export function applyReadImageTool(ctx: Context): void { // of the same file cannot conflict. isConcurrencySafe: () => true, async execute(args, exec) { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) // Every pre-read gate runs before any filesystem I/O so a refusal never // leaks partial reads or attachment writes. An extension-less path @@ -247,11 +321,14 @@ export function applyReadImageTool(ctx: Context): void { const extension = extname(args.file_path).toLowerCase() const declared = imageMediaTypeForPath(args.file_path) if (declared === undefined && extension !== '') { - throw new Error(`cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`) + throw new ImageInputError( + `cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, + 'IMAGE_UNSUPPORTED_FORMAT', + ) } const attachments = ctx.get('attachments') if (attachments === undefined) { - throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) + throw new ToolContextUnavailableError(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) } if (declared !== undefined) assertDeploymentAccepts(attachments, declared, args.file_path) await assertImageCapableRoute(ctx, exec, args.file_path) @@ -264,7 +341,10 @@ export function applyReadImageTool(ctx: Context): void { const data = await ctx.fs.readBytes(target, exec.signal, byteCap) const mediaType = declared ?? sniffImageMediaType(data) if (mediaType === undefined) { - throw new Error(`cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`) + throw new ImageInputError( + `cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, + 'IMAGE_UNSUPPORTED_FORMAT', + ) } if (declared === undefined) assertDeploymentAccepts(attachments, mediaType, target.displayPath) // Persist before returning: the image block must reference a durably @@ -273,51 +353,9 @@ export function applyReadImageTool(ctx: Context): void { try { ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) } catch (error: unknown) { - if (!(error instanceof AttachmentError)) throw error - // Dimension refusals stay recoverable tool errors: an oversized image - // must never enter durable history, where it would ride every later - // model request past provider-side dimension rejections. - if (error.code === 'IMAGE_DIMENSION_TOO_LARGE') { - throw new Error( - `cannot read "${target.displayPath}": at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, - { cause: error }, - ) - } - if (error.code === 'IMAGE_TOO_MANY_PIXELS') { - throw new Error( - `cannot read "${target.displayPath}": the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, - { cause: error }, - ) - } - if (error.code === 'IMAGE_TOO_LARGE') { - throw new Error( - `cannot read "${target.displayPath}": the image cannot be stored within the deployment's byte limits; downscale the image and read the smaller copy`, - { cause: error }, - ) - } - if (error.code === 'ATTACHMENT_WRITE_FAILED' && /16-bit PNG/iu.test(error.message)) { - throw new Error( - `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, - { cause: error }, - ) - } - if (error.code === 'INVALID_IMAGE' && declared === undefined) { - throw new Error( - `cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, - { cause: error }, - ) - } - if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error - if (declared === undefined) { - throw new Error( - `cannot read "${target.displayPath}": the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, - { cause: error }, - ) - } - throw new Error( - `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, - { cause: error }, - ) + throw saveFailure(error, attachments, { + displayPath: target.displayPath, extension, mediaType, sniffed: declared === undefined, + }) } ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) const value: ImageReadValue = { diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 97087edf7e..6c2feac7a4 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -26,6 +26,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import { AttachmentError, AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { FsError } from '@deepseek-ai/dsh-fs' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { @@ -372,6 +373,7 @@ describe('extension-less paths', () => { const trailingDot = await readImage(ctx, { file_path: 'foo.' }, agentOn('vision-model')) expect(trailingDot.isError).toBe(true) expect(text(trailingDot)).toContain('cannot read "foo.": the . extension does not declare a supported image format') + expect(trailingDot.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) const dotfile = await readImage(ctx, { file_path: '.hidden' }, agentOn('vision-model')) expect(dotfile.isError).toBe(false) @@ -386,6 +388,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'notes' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain(`cannot read "${join(dir, 'notes')}": the file content is not a supported image format`) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) it('explains extension-less bytes that sniff as an image but do not decode', async () => { @@ -394,6 +397,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'broken' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_INVALID' }) }) it('applies the deployment media-type policy to the sniffed format', async () => { @@ -426,6 +430,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'avatar' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('image/png images are not accepted by this deployment') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) it('names a signature/decoded-format disagreement on an extension-less path', async () => { @@ -458,6 +463,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'sniffed' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('the file signature claims image/png, but the bytes decode as a different image format') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) }) }) @@ -484,6 +490,7 @@ describe('strict image-modality gate', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn(model)) expect(result.isError).toBe(true) expect(text(result)).toContain('does not declare image input') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_MODEL' }) }) it('refuses when the route cannot be resolved (no agent, or no header and no options)', async () => { @@ -492,10 +499,12 @@ describe('strict image-modality gate', () => { const noAgent = await readImage(ctx, { file_path: 'red.png' }) expect(noAgent.isError).toBe(true) expect(text(noAgent)).toContain('route could not be resolved') + expect(noAgent.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) const noRoute = await readImage(ctx, { file_path: 'red.png' }, agentOn(undefined)) expect(noRoute.isError).toBe(true) expect(text(noRoute)).toContain('route could not be resolved') + expect(noRoute.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) }) it('refuses when no llm service is mounted', async () => { @@ -504,6 +513,7 @@ describe('strict image-modality gate', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('route could not be resolved') + expect(result.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) }) }) @@ -512,11 +522,13 @@ describe('argument and service preconditions', () => { const ctx = await setup() const empty = await readImage(ctx, { file_path: ' ' }, agentOn('vision-model')) expect(empty.isError).toBe(true) - expect(text(empty)).toContain('non-empty') + expect(text(empty)).toContain('file_path must be a non-empty string') + expect(empty.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) const nonImage = await readImage(ctx, { file_path: 'notes.txt' }, agentOn('vision-model')) expect(nonImage.isError).toBe(true) expect(text(nonImage)).toContain('the .txt extension does not declare a supported image format') + expect(nonImage.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) it.each([{}, { toolFs: { read: false, write: false, edit: false } }] satisfies SetupOptions[]) @@ -537,6 +549,7 @@ describe('argument and service preconditions', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('no attachment service is mounted') + expect(result.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) }) it('refuses a media type the deployment does not accept', async () => { @@ -568,6 +581,7 @@ describe('argument and service preconditions', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('image/png images are not accepted by this deployment') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) }) @@ -579,6 +593,7 @@ describe('image admission failures', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('the .jpg extension declares image/jpeg') expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) }) it('caps an extension-less read at maxImageBytes before format detection', async () => { @@ -587,6 +602,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'red' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds') + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_TOO_LARGE' }) }) it('honors the tighter per-message aggregate byte bound', async () => { @@ -595,6 +611,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds') + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_TOO_LARGE' }) }) it('surfaces the pixel limit from the attachment admission', async () => { @@ -603,6 +620,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) @@ -612,61 +630,114 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('at least one image side exceeds the 2px limit') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) - it('passes storage faults and non-attachment failures through unchanged', async () => { - /** Store whose commit fails with a configurable error; admission itself passes. */ - class FailingStore extends AttachmentStore { - static failure: unknown - readonly imageLimits: ImageAttachmentLimits = Object.freeze({ - maxImageBytes: 1024, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1024, - maxImagePixels: 100, - maxImageDimension: 2000, - mediaTypes: Object.freeze(['image/png'] as const), - }) + /** Store whose commit runs a configurable failure; admission itself passes. */ + class FailingStore extends AttachmentStore { + static failure: () => unknown = () => new Error('unset') + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), + }) - validateImage(_input: SaveImageAttachment): Promise { - return Promise.resolve() - } - - async saveImage(_input: SaveImageAttachment): Promise { - throw FailingStore.failure - } - - readImage(_ref: ImageAttachmentRef): Promise { - throw new Error('unreachable in this test') - } + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() } - await writeFile(join(dir, 'red.png'), PNG_1X1) + + async saveImage(_input: SaveImageAttachment): Promise { + throw FailingStore.failure() + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + + async function failingSave(failure: () => unknown, file = 'red.png', signal = testToolSignal) { + await writeFile(join(dir, file), PNG_1X1) const ctx = await setup({ attachments: false }) await ctx.plugin(FailingStore) + FailingStore.failure = failure + return executeToolFixture(ctx, { + signal, + callId: ToolCallId(`img-call-${++callCounter}`), + name: 'read_image', + arguments: { file_path: file }, + agent: agentOn('vision-model') as never, + }) + } - FailingStore.failure = new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED') - const storageFault = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(storageFault.isError).toBe(true) - expect(text(storageFault)).toContain('Unable to persist image attachment.') + it.each([ + ['IMAGE_TOO_LARGE', 'Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE', + 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], + ['IMAGES_TOO_LARGE', 'Image batch exceeds the configured aggregate image-byte limit.', 'IMAGE_TOO_LARGE', + 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], + ['UNSUPPORTED_IMAGE_TYPE', 'Image type image/png is not accepted by this deployment.', 'IMAGE_UNSUPPORTED_FORMAT', + 'image/png images are not accepted by this deployment'], + ['INVALID_IMAGE', 'Unsupported or malformed image data.', 'IMAGE_INVALID', + 'the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt'], + ['ATTACHMENT_WRITE_FAILED', 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'IMAGE_CONVERSION_FAILED', + 'the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry'], + ['ATTACHMENT_WRITE_FAILED', 'Unable to persist attachment.', 'IMAGE_STORE_FAILED', + 'the attachment store could not save the image: Unable to persist attachment.'], + ...([ + 'TOO_MANY_IMAGES', 'INVALID_IMAGE_BASE64', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF', 'ATTACHMENT_CORRUPT', + 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', 'ATTACHMENT_FILES_UNSUPPORTED', + ] as const).map(code => [code, 'Stored attachment failed.', 'IMAGE_STORE_FAILED', + 'the attachment store could not save the image: Stored attachment failed.'] as const), + ] as const)('classifies AttachmentError %s (%s) as %s', async (attachmentCode, message, code, detail) => { + const cause = new AttachmentError(message, attachmentCode) + const result = await failingSave(() => cause) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code }) + expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": ${detail}`) + }) - FailingStore.failure = new AttachmentError( - 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', - 'ATTACHMENT_WRITE_FAILED', - ) - const sixteenBit = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(text(sixteenBit)).toContain( - `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, - ) + it('wraps a non-attachment save failure as IMAGE_STORE_FAILED', async () => { + const result = await failingSave(() => new Error('unrelated infrastructure failure')) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) + expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": the attachment store could not save the image: unrelated infrastructure failure`) - FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') - const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(overBudget.isError).toBe(true) - expect(text(overBudget)).toContain('cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy') + const nonError = await failingSave(() => 'string failure') + expect(nonError.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) + expect(text(nonError)).toContain('the attachment store could not save the image: string failure') - FailingStore.failure = new Error('unrelated infrastructure failure') - const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(unrelated.isError).toBe(true) - expect(text(unrelated)).toContain('unrelated infrastructure failure') + }) + + it('passes a classified HarnessError and an AbortError through unchanged', async () => { + const fsFailure = await failingSave(() => new FsError('cannot read "x": permission denied', 'FS_PERMISSION_DENIED')) + expect(fsFailure.error?.info).toEqual({ name: 'FsError', code: 'FS_PERMISSION_DENIED' }) + expect(text(fsFailure)).toBe('Error: cannot read "x": permission denied') + + const abort = await failingSave(() => new DOMException('store stopped', 'AbortError')) + expect(text(abort)).toContain('store stopped') + expect(text(abort)).not.toContain('cannot read') + }) + + it('lets the registry classify an AbortError thrown after caller cancellation', async () => { + const controller = new AbortController() + const result = await failingSave(() => { + controller.abort() + return new DOMException('save cancelled', 'AbortError') + }, 'red.png', controller.signal) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect(text(result)).not.toContain('cannot read') + }) + + it('classifies a save failure that is not cancellation even after the caller aborts', async () => { + const controller = new AbortController() + const result = await failingSave(() => { + controller.abort() + return new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED') + }, 'red.png', controller.signal) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) }) it('reports a missing image file and a directory target through the fs vocabulary', async () => { @@ -677,11 +748,13 @@ describe('image admission failures', () => { const missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model')) expect(missing.isError).toBe(true) expect(text(missing)).toContain('not found') + expect(missing.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_FOUND' }) expect(observed).toEqual([{ path: join(dir, 'absent.png'), kind: 'absent' }]) const directory = await readImage(ctx, { file_path: 'folder.png' }, agentOn('vision-model')) expect(directory.isError).toBe(true) expect(text(directory)).toContain('not a regular file') + expect(directory.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) it('omits the display name when the store returns a reference without one', async () => { diff --git a/snapshots/session/read-image-text-route/session.v4.jsonl b/snapshots/session/read-image-text-route/session.v4.jsonl index bd46aa46ef..3c4c5b47e6 100644 --- a/snapshots/session/read-image-text-route/session.v4.jsonl +++ b/snapshots/session/read-image-text-route/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Use read_image on red.png in","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699420816,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1788699420816,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}},{"type":"chunk","time":1788699420816,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699420816,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"read-image-refused"},"toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"read-image-refused"},"toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true,"id":"{{message:5}}"},"error":{"name":"ImageInputError","code":"IMAGE_UNSUPPORTED_MODEL"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699420834,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788699420834,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNAVAILABLE"}}},{"type":"chunk","time":1788699420834,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699420834,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From d17fad9b9ee150e5b1372af2f7c06776568fc726 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Fri, 6 Nov 2026 14:43:56 +0800 Subject: [PATCH 018/149] fix(agent): classify closed admission and document migration --- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 12 +++++++----- docs/subsystems/core.zh.md | 12 +++++++----- .../agent-closed-admission/guide.i18n.yaml | 16 ++++++++++++++++ .../v0.2.2-rc.1/agent-closed-admission/guide.md | 17 +++++++++++++++++ .../agent-closed-admission/guide.zh.md | 17 +++++++++++++++++ packages/core/agent-loop/src/index.ts | 7 ++----- packages/core/agent-loop/tests/cancel.spec.ts | 2 +- .../tests/fixtures/serial-created.mjs | 9 +++------ .../agent-loop/tests/input-lifecycle.spec.ts | 17 ++++++++++++----- packages/core/agent/README.i18n.yaml | 4 ++-- packages/core/agent/README.md | 2 ++ packages/core/agent/README.zh.md | 2 ++ packages/core/agent/src/closed-error.ts | 8 ++++---- packages/core/agent/src/runtime-types.ts | 10 +++++----- packages/sdk/server/src/server.ts | 5 ++--- packages/subagent/subagent/src/manager.ts | 3 +-- scripts/smoke-python-runtime.py | 1 - .../python-sdk-single-exe/advanced/result.json | 8 ++++---- .../advanced/session.1.v4.jsonl | 4 ++-- .../advanced/session.2.v4.jsonl | 4 ++-- 21 files changed, 110 insertions(+), 54 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.zh.md diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 363584a5fd..c3495dec81 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -12,8 +12,8 @@ en: d71c669c1a36bf52 zh: 9b335f90c84a894e /core/the-agent-handle: - en: 6ee15fec81bc0686 - zh: b6f3fcd48823ea75 + en: bb0aec7a50800c1e + zh: a58a3392368f644a /core/initiating-agent: en: 6c8c47ac78de2dde zh: c9d527a25a85d1d9 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 7b8503cb1a..7e86e6e0d1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -98,7 +98,7 @@ interface Agent { * inbox until the task settles, while public status stays `idle`. * `whenIdle()` follows both the task and any waking work released behind it. * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. - * @throws AgentClosedError after disposal begins, or an Error when another activity owns the agent. + * @throws AgentClosedError once the Agent lifetime is aborted, or an Error when another activity owns the agent. * @returns the task promise. */ runMaintenance(task: (signal: AbortSignal) => Promise): Promise @@ -113,7 +113,7 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ send(message: UserMessage, target: InboxTarget, wakeup: boolean): void @@ -121,7 +121,7 @@ interface Agent { * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ followup(message: UserMessage): void @@ -131,7 +131,7 @@ interface Agent { * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ steer(message: UserMessage): void @@ -142,7 +142,7 @@ interface Agent { * wakes them. It may miss a request whose pre-step already claimed its * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ inject(message: UserMessage): void } @@ -374,6 +374,8 @@ The cause is a TypeScript-enforced same-process input. An active cancellation ho The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. +`AgentClosedError` extends `HarnessError` with code `AGENT_CLOSED` and the affected `agentId`. Admission closes when the caller cancels creation, the Agent starts disposal, or its factory begins shutdown. The creation signal stops governing admission after creation succeeds. + ## Initiating Agent The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9f0bf51061..c82be05f03 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -102,7 +102,7 @@ interface Agent { * inbox until the task settles, while public status stays `idle`. * `whenIdle()` follows both the task and any waking work released behind it. * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. - * @throws AgentClosedError after disposal begins, or an Error when another activity owns the agent. + * @throws AgentClosedError once the Agent lifetime is aborted, or an Error when another activity owns the agent. * @returns the task promise. */ runMaintenance(task: (signal: AbortSignal) => Promise): Promise @@ -117,7 +117,7 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ send(message: UserMessage, target: InboxTarget, wakeup: boolean): void @@ -125,7 +125,7 @@ interface Agent { * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ followup(message: UserMessage): void @@ -135,7 +135,7 @@ interface Agent { * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ steer(message: UserMessage): void @@ -146,7 +146,7 @@ interface Agent { * wakes them. It may miss a request whose pre-step already claimed its * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ inject(message: UserMessage): void } @@ -380,6 +380,8 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有 +`AgentClosedError` 继承 `HarnessError`,包含错误码 `AGENT_CLOSED` 和受影响的 `agentId`。调用方取消创建、Agent 开始释放或工厂开始关闭时,Agent 停止接纳新工作。创建成功后,创建信号不再控制工作接纳。 + ## 发起 Agent `ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md)定义其生命周期和作用域规则。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.i18n.yaml new file mode 100644 index 0000000000..83fa03b9c8 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.md +/: + en: e4666ee6e813e7a3 + zh: ab68705b61d0da3c +/handle-agent-input-rejected-during-shutdown: + en: 5ec42a6575141728 + zh: a5174b0e04526c58 +/handle-agent-input-rejected-during-shutdown/change: + en: def59fb50e36f026 + zh: 91e79aad0ea758ad +/handle-agent-input-rejected-during-shutdown/migration: + en: e4c7ffd0e06c01bf + zh: 7fc1f43747209fb7 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.md new file mode 100644 index 0000000000..7aaff2efc6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "Agent input and maintenance methods throw AgentClosedError once the Agent stops accepting new work." +--- + +# Handle Agent input rejected during shutdown + +English | [中文](guide.zh.md) + +## Change + +In v0.2.2-rc.1, a retained `Agent` could accept input while teardown was in progress. The Agent now rejects new input and maintenance work with `AgentClosedError` as soon as its lifetime is aborted. It can still appear in the registry while admitted work settles. + +## Migration + +1. In extensions that call `Agent.send`, `followup`, `steer`, `inject`, or `runMaintenance`, handle the synchronous `AgentClosedError` from `@deepseek-ai/dsh-agent` at the call site. Treat code `AGENT_CLOSED` as an unavailable Agent and preserve handling for other failures. +2. Verify by disposing an Agent with a maintenance task still settling. A subsequent input call throws `AgentClosedError` without adding an inbox entry. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.zh.md new file mode 100644 index 0000000000..9ee1eb17a1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/agent-closed-admission/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "Agent 停止接纳新工作后,输入和维护方法会抛出 AgentClosedError。" +--- + +# 处理关闭期间被拒绝的 Agent 输入 + +[English](guide.md) | 中文 + +## 变更 + +在 v0.2.2-rc.1 中,调用方持有的 `Agent` 在清理过程中仍可能接收输入。现在,Agent 的生命周期信号一旦中止,就会以 `AgentClosedError` 拒绝新输入和维护工作。已接纳的工作收尾期间,注册表中仍可能找到该 Agent。 + +## 迁移 + +1. 若扩展调用 `Agent.send`、`followup`、`steer`、`inject` 或 `runMaintenance`,请在调用处处理从 `@deepseek-ai/dsh-agent` 导出的同步异常 `AgentClosedError`。收到 `AGENT_CLOSED` 时按 Agent 不可用处理,保留其他失败的处理逻辑。 +2. 在维护任务尚未结束时开始释放 Agent 以验证改动。之后调用输入方法应抛出 `AgentClosedError`,且不向 inbox 添加条目。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index a0b02d4eee..ecdafadb9d 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -469,11 +469,8 @@ export class AgentLoop extends Service implements AgentFactory { const id = session.id const loopCtx = this.runtime.ctx - // Deactivation fuses three owners, each with its own reason: the caller's - // cancellation signal, the owner fiber's unload, and factory teardown. - // It is registered BEFORE any resource exists, over mutable slots, so an - // unload arriving while the scope is still minting finds a working - // disposer instead of a leak. + // The caller's signal governs creation. Owner disposal and factory + // shutdown can end the Agent's lifetime both during and after creation. const abort = new AbortController() const onCallerAbort = (): void => { abort.abort(callerSignal?.reason instanceof Error diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 2ca5eb0299..434857afe9 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -324,7 +324,7 @@ describe('Agent.cancel()', () => { send(agent, 'active') await streaming.promise const disposal = handle.dispose() - expect(() =>{ send(agent, 'late wake') }).toThrow(AgentClosedError) + expect(() => { send(agent, 'late wake') }).toThrow(AgentClosedError) await disposal expect(adapter.requests).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs index 1295611023..2b27a7f33b 100644 --- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -6,11 +6,11 @@ export const name = 'serial-created-fixture' export const inject = ['agents', 'sessions'] /** Install ordered creation listeners whose context must precede the first turn. */ -export function apply(ctx, config = {}) { +export function apply(ctx) { const checkedCancellation = new WeakSet() ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => { if (checkedCancellation.has(agent) - || (config.checkRootCancellation !== true && agent.session.header.origin !== 'subagent')) return next() + || agent.session.header.origin !== 'subagent') return next() checkedCancellation.add(agent) const pending = { id: randomUUID(), role: 'user', @@ -35,10 +35,7 @@ export function apply(ctx, config = {}) { agent.inbox.remove(pending.id) for (const message of messages) { const ordinary = message.source.kind === 'user' - const restored = message.source.kind === `plugin:${name}` - ? { ...message, content: [{ type: 'text', text: message.content[0].text + ' Queue-clear failures still cancel active work.' }] } - : message - agent.send(restored, ordinary ? 'next-turn' : 'next-step', ordinary) + agent.send(message, ordinary ? 'next-turn' : 'next-step', ordinary) } return next() }) diff --git a/packages/core/agent-loop/tests/input-lifecycle.spec.ts b/packages/core/agent-loop/tests/input-lifecycle.spec.ts index 560fea3ca1..3e9bb2cf10 100644 --- a/packages/core/agent-loop/tests/input-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/input-lifecycle.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { AgentClosedError, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { HarnessError, createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -52,7 +52,6 @@ async function harness() { return { ctx, root, adapter, handle, agent: handle.agent } } - it('cancellation still aborts the running request when queue clearing fails', async () => { const { ctx, agent } = await harness() const entered = Promise.withResolvers() @@ -73,6 +72,7 @@ it('cancellation still aborts the running request when queue clearing fails', as }) expect(() => { agent.cancel({ kind: 'user' }) }).toThrow('injected queue clear failure') expect(signal.aborted).toBe(true) + expect(agent.inbox.nextTurn).toEqual([queued]) } finally { stopRejecting() hold() @@ -82,7 +82,6 @@ it('cancellation still aborts the running request when queue clearing fails', as } }) - it('an Agent still in the registry rejects new input while its owner is closing', async () => { const { ctx, agent, handle } = await harness() const release = Promise.withResolvers() @@ -96,6 +95,16 @@ it('an Agent still in the registry rejects new input while its owner is closing' await aborted.promise expect(ctx.agents.get(agent.id)).toBe(agent) const message = input('during closing') + const error = vi.fn((failure: unknown) => failure) + try { + agent.followup(message) + } catch (failure) { + error(failure) + } + expect(error).toHaveBeenCalledWith(expect.objectContaining({ + name: 'AgentClosedError', code: 'AGENT_CLOSED', agentId: agent.id, + })) + expect(error.mock.calls[0]?.[0]).toBeInstanceOf(HarnessError) for (const submit of [() => { agent.followup(message) }, () => { agent.steer(message) }, () => { agent.inject(message) }, () => { agent.send(message, 'next-turn', true) }, () => agent.runMaintenance(async () => {}), () => { agent.inbox.append('next-turn', message) }, () => { agent.inbox.prepend('next-step', message) }, () => agent.inbox.splice('next-turn', 0, 0, [message]), () => agent.inbox.replace(message.id, message)]) { expect(submit).toThrow(AgentClosedError) } @@ -108,8 +117,6 @@ it('an Agent still in the registry rejects new input while its owner is closing' } }) - - it('waits for admitted maintenance after a queue-clear failure before releasing its Session', async () => { const { ctx, agent, handle } = await harness() const release = Promise.withResolvers() diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index df3e29c8a9..6c21a72d7d 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -27,8 +27,8 @@ en: ff2b530bb20bcfe2 zh: 4f9024c4881a4ba5 /deepseek-ai-dsh-agent/use-this-package/scope-registrations-to-one-agent: - en: 2594a1e02adea801 - zh: cae8860369728efb + en: cec57f2cb7ab3a5c + zh: 2896b42eb5310441 /deepseek-ai-dsh-agent/use-this-package/intercept-or-observe-work-in-flight: en: 371511fb13093dd2 zh: 4be81d246cf299b2 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9772578fcc..024f69aeef 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -73,6 +73,8 @@ await handle.agent.whenIdle() `Agent.ctx` is the agent's scoped context: registrations made through it (tools, prompt sections, variables, event listeners, restrictions) apply to that agent alone and unwind on disposal. The same mechanism is what agent presets use to give one session a different capability set without affecting its neighbors. +Input and maintenance methods reject closed Agents with `AgentClosedError` (`AGENT_CLOSED`). See [Agent lifetime and admission](../../../docs/subsystems/core.md#the-agent-handle) for the lifecycle rules. + ### Intercept or observe work in flight `agent/request` is deprecated and retained for existing listeners. Do not add new listeners. Provider, model, and optional explicit reasoning effort are fixed at step preparation; listeners must preserve all three, including an absent effort. `AgentOptions.maxTokens` sets the output cap. Other per-request control overrides have no replacement API. See the [migration guide](../../../docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.md) for affected callers. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index b5b2af52d4..693ad615ce 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -73,6 +73,8 @@ await handle.agent.whenIdle() `Agent.ctx` 是该 agent 的作用域上下文:通过它进行的注册(工具、提示词段、变量、事件监听器、限制)只对该 agent 生效,并在 dispose(资源释放)时全部撤销。同一机制也是 agent preset 用来让一个会话获得不同能力集、同时不影响其邻居的方式。 +输入和维护方法会以 `AgentClosedError`(`AGENT_CLOSED`)拒绝已停止接纳新工作的 Agent。生命周期规则见 [Agent 句柄](../../../docs/subsystems/core.zh.md#the-agent-handle)。 + ### 拦截或观察进行中的工作 `agent/request` 已弃用,仅为现有监听器保留。不要添加新监听器。提供方、模型和可选的显式推理强度在步骤准备时固定;监听器必须保留三者,包括未指定强度的状态。`AgentOptions.maxTokens` 设置输出上限。其他逐请求控制参数覆盖没有替代 API。受影响的调用方参见[迁移指南](../../../docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.zh.md)。 diff --git a/packages/core/agent/src/closed-error.ts b/packages/core/agent/src/closed-error.ts index abcb335320..a4ca452887 100644 --- a/packages/core/agent/src/closed-error.ts +++ b/packages/core/agent/src/closed-error.ts @@ -1,15 +1,15 @@ /** Rejection of new work after an Agent starts closing. @module @deepseek-ai/dsh-agent/closed-error */ +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' -/** An Agent has stopped accepting new input and maintenance work. */ -export class AgentClosedError extends Error { +/** An Agent has stopped accepting new input and maintenance work (`AGENT_CLOSED`). */ +export class AgentClosedError extends HarnessError { /** * @param agentId - closing Agent identity. * @param options - original lifecycle cancellation, when available. */ constructor(readonly agentId: SessionId, options?: ErrorOptions) { - super(`agent "${agentId}" is closing`, options) - this.name = 'AgentClosedError' + super(`agent "${agentId}" is closing`, 'AGENT_CLOSED', options) } } diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index aa5efe6d0a..39b15064dc 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -226,7 +226,7 @@ declare module './types.ts' { * inbox until the task settles, while public status stays `idle`. * `whenIdle()` follows both the task and any waking work released behind it. * @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}. - * @throws AgentClosedError after disposal begins, or an Error when another activity owns the agent. + * @throws AgentClosedError once the Agent lifetime is aborted, or an Error when another activity owns the agent. * @returns the task promise. */ runMaintenance(task: (signal: AbortSignal) => Promise): Promise @@ -241,7 +241,7 @@ declare module './types.ts' { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ send(message: UserMessage, target: InboxTarget, wakeup: boolean): void @@ -249,7 +249,7 @@ declare module './types.ts' { * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ followup(message: UserMessage): void @@ -259,7 +259,7 @@ declare module './types.ts' { * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ steer(message: UserMessage): void @@ -270,7 +270,7 @@ declare module './types.ts' { * wakes them. It may miss a request whose pre-step already claimed its * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. - * @throws AgentClosedError after disposal begins; no input is inserted. + * @throws AgentClosedError once the Agent lifetime is aborted. The inbox is unchanged. */ inject(message: UserMessage): void } diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index bccfa6634e..46cda8d44b 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -263,9 +263,8 @@ export class HarnessSdkJsonRpcServer { if (!this.initialized) throw new Error('SDK server is not initialized') const rec = await this.getOrCreateSession(params.sessionId) this.lifetime.signal.throwIfAborted() - // An agent-loop-only reload disposes the loop's agents while this record - // survives; a retained agent accepts followup() silently, so validate the - // record against the live registry before delivery. + // An agent-loop reload can dispose the Agent while the SDK still retains + // this record. Reject stale records before admitting prompt attachments. this.assertLiveAgent(rec, params.sessionId) const content = await durablePromptContent(this.ctx, params.contentBlocks) this.lifetime.signal.throwIfAborted() diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index f4a2a3d652..1f9108edae 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -10,8 +10,7 @@ import { randomUUID } from 'node:crypto' import { isAbsolute, resolve } from 'node:path' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' -import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import { AgentClosedError } from '@deepseek-ai/dsh-agent' +import { AgentClosedError, type Agent, type AgentOptions, type CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index d9baf1d7f9..92611f23bc 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1787,7 +1787,6 @@ def smoke_sdk_snapshot_mode( {"insert": [{ "id": "serial-created-fixture", "name": str(Path(__file__).resolve().parents[1] / "packages/core/agent-loop/tests/fixtures/serial-created.mjs"), - "config": {"checkRootCancellation": False}, }]}, ]) with DeepSeekHarness( diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index c5568f5a18..8aaf44b0b6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -2941,7 +2941,7 @@ "content": [ { "type": "text", - "text": "Serial agent creation completed. Queue-clear failures still cancel active work." + "text": "Serial agent creation completed." } ], "source": { @@ -3127,7 +3127,7 @@ "content": [ { "type": "text", - "text": "Serial agent creation completed. Queue-clear failures still cancel active work." + "text": "Serial agent creation completed." } ], "source": { @@ -3967,7 +3967,7 @@ "content": [ { "type": "text", - "text": "Serial agent creation completed. Queue-clear failures still cancel active work." + "text": "Serial agent creation completed." } ], "source": { @@ -4149,7 +4149,7 @@ "content": [ { "type": "text", - "text": "Serial agent creation completed. Queue-clear failures still cancel active work." + "text": "Serial agent creation completed." } ], "source": { diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl index 1a64fcd142..ee9fbfd3cb 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.v4.jsonl @@ -10,7 +10,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Cancellation checkpoint probe."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} {"type":"turn/start","data":{"turn":2}} @@ -18,7 +18,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","snapshot_double","subagent","workflow","working_directory"]},"reason":"initial"}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl index 7f4bf22ad4..6fb5ebae1f 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.v4.jsonl @@ -10,7 +10,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Cancellation checkpoint probe."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} {"type":"turn/start","data":{"turn":2}} @@ -18,7 +18,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Queue-clear failures still cancel active work."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","snapshot_double","subagent","workflow","working_directory"]},"reason":"initial"}} From ad5fab1a1c57323920355b1280cfbe543517bb84 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Fri, 6 Nov 2026 15:10:01 +0800 Subject: [PATCH 019/149] fix(agent): contain input rejection during shutdown --- .../goal/goal-round-driver/README.i18n.yaml | 4 ++-- packages/goal/goal-round-driver/README.md | 2 +- packages/goal/goal-round-driver/README.zh.md | 2 +- packages/goal/goal-round-driver/src/index.ts | 3 ++- .../tests/goal-round-driver.spec.ts | 16 +++++++++++++++- .../session/working-directory/README.i18n.yaml | 4 ++-- packages/session/working-directory/README.md | 2 +- .../session/working-directory/README.zh.md | 2 +- .../session/working-directory/src/index.ts | 3 ++- .../tests/working-directory.spec.ts | 18 ++++++++++++++++++ 10 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/goal/goal-round-driver/README.i18n.yaml b/packages/goal/goal-round-driver/README.i18n.yaml index 19e3ebc68a..18af868300 100644 --- a/packages/goal/goal-round-driver/README.i18n.yaml +++ b/packages/goal/goal-round-driver/README.i18n.yaml @@ -24,8 +24,8 @@ en: a1312339977f1127 zh: 57450327ff7e7d83 /deepseek-ai-dsh-goal-round-driver/use-this-package/when-continuation-stops: - en: f3cb23a11176dc4f - zh: de7aa54d5c93445d + en: 7c4d150d79e55ba4 + zh: be24cd1956894787 /deepseek-ai-dsh-goal-round-driver/use-this-package/after-resume-fork-or-unload: en: 85d5470b1b330330 zh: e761f9046ba7af8a diff --git a/packages/goal/goal-round-driver/README.md b/packages/goal/goal-round-driver/README.md index 1302bc6fdc..2998c3f8fe 100644 --- a/packages/goal/goal-round-driver/README.md +++ b/packages/goal/goal-round-driver/README.md @@ -50,7 +50,7 @@ With an exact live agent idle, an active armed goal, and remaining capacity, the ### When continuation stops -A round starts only at whole-agent idle and, when the subagent service is loaded, only after the agent's progressing descendants settle; completion, pause, and blocking suppress continuation; a host-initiated pause also aborts the turn already running, while a model-initiated pause inside its own turn finishes normally. An edit only invalidates an in-flight round through the revision fence, and the driver continues the new revision. The driver also stops on its own when a turn ends on max tokens, a durability write fails, the agent is cancelled, the plugin unloads, or the round cap is exhausted — at the cap it records a blocker with the stable code `round-limit`. Cancellation never auto-restarts a round: a goal whose round was under way is paused at the next idle point, and a cancellation unrelated to a goal attempt only disarms continuation. Agent disposal, such as a graceful process shutdown or a reload of the plugin that owns the Agent, aborts the round with the `disposed` cause and only disarms continuation, so the goal stays active, as on plugin unload; user, parent, and hook cancellations still pause it. A round prompt still queued when the agent reaches idle is removed from the inbox, so later human input does not wait behind it; when the cancelled turn belonged to other work, continuation is disarmed rather than paused. +A round starts only at whole-agent idle and, when the subagent service is loaded, only after the agent's progressing descendants settle; completion, pause, and blocking suppress continuation; a host-initiated pause also aborts the turn already running, while a model-initiated pause inside its own turn finishes normally. An edit only invalidates an in-flight round through the revision fence, and the driver continues the new revision. The driver also stops on its own when a turn ends on max tokens, a durability write fails, the agent is cancelled, the plugin unloads, or the round cap is exhausted — at the cap it records a blocker with the stable code `round-limit`. Cancellation never auto-restarts a round: a goal whose round was under way is paused at the next idle point, and a cancellation unrelated to a goal attempt only disarms continuation. If Agent shutdown rejects a queued round, the goal stays active. Agent disposal, such as a graceful process shutdown or a reload of the plugin that owns the Agent, aborts the round with the `disposed` cause and only disarms continuation, so the goal stays active, as on plugin unload; user, parent, and hook cancellations still pause it. A round prompt still queued when the agent reaches idle is removed from the inbox, so later human input does not wait behind it; when the cancelled turn belonged to other work, continuation is disarmed rather than paused. ### After resume, fork, or unload diff --git a/packages/goal/goal-round-driver/README.zh.md b/packages/goal/goal-round-driver/README.zh.md index 05905c7043..65cc9c1b77 100644 --- a/packages/goal/goal-round-driver/README.zh.md +++ b/packages/goal/goal-round-driver/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 何时停止续行 -Round 只在整个 agent 进入 idle 时启动;加载了子 agent 服务时,还要等该 agent 仍在推进的后代全部结束后才启动;完成、暂停和阻塞会阻止续行;宿主发起的暂停还会中止正在运行的轮次,而模型在自己轮次内发起的暂停会正常结束。编辑只会通过修订栅栏使进行中的 Round 失效,驱动器会继续新修订。驱动器也会在以下情况自行停止:轮次因 max tokens 结束、持久性写入失败、agent 被取消、插件卸载,或 Round 上限耗尽——上限耗尽时它会以稳定代码 `round-limit` 记录一个 blocker。取消绝不会自动重启 Round:Round 已在进行的 goal 会在下一次 idle 时被暂停;与 goal 尝试无关的取消只会停用续行。Agent 被处置(例如进程正常关停,或重载拥有该 Agent 的插件)时,Round 以 `disposed` 原因中止,此时只停用续行,goal 保持 active,与插件卸载时一致;用户、父 Agent 与 hook 发起的取消仍会暂停它。agent 进入 idle 时仍在排队的 Round 提示会从收件箱移除,后续人类输入不会排在它后面等待;被取消的轮次属于其他工作时,续行会被停用而不是暂停。 +Round 只在整个 agent 进入 idle 时启动;加载了子 agent 服务时,还要等该 agent 仍在推进的后代全部结束后才启动;完成、暂停和阻塞会阻止续行;宿主发起的暂停还会中止正在运行的轮次,而模型在自己轮次内发起的暂停会正常结束。编辑只会通过修订栅栏使进行中的 Round 失效,驱动器会继续新修订。驱动器也会在以下情况自行停止:轮次因 max tokens 结束、持久性写入失败、agent 被取消、插件卸载,或 Round 上限耗尽——上限耗尽时它会以稳定代码 `round-limit` 记录一个 blocker。取消绝不会自动重启 Round:Round 已在进行的 goal 会在下一次 idle 时被暂停;与 goal 尝试无关的取消只会停用续行。Agent 关闭期间拒绝轮次入队时,goal 保持 active。Agent 被处置(例如进程正常关停,或重载拥有该 Agent 的插件)时,Round 以 `disposed` 原因中止,此时只停用续行,goal 保持 active,与插件卸载时一致;用户、父 Agent 与 hook 发起的取消仍会暂停它。agent 进入 idle 时仍在排队的 Round 提示会从收件箱移除,后续人类输入不会排在它后面等待;被取消的轮次属于其他工作时,续行会被停用而不是暂停。 ### resume、fork 或卸载之后 diff --git a/packages/goal/goal-round-driver/src/index.ts b/packages/goal/goal-round-driver/src/index.ts index d3ddfda4cc..461a874f08 100644 --- a/packages/goal/goal-round-driver/src/index.ts +++ b/packages/goal/goal-round-driver/src/index.ts @@ -6,7 +6,7 @@ import { isDeepStrictEqual } from 'node:util' import { FiberState } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import { AgentClosedError, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' @@ -208,6 +208,7 @@ export function apply(ctx: Context): void { agent.followup(message) } catch (error: unknown) { state.attempt = undefined + if (error instanceof AgentClosedError) return ctx.logger.warn(`goal-round-driver: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`) const latest = currentGoal(state) if (latest !== undefined && latest.id === goal.id && latest.revision === goal.revision diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index d9f97df07d..c9be135ea1 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { AgentClosedError, agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' @@ -727,6 +727,20 @@ describe('same-session goal driving', () => { expect(goal?.roundsStarted).toBe(1) }) + it('keeps the goal active when the Agent closes before queue admission', async () => { + const test = await harness([]) + const admission = Promise.withResolvers() + vi.spyOn(test.agent, 'followup').mockImplementation(() => { + admission.resolve(undefined) + throw new AgentClosedError(test.agent.id) + }) + test.ctx.goals.create(test.agent, { objective: 'resume after shutdown' }) + await admission.promise + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'goal/blocked')).toBe(false) + }) + it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => { const test = await harness([]) // Reject only the goal-sourced round follow-up, not the state-change injection diff --git a/packages/session/working-directory/README.i18n.yaml b/packages/session/working-directory/README.i18n.yaml index fd50066fe2..4ed48e75cc 100644 --- a/packages/session/working-directory/README.i18n.yaml +++ b/packages/session/working-directory/README.i18n.yaml @@ -18,8 +18,8 @@ en: e65405026debde7c zh: e12a648eb10ed16b /deepseek-ai-dsh-working-directory/understand-the-implementation: - en: f0458147d29631b1 - zh: 6a288670dd084ffb + en: c0681d6879ece29d + zh: 42bf039fc1bbdaf5 /deepseek-ai-dsh-working-directory/further-exploration: en: eabff683146786c8 zh: 7997dec17802ef64 diff --git a/packages/session/working-directory/README.md b/packages/session/working-directory/README.md index 44ed9ba9dc..9506cfa788 100644 --- a/packages/session/working-directory/README.md +++ b/packages/session/working-directory/README.md @@ -41,7 +41,7 @@ Directory validation and publication delegate through `working-directory/access`
Implementation internals — click to expand -The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. +The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory. It warns unless the Agent is closing. The next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. No runtime invariant companion is published because the service reads its only directory state from the Session projection and checks filesystem validity at use. diff --git a/packages/session/working-directory/README.zh.md b/packages/session/working-directory/README.zh.md index 9561d73972..1df1f5673b 100644 --- a/packages/session/working-directory/README.zh.md +++ b/packages/session/working-directory/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference"
实现细节 — 点击展开 -Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。 +Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录。Agent 正在关闭时无需告警。下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。 不发布 `./invariant` 伴生模块,因为服务从 Session 投影读取唯一的目录状态,并在使用时检查文件系统有效性。 diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 62c3750c2c..be0c9e5187 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -7,7 +7,7 @@ import { isAbsolute } from 'node:path' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentClosedError, type Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' @@ -217,6 +217,7 @@ export class WorkingDirectoryService extends Service { try { agent.inject(notice) } catch (error) { + if (error instanceof AgentClosedError) return this.ctx.logger.warn(`working-directory: committed ${JSON.stringify(directory)}, but could not queue its notice: ${String(error)}`) } } diff --git a/packages/session/working-directory/tests/working-directory.spec.ts b/packages/session/working-directory/tests/working-directory.spec.ts index 22fb99af47..d05bf06f10 100644 --- a/packages/session/working-directory/tests/working-directory.spec.ts +++ b/packages/session/working-directory/tests/working-directory.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { setImmediate } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' +import { AgentClosedError } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import WorkingDirectory, { type Config as WorkingDirectoryConfig } from '@deepseek-ai/dsh-working-directory' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -314,6 +315,23 @@ describe('Session working directories', () => { } }) + it('keeps the committed directory when the Agent closes before accepting its notice', async () => { + await using f = await fixture() + const child = join(f.root, 'child') + await mkdir(child) + const inject = vi.spyOn(f.agent, 'inject').mockImplementation(() => { throw new AgentClosedError(f.agent.id) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + try { + await expect(f.ctx.workingDirectory.set(f.agent, child)).resolves.toBe(child) + expect(f.ctx.workingDirectory.get(f.agent.session)).toBe(child) + expect(inject).toHaveBeenCalledOnce() + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('could not queue its notice')) + } finally { + inject.mockRestore() + warn.mockRestore() + } + }) + it('returns committed state and warns when the notice cannot be queued', async () => { await using f = await fixture() const child = join(f.root, 'child') From 360216b089c5ca29f1bb25cadb8a64bdb02c8974 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:36:18 +0800 Subject: [PATCH 020/149] feat(lsp): give lsp tool failures structured error codes tool-lsp argument checks throw ToolArgsError. lsp-stdio keeps FsError codes from resolution and reads (adding the LSP operand to the message), reports a missing or non-directory workspace and an oversized source as FsError, and adds LspError codes for the stdio provider: LSP_FILE_OUTSIDE_WORKSPACE, LSP_UNSUPPORTED_ENCODING, LSP_SPAWN_FAILED, LSP_SERVER_EXITED, LSP_PROTOCOL_ERROR, LSP_MESSAGE_TOO_LARGE, and LSP_SERVER_ERROR (JSON-RPC code and text in the message), and LSP_NOT_INITIALIZED for a query that finds no negotiated capabilities after a successful handshake. A query failure whose teardown also fails keeps the query's name and code. LspError assigns its literal name; the LSP subsystem page documents every code. --- docs/subsystems/lsp.i18n.yaml | 7 +- docs/subsystems/lsp.md | 27 ++++- docs/subsystems/lsp.zh.md | 27 ++++- packages/lsp/lsp-stdio/README.i18n.yaml | 4 +- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/src/abort.ts | 7 +- packages/lsp/lsp-stdio/src/connection.ts | 74 +++++++++++--- packages/lsp/lsp-stdio/src/framing.ts | 28 ++++-- packages/lsp/lsp-stdio/src/host.ts | 34 +++++-- packages/lsp/lsp-stdio/src/index.ts | 32 +++++- packages/lsp/lsp-stdio/src/instance.ts | 16 +-- packages/lsp/lsp-stdio/src/protocol.ts | 5 - packages/lsp/lsp-stdio/src/translate.ts | 30 +++++- .../lsp/lsp-stdio/tests/connection.spec.ts | 93 +++++++++++++++-- packages/lsp/lsp-stdio/tests/framing.spec.ts | 12 +-- packages/lsp/lsp-stdio/tests/host.spec.ts | 79 ++++++++++++--- packages/lsp/lsp-stdio/tests/instance.spec.ts | 57 ++++++++++- .../lsp/lsp-stdio/tests/lifecycle.spec.ts | 99 ++++++++++++++++--- .../lsp/lsp-stdio/tests/translate.spec.ts | 6 +- packages/lsp/lsp/src/index.ts | 21 +++- packages/lsp/tool-lsp/README.i18n.yaml | 4 +- packages/lsp/tool-lsp/README.md | 2 +- packages/lsp/tool-lsp/README.zh.md | 2 +- packages/lsp/tool-lsp/src/render.ts | 10 +- .../lsp/tool-lsp/tests/integration.spec.ts | 13 +++ packages/lsp/tool-lsp/tests/render.spec.ts | 10 +- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 31 +++++- 28 files changed, 608 insertions(+), 126 deletions(-) diff --git a/docs/subsystems/lsp.i18n.yaml b/docs/subsystems/lsp.i18n.yaml index cae4151b07..6d21e0fc70 100644 --- a/docs/subsystems/lsp.i18n.yaml +++ b/docs/subsystems/lsp.i18n.yaml @@ -15,5 +15,8 @@ en: d260facb7ad22440 zh: f4868aae5be01eb6 /lsp-navigation/provider-and-service: - en: 754b4f1a0c80e332 - zh: aaacbc912dd6337b + en: 9977196522f2d7e5 + zh: f971ba2c60beda68 +/lsp-navigation/failures: + en: 192bd5699c1a23af + zh: f221bff65286231a diff --git a/docs/subsystems/lsp.md b/docs/subsystems/lsp.md index 5a8adb24c1..275ba3b236 100644 --- a/docs/subsystems/lsp.md +++ b/docs/subsystems/lsp.md @@ -162,7 +162,32 @@ interface LspService { } ``` -`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with the stable codes listed under [Failures](#failures), which callers route on instead of parsing `message`. + + +## Failures + +Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message. + +| Code | Raised by | Meaning | Consumer action | +|---|---|---|---| +| `LSP_INVALID_PROVIDER` | Seam | A registration has an empty id or language id, no extensions, or an invalid extension | Fix the registration | +| `LSP_CONFLICT` | Seam | The provider id or an extension is already registered | Remove the duplicate registration | +| `LSP_UNAVAILABLE` | Seam | No provider handles the file's extension | Query a supported file or add a provider | +| `LSP_WORKSPACE_REQUIRED` | `lsp` tool | The call has no Agent Session workspace | Call the tool from an Agent Session | +| `LSP_DISPOSED` | Provider | The provider or its server instance was disposed | Retry after the provider is mounted again | +| `LSP_UNSUPPORTED_OPERATION` | Provider | The server lacks the operation or transient open/close synchronization | Choose another operation or server | +| `LSP_MALFORMED_RESPONSE` | Provider | The server's `initialize` or query result violates the protocol | Report the server defect | +| `LSP_FILE_OUTSIDE_WORKSPACE` | Stdio provider | The source resolves outside the canonical workspace | Query a file inside the workspace | +| `LSP_UNSUPPORTED_ENCODING` | Stdio provider | The server negotiated a position encoding other than UTF-16 | Use a server that supports UTF-16 | +| `LSP_SPAWN_FAILED` | Stdio provider | The subprocess provider failed to launch or run the server; a provider `HarnessError` keeps its own `name` and `code` instead | Fix the server command or installation | +| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | +| `LSP_PROTOCOL_ERROR` | Stdio provider | The server's output broke base-protocol framing or JSON | Report the server defect | +| `LSP_MESSAGE_TOO_LARGE` | Stdio provider | A server message exceeded `maxMessageBytes` | Narrow the query or raise `maxMessageBytes` | +| `LSP_SERVER_ERROR` | Stdio provider | The server answered with a JSON-RPC error; the message carries its numeric code and text | Act on the server's message | +| `LSP_NOT_INITIALIZED` | Stdio provider | A query found the server instance without negotiated capabilities after its handshake succeeded; this is inconsistent internal state, not a server condition | Report the defect | + +After a transport failure (`LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`), the stdio provider retries the read-only query once on a fresh process when the failed process tears down cleanly. diff --git a/docs/subsystems/lsp.zh.md b/docs/subsystems/lsp.zh.md index e041217866..65612a8eac 100644 --- a/docs/subsystems/lsp.zh.md +++ b/docs/subsystems/lsp.zh.md @@ -162,7 +162,32 @@ interface LspService { } ``` -`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供 `LSP_INVALID_PROVIDER`、`LSP_CONFLICT`、`LSP_UNAVAILABLE`、`LSP_DISPOSED`、`LSP_UNSUPPORTED_OPERATION` 和 `LSP_MALFORMED_RESPONSE` 等稳定错误码,调用方应按错误码路由,而不是解析 `message`。 +`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供[失败](#failures)一节列出的稳定错误码,调用方应按错误码路由,而不是解析 `message`。 + + +## 失败 + +`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中。 + +| 错误码 | 抛出方 | 含义 | 消费方操作 | +|---|---|---|---| +| `LSP_INVALID_PROVIDER` | Seam | 注册的 id 或语言 id 为空、没有扩展名,或扩展名无效 | 修正注册 | +| `LSP_CONFLICT` | Seam | 提供方 id 或某个扩展名已被注册 | 移除重复注册 | +| `LSP_UNAVAILABLE` | Seam | 没有提供方处理该文件扩展名 | 查询受支持的文件,或添加提供方 | +| `LSP_WORKSPACE_REQUIRED` | `lsp` 工具 | 调用没有 Agent Session 工作区 | 从 Agent Session 调用该工具 | +| `LSP_DISPOSED` | 提供方 | 提供方或其服务器实例已被释放 | 在提供方重新挂载后重试 | +| `LSP_UNSUPPORTED_OPERATION` | 提供方 | 服务器不支持该操作或临时打开/关闭同步 | 选择其他操作或服务器 | +| `LSP_MALFORMED_RESPONSE` | 提供方 | 服务器的 `initialize` 或查询结果违反协议 | 报告服务器缺陷 | +| `LSP_FILE_OUTSIDE_WORKSPACE` | stdio 提供方 | 源文件解析到规范工作区之外 | 查询工作区内的文件 | +| `LSP_UNSUPPORTED_ENCODING` | stdio 提供方 | 服务器协商了 UTF-16 以外的位置编码 | 使用支持 UTF-16 的服务器 | +| `LSP_SPAWN_FAILED` | stdio 提供方 | 子进程提供方未能启动或运行服务器;提供方的 `HarnessError` 则保留其自身的 `name` 和 `code` | 修正服务器命令或安装 | +| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | +| `LSP_PROTOCOL_ERROR` | stdio 提供方 | 服务器输出破坏了基础协议分帧或 JSON | 报告服务器缺陷 | +| `LSP_MESSAGE_TOO_LARGE` | stdio 提供方 | 某条服务器消息超过 `maxMessageBytes` | 缩小查询范围或调大 `maxMessageBytes` | +| `LSP_SERVER_ERROR` | stdio 提供方 | 服务器以 JSON-RPC 错误应答;消息携带其数字错误码与文本 | 按服务器消息处理 | +| `LSP_NOT_INITIALIZED` | stdio 提供方 | 握手已成功,但查询时服务器实例没有已协商的能力;这是内部状态不一致,不是服务器的情况 | 报告该缺陷 | + +传输失败(`LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`)后,如果失败的进程干净地完成拆除,stdio 提供方会在新进程上把只读查询重试一次。 diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 1d00d484cc..369a475ea4 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41363baf8fdba020 zh: d1a6193b1aa813ba /deepseek-ai-dsh-lsp-stdio/use-this-package/observable-success-and-failures: - en: 57f3a82c014af107 - zh: 770fc617c1ab0eab + en: 27766a0ecd0f70fa + zh: 532bc93a8ce5e701 /deepseek-ai-dsh-lsp-stdio/use-this-package/security-boundary: en: f1f7fef771bcaa6d zh: f11dcf771a6fecfc diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index ddcfb78292..8f30301f48 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -68,7 +68,7 @@ On the first query for a workspace, the provider launches one server process for ### Observable success and failures -A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. The query fails when the server does not support the operation or the transient open/close synchronization (`LSP_UNSUPPORTED_OPERATION`), when the source is missing, non-regular, non-UTF-8, oversized, or outside the canonical workspace (rejected before the server starts), or when the server returns a malformed payload (`LSP_MALFORMED_RESPONSE`). A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. +A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`; a query that finds the instance without negotiated capabilities after a successful handshake reports `LSP_NOT_INITIALIZED`, which marks a provider defect. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. ### Security boundary diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index aef549f4bf..f54eda31c1 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 可观察的成功与失败 -成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。当服务器不支持该操作或临时打开/关闭同步(`LSP_UNSUPPORTED_OPERATION`)、源文件缺失、非普通文件、非 UTF-8、过大或位于规范工作区之外(在服务器启动前被拒绝),或服务器返回格式错误的载荷(`LSP_MALFORMED_RESPONSE`)时,查询会失败。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 +成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`;握手成功后查询却发现实例没有已协商的能力时报告 `LSP_NOT_INITIALIZED`,表示提供方存在缺陷。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 ### 安全边界 diff --git a/packages/lsp/lsp-stdio/src/abort.ts b/packages/lsp/lsp-stdio/src/abort.ts index c6706fc8b8..be86684d5d 100644 --- a/packages/lsp/lsp-stdio/src/abort.ts +++ b/packages/lsp/lsp-stdio/src/abort.ts @@ -8,14 +8,17 @@ import { timeoutOf } from '@deepseek-ai/dsh-timeout' /** * Build an abort Error carrying the signal's reason and preserving timeout classification. * @param signal - the aborted signal whose reason to surface. - * @returns the timeout reason if present, else the Error reason, else a generic aborted Error. + * @returns the timeout reason if present, else the Error reason, else an `AbortError`-named Error + * whose `cause` is the non-Error reason, so tool cancellation classification still applies. */ export function abortError(signal: AbortSignal): Error { const timeout = timeoutOf(signal) if (timeout !== undefined) return timeout const reason: unknown = signal.reason if (reason instanceof Error) return reason - return new Error('LSP query aborted') + const aborted = new Error('LSP query aborted', { cause: reason }) + aborted.name = 'AbortError' + return aborted } /** diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index bdc4b3fb11..e910ac0a52 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -11,6 +11,8 @@ */ import type { Writable } from 'node:stream' +import { LspError } from '@deepseek-ai/dsh-lsp' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -39,6 +41,7 @@ export interface ConnectionSpec { } interface Pending { + method: string resolve: (value: unknown) => void reject: (error: Error) => void } @@ -62,7 +65,13 @@ const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } -/** A live JSON-RPC endpoint bound to one child process. */ +/** + * A live JSON-RPC endpoint bound to one child process. Request and write failures are `LspError`s: + * `LSP_SERVER_EXITED` when the process exits or its stdin fails, `LSP_SPAWN_FAILED` when the + * subprocess provider fails to start or run it (a provider `HarnessError` keeps its own identity), + * `LSP_PROTOCOL_ERROR` or `LSP_MESSAGE_TOO_LARGE` when its stdout breaks framing, and + * `LSP_SERVER_ERROR` when it answers a request with a JSON-RPC error. + */ export class LspConnection { private readonly handle: SubprocessHandle private readonly stdin: Writable @@ -70,6 +79,8 @@ export class LspConnection { private readonly pending = new Map() private nextId = 1 private closeReason: Error | undefined + /** The subprocess provider's launch or run failure; it supersedes the stdin failure it causes. */ + private launchFailure: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise @@ -110,7 +121,7 @@ export class LspConnection { this.stdin = this.handle.stdin this.closed = new Promise((resolve) => { const close = (): void => { - const reason = this.closeReason ?? new Error(this.exitMessage()) + const reason = this.closeReason ?? new LspError(this.exitMessage(), 'LSP_SERVER_EXITED') // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason @@ -120,14 +131,16 @@ export class LspConnection { this.handle.done.then(close, (error: unknown) => { // A spawn-level failure never produces a close event; the rejection is // the fatal cause and the close boundary at once. - this.fail(asError(error)) + const failure = spawnFailure(error) + this.launchFailure = failure + this.fail(failure) close() }) }) // Child stdin can fail while the process itself remains alive (for example, a server closes fd // 0). Treat that as a fatal connection error so pending requests reject immediately instead of // waiting for a process-close event that may never arrive. - this.stdin.on('error', (error) => { this.fail(error) }) + this.stdin.on('error', (error) => { this.fail(this.stdinFailure(error)) }) this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } @@ -148,7 +161,18 @@ export class LspConnection { * @returns `true` only when this connection produced that exact failure. */ failedWith(error: unknown): boolean { - return this.closeReason === error + return this.closeReason === error || (this.launchFailure !== undefined && this.launchFailure === error) + } + + /** + * Report a transport failure by its authoritative cause once the process has settled. A failed + * launch usually surfaces first as a stdin write failure; after `done` rejects, that failure + * reports the launch failure instead. + * @param error - error caught by the instance after teardown. + * @returns the launch failure for this connection's transport failure, otherwise `error`. + */ + settledFailure(error: unknown): unknown { + return this.launchFailure !== undefined && this.failedWith(error) ? this.launchFailure : error } /** @@ -164,7 +188,7 @@ export class LspConnection { reject(this.closeReason) return } - this.pending.set(id, { resolve, reject }) + this.pending.set(id, { method, resolve, reject }) // `write()` records either synchronous or callback-delivered failures on the connection and // rejects every pending request. This handler only consumes the write promise itself. void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {}) @@ -266,8 +290,7 @@ export class LspConnection { this.pending.delete(id) const error = frame.error if (error !== null && typeof error === 'object') { - const record = error as Record - pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response')) + pending.reject(responseError(pending.method, error as Record)) return } pending.resolve(frame.result) @@ -281,8 +304,9 @@ export class LspConnection { resolve() return } - this.fail(error) - reject(error) + const failure = this.stdinFailure(error) + this.fail(failure) + reject(failure) } try { this.writer(this.stdin, message, done) @@ -303,8 +327,20 @@ export class LspConnection { return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` } + /** + * Convert a stdin failure to `LSP_SERVER_EXITED`. The stream reports one failure through both its + * `error` event and the write callback, so the second report reuses the first conversion and + * {@link failedWith} still recognizes the rejection. + */ + private stdinFailure(error: Error): Error { + const current = this.closeReason + if (current instanceof LspError && current.cause === error) return current + return new LspError(`language server stopped accepting input: ${error.message}`, 'LSP_SERVER_EXITED', { cause: error }) + } + private fail(error: Error): void { - /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ + /* v8 ignore next -- the second arm needs a second failure before close, such as a launch failure after a stdin + failure; the subprocess provider does not fix that order, so coverage cannot rely on it. */ if (this.closeReason === undefined) this.closeReason = error this.failAll(error) } @@ -316,6 +352,22 @@ export class LspConnection { } } +/** + * Convert a subprocess `done` rejection to `LSP_SPAWN_FAILED`; a provider `HarnessError` already + * carries the classification and propagates unchanged. + */ +function spawnFailure(error: unknown): Error { + if (error instanceof HarnessError) return error + return new LspError(`language server process failed: ${asError(error).message}`, 'LSP_SPAWN_FAILED', { cause: error }) +} + +/** Convert a JSON-RPC error response, keeping its numeric code and message in `message`. */ +function responseError(method: string, error: Record): LspError { + const code = typeof error.code === 'number' ? ` ${error.code}` : '' + const detail = typeof error.message === 'string' ? error.message : 'no message' + return new LspError(`language server rejected ${method} with JSON-RPC error${code}: ${detail}`, 'LSP_SERVER_ERROR') +} + /** Coerce an unknown thrown value to an `Error`. */ function asError(value: unknown): Error { /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */ diff --git a/packages/lsp/lsp-stdio/src/framing.ts b/packages/lsp/lsp-stdio/src/framing.ts index 7d7a44dad8..536d354875 100644 --- a/packages/lsp/lsp-stdio/src/framing.ts +++ b/packages/lsp/lsp-stdio/src/framing.ts @@ -5,6 +5,8 @@ * @module @deepseek-ai/dsh-lsp-stdio/framing */ +import { LspError } from '@deepseek-ai/dsh-lsp' + /** The header/body separator in the LSP base protocol. */ const HEADER_SEPARATOR = '\r\n\r\n' @@ -42,7 +44,8 @@ export class MessageDecoder { * Append a chunk and return every message body that is now complete. * @param chunk - raw bytes from the server's stdout. * @returns the parsed JSON bodies, in arrival order (possibly empty). - * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`. + * @throws LspError `LSP_PROTOCOL_ERROR` when a header or body is malformed, or + * `LSP_MESSAGE_TOO_LARGE` when a body exceeds `maxMessageBytes`. */ push(chunk: Buffer): unknown[] { this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]) @@ -60,17 +63,20 @@ export class MessageDecoder { const separator = this.buffer.indexOf(HEADER_SEPARATOR) if (separator < 0) { if (this.buffer.length > MAX_HEADER_BYTES) { - throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) + throw protocolError(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) } return { ready: false } } if (separator > MAX_HEADER_BYTES) { - throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) + throw protocolError(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) } const headerText = this.buffer.toString('ascii', 0, separator) const contentLength = parseContentLength(headerText) if (contentLength > this.maxMessageBytes) { - throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`) + throw new LspError( + `LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`, + 'LSP_MESSAGE_TOO_LARGE', + ) } const bodyStart = separator + HEADER_SEPARATOR.length const bodyEnd = bodyStart + contentLength @@ -81,7 +87,10 @@ export class MessageDecoder { return { ready: true, message: JSON.parse(body) } } catch (error) { /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */ - throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + throw protocolError( + `LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) } } } @@ -94,9 +103,14 @@ function parseContentLength(headerText: string): number { if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue const value = Number(line.slice(colon + 1).trim()) if (!Number.isInteger(value) || value < 0) { - throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`) + throw protocolError(`invalid Content-Length header: ${JSON.stringify(line)}`) } return value } - throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) + throw protocolError(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) +} + +/** A framing failure: the server's byte stream no longer follows the LSP base protocol. */ +function protocolError(message: string, options?: ErrorOptions): LspError { + return new LspError(message, 'LSP_PROTOCOL_ERROR', options) } diff --git a/packages/lsp/lsp-stdio/src/host.ts b/packages/lsp/lsp-stdio/src/host.ts index ef4b56809d..d28561ea90 100644 --- a/packages/lsp/lsp-stdio/src/host.ts +++ b/packages/lsp/lsp-stdio/src/host.ts @@ -1,7 +1,9 @@ /** Filesystem-seam source access for the generic stdio LSP provider. */ import { Buffer } from 'node:buffer' +import { FsError } from '@deepseek-ai/dsh-fs' import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs' +import { LspError } from '@deepseek-ai/dsh-lsp' import { throwIfAborted } from './abort.ts' /** A canonical workspace in the filesystem/subprocess execution world. */ @@ -28,6 +30,8 @@ export interface HostSource { * @param workspaceRoot - caller-supplied workspace path. * @param signal - optional cancellation around provider operations. * @returns stable identity plus process path and file URI. + * @throws FsError with the provider's code when resolution or `stat` fails, `FS_NOT_FOUND` when the + * workspace is missing, or `FS_NOT_DIRECTORY` when it is not a directory. */ export async function canonicalizeWorkspace( fs: FileSystem, @@ -40,7 +44,7 @@ export async function canonicalizeWorkspace( target = await fs.resolve(workspaceRoot, signal === undefined ? {} : { signal }) } catch (error: unknown) { throwIfAborted(signal) - throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`, { cause: error }) + rethrowWithFsContext(error, `workspace root "${workspaceRoot}" cannot be resolved`) } throwIfAborted(signal) const info = await fs.stat(target, signal).catch((error: unknown) => { @@ -48,8 +52,11 @@ export async function canonicalizeWorkspace( throw error }) throwIfAborted(signal) - if (info?.type !== 'directory') { - throw new Error(`workspace root "${workspaceRoot}" is not a directory`) + if (info === undefined) { + throw new FsError(`workspace root "${workspaceRoot}" does not exist`, 'FS_NOT_FOUND') + } + if (info.type !== 'directory') { + throw new FsError(`workspace root "${workspaceRoot}" is not a directory`, 'FS_NOT_DIRECTORY') } return { target, @@ -68,6 +75,9 @@ export async function canonicalizeWorkspace( * @param maxDocumentBytes - largest complete source accepted by this host. * @param signal - optional cancellation. * @returns canonical file URI and current text. + * @throws FsError with the provider's code when resolution or reading fails, or `FS_TOO_LARGE` when + * the source exceeds `maxDocumentBytes`; LspError `LSP_FILE_OUTSIDE_WORKSPACE` when the source + * resolves outside `workspace`. */ export async function readHostSource( fs: FileSystem, @@ -85,11 +95,11 @@ export async function readHostSource( }) } catch (error: unknown) { throwIfAborted(signal) - throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`, { cause: error }) + rethrowWithFsContext(error, `source "${filePath}" cannot be resolved`) } throwIfAborted(signal) if (!fs.contains(workspace.target, target)) { - throw new Error(`source "${filePath}" resolves outside the workspace`) + throw new LspError(`source "${filePath}" resolves outside the workspace`, 'LSP_FILE_OUTSIDE_WORKSPACE') } const chunks: string[] = [] let bytes = 0 @@ -105,11 +115,12 @@ export async function readHostSource( } } catch (error: unknown) { throwIfAborted(signal) - throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error }) + rethrowWithFsContext(error, `source "${filePath}" could not be read`) } if (bytes > maxDocumentBytes) { - throw new Error( + throw new FsError( `source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit; reading stopped after ${bytes} bytes`, + 'FS_TOO_LARGE', ) } throwIfAborted(signal) @@ -119,6 +130,11 @@ export async function readHostSource( } } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) +/** + * Prefix a filesystem provider failure with the LSP operand while keeping its `FsError` code; any + * other thrown value is a provider defect and propagates unchanged. + */ +function rethrowWithFsContext(error: unknown, context: string): never { + if (!(error instanceof FsError)) throw error + throw new FsError(`${context}: ${error.message}`, error.code, { cause: error }) } diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index bfe1974e38..1e7fe51591 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -14,6 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { LspProvider, LspProviderQuery, @@ -116,6 +117,32 @@ function throwTeardownFailures(results: readonly PromiseSettledResult[], m if (failures.length > 1) throw new AggregateError(failures, message) } +/** + * A coded query failure whose instance teardown also failed. It keeps the query failure's `name` + * and `code` so tool results route on the query outcome; `cause` aggregates both failures. + */ +class LspQueryTeardownError extends HarnessError { + constructor(query: HarnessError, teardown: unknown, cause: AggregateError) { + super(`${query.message}; language server teardown also failed: ${messageOf(teardown)}`, query.code, { cause }) + this.name = query.name + } +} + +/** + * Combine a query failure with the teardown failure of its instance. A `HarnessError` query + * failure keeps its identity on the combined error; any other query failure has no identity to keep + * and both surface as an `AggregateError`. + */ +function queryAndTeardownFailure(query: unknown, teardown: unknown): Error { + const both = new AggregateError([query, teardown], 'LSP operation and teardown failed') + return query instanceof HarnessError ? new LspQueryTeardownError(query, teardown, both) : both +} + +/** Read a thrown value's message for a combined failure report. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + /** * Register the configured stdio LSP providers. Resolves every executable at load (after credential * scrubbing) before publishing any provider; each process launches lazily on its first matching @@ -294,10 +321,7 @@ class LocalLspProvider implements LspProvider { } if (teardownOutcome?.status === 'rejected') { if (queryOutcome.status === 'rejected') { - throw new AggregateError( - [queryOutcome.reason, teardownOutcome.reason], - 'LSP operation and teardown failed', - ) + throw queryAndTeardownFailure(queryOutcome.reason, teardownOutcome.reason) } throw teardownOutcome.reason } diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index d86d484571..f72a892fd5 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -18,8 +18,9 @@ import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' -import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' +import type { WireServerCapabilities } from './protocol.ts' import { + initializeCapabilities, negotiatePositionEncoding, normalizeHover, normalizeLocations, @@ -97,8 +98,9 @@ export class LspInstance { const run = abortable(this.queue, signal) .then(() => this.runQuery(request, source, signal)) .catch(async (error: unknown) => { - if (this.isTransportFailure(error)) await this.awaitTeardownAttempt() - throw error + if (!this.isTransportFailure(error)) throw error + await this.awaitTeardownAttempt() + throw this.connection.settledFailure(error) }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up @@ -116,8 +118,8 @@ export class LspInstance { workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }], capabilities: CLIENT_CAPABILITIES, initializationOptions: this.spec.initializationOptions, - }) as WireInitializeResult - const capabilities = initializeResult.capabilities + }) + const capabilities = initializeCapabilities(initializeResult) // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) this.capabilities = capabilities @@ -141,8 +143,8 @@ export class LspInstance { throw error } const capabilities = this.capabilities - /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ - if (capabilities === undefined) throw new Error('LSP instance is not initialized') + // `ready` resolves only after capabilities are set; reaching this means instance state is inconsistent. + if (capabilities === undefined) throw new LspError('LSP instance is not initialized', 'LSP_NOT_INITIALIZED') if (!supportsOperation(capabilities, request.operation)) { throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } diff --git a/packages/lsp/lsp-stdio/src/protocol.ts b/packages/lsp/lsp-stdio/src/protocol.ts index 96257678ed..e91e45ed5c 100644 --- a/packages/lsp/lsp-stdio/src/protocol.ts +++ b/packages/lsp/lsp-stdio/src/protocol.ts @@ -73,8 +73,3 @@ export interface WireServerCapabilities { readonly implementationProvider?: WireProviderCapability readonly hoverProvider?: WireProviderCapability } - -/** The `initialize` result envelope. */ -export interface WireInitializeResult { - readonly capabilities: WireServerCapabilities -} diff --git a/packages/lsp/lsp-stdio/src/translate.ts b/packages/lsp/lsp-stdio/src/translate.ts index a2de8ed419..8b26fc5d07 100644 --- a/packages/lsp/lsp-stdio/src/translate.ts +++ b/packages/lsp/lsp-stdio/src/translate.ts @@ -92,11 +92,31 @@ function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { * other than `utf-16` is a protocol error this host does not support. * @param encoding - the server's advertised `positionEncoding`, if any. * @returns the string `'utf-16'`. - * @throws Error for any non-`utf-16` encoding. + * @throws LspError `LSP_UNSUPPORTED_ENCODING` for any non-`utf-16` encoding. */ export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' { if (encoding === undefined || encoding === 'utf-16') return 'utf-16' - throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`) + throw new LspError( + `server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`, + 'LSP_UNSUPPORTED_ENCODING', + ) +} + +/** + * Read the server capabilities from an untrusted `initialize` result. + * @param result - the raw `initialize` response result. + * @returns the advertised capabilities object. + * @throws LspError `LSP_MALFORMED_RESPONSE` when the result or its `capabilities` is not an object. + */ +export function initializeCapabilities(result: unknown): WireServerCapabilities { + if (result === null || typeof result !== 'object') { + throw malformedResponse('LSP initialize result was not an object') + } + const capabilities = (result as Record).capabilities + if (capabilities === null || typeof capabilities !== 'object') { + throw malformedResponse('LSP initialize result had no capabilities object') + } + return capabilities } /** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */ @@ -141,7 +161,8 @@ function isProtocolCoordinate(value: unknown): value is number { * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`. * @param payload - the raw `textDocument/definition|references|implementation` result. * @returns the normalized locations (empty for `null`/`[]`). - * @throws Error when an element is neither a `Location` nor a `LocationLink`. + * @throws LspError `LSP_MALFORMED_RESPONSE` when the result is missing or an element is neither a + * `Location` nor a `LocationLink`. */ export function normalizeLocations(payload: unknown): LspLocation[] { if (payload === null) return [] @@ -178,7 +199,8 @@ function renderMarkedString(value: WireMarkedString): string { * joins its rendered parts with one blank line. The model-facing tool owns the complete result cap. * @param payload - the raw `textDocument/hover` result. * @returns the normalized hover, or `null` when there is no content. - * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. + * @throws LspError `LSP_MALFORMED_RESPONSE` when the payload is a non-null, non-object, or + * structurally invalid hover. */ export function normalizeHover(payload: unknown): LspHover | null { if (payload === null) return null diff --git a/packages/lsp/lsp-stdio/tests/connection.spec.ts b/packages/lsp/lsp-stdio/tests/connection.spec.ts index 37a9ef84e6..9428db7ad7 100644 --- a/packages/lsp/lsp-stdio/tests/connection.spec.ts +++ b/packages/lsp/lsp-stdio/tests/connection.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' @@ -62,7 +63,11 @@ describe('LspConnection', () => { it('rejects a request when the server replies with an error', async () => { const conn = connect({ LSP_FAKE_ERROR: '1' }) await conn.request('initialize', { capabilities: {} }) - await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) + await expect(conn.request('textDocument/hover', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_ERROR', + message: 'language server rejected textDocument/hover with JSON-RPC error -32000: server refused the request', + }) }) it('treats terminating an already-closed child as a teardown race', async () => { @@ -124,7 +129,11 @@ describe('LspConnection', () => { await conn.request('initialize', { capabilities: {} }) conn.terminate() await conn.closed - await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/) + await expect(conn.request('textDocument/hover', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: expect.stringMatching(/^language server exited/) as string, + }) }) it('cancel is a no-op-safe write after close', async () => { @@ -171,13 +180,52 @@ describe('LspConnection edge behavior', () => { configuration: null, }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) - await expect(conn.request('initialize', {})).rejects.toThrow() + // The failed launch usually surfaces first as a stdin write failure; once the process settles, + // the connection reports the provider's launch failure for it. + const failure: unknown = await conn.request('initialize', {}).catch((error: unknown) => error) + expect(failure).toMatchObject({ name: 'LspError', code: expect.stringMatching(/^LSP_(SERVER_EXITED|SPAWN_FAILED)$/) as string }) + await conn.closed + expect(conn.failedWith(failure)).toBe(true) + expect(conn.settledFailure(failure)).toMatchObject({ + name: 'LspError', + code: 'LSP_SPAWN_FAILED', + message: expect.stringMatching(/^language server process failed: .*ENOENT/) as string, + cause: expect.any(Error) as Error, + }) + const unrelated = new Error('unrelated') + expect(conn.settledFailure(unrelated)).toBe(unrelated) + }) + + it('keeps a subprocess provider HarnessError as the spawn failure', async () => { + const providerFailure = new HarnessError('sandbox refused the launch', 'SANDBOX_DENIED') + const conn = new LspConnection({ + command: process.execPath, + args: ['-e', 'setInterval(()=>{}, 1000)'], + cwd: process.cwd(), + env: scrubbedParentEnv(), + maxMessageBytes: 1000, + maxStderrBytes: 1000, + killGraceMs: 3_000, + configuration: null, + }, (spec) => { + const handle = spawnSubprocess(spec) + return Object.assign(Object.create(handle) as typeof handle, { + done: handle.done.then(() => { throw providerFailure }), + }) + }, () => Promise.resolve(null)) + open.push(conn) + const failure: unknown = await Promise.all([ + conn.request('initialize', {}).catch((error: unknown) => error), + Promise.resolve().then(() => { conn.terminate() }), + ]).then(([error]) => error) + await conn.closed + expect(conn.settledFailure(failure)).toBe(providerFailure) }) it('kills the process and fails pending requests on a framing error', async () => { // Emit an invalid Content-Length header, corrupting the stream irrecoverably. const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)') - await expect(conn.request('initialize', {})).rejects.toThrow() + await expect(conn.request('initialize', {})).rejects.toMatchObject({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR' }) }) it('ignores a framed non-object message', async () => { @@ -215,18 +263,25 @@ describe('LspConnection edge behavior', () => { expect(Buffer.byteLength(conn.stderrTail)).toBe(4) }) - it('rejects with a fallback message when the error response has no message string', async () => { + it.each([ + ['no message string', '{code:-1}', 'language server rejected initialize with JSON-RPC error -1: no message'], + ['no numeric code', '{message:"refused"}', 'language server rejected initialize with JSON-RPC error: refused'], + ])('rejects with LSP_SERVER_ERROR when the error response has %s', async (_label, error, message) => { const script = 'let b=Buffer.alloc(0);' + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' - + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});' + + `process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:${error}})));});` const conn = connectScript(script) - await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/) + await expect(conn.request('initialize', {})).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_ERROR', message }) }) it('rejects a pending request when the process exits mid-flight', async () => { // Never responds, then exits shortly: the pending request must reject on close. const conn = connectScript('setTimeout(()=>process.exit(0), 100)') - await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) + await expect(conn.request('initialize', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: 'language server exited', + }) }) it('rejects a pending request when child stdin fails but the process stays alive', async () => { @@ -235,7 +290,27 @@ describe('LspConnection edge behavior', () => { queueMicrotask(() => { done(failure) }) } const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer) - await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/) + await expect(conn.request('initialize', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: 'language server stopped accepting input: fixture stdin failure', + cause: failure, + }) + }) + + it('reports one stdin failure from the error event and the write callback as one transport failure', async () => { + const failure = new Error('fixture stdin failure') + const writer: ConnectionWriter = (stdin, _message, done) => { + queueMicrotask(() => { + stdin.emit('error', failure) + done(failure) + }) + } + const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer) + const rejection: unknown = await conn.request('initialize', {}).catch((error: unknown) => error) + expect(rejection).toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED', cause: failure }) + expect(conn.failedWith(rejection)).toBe(true) + await expect(conn.notify('initialized', {})).rejects.toBe(rejection) }) it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { diff --git a/packages/lsp/lsp-stdio/tests/framing.spec.ts b/packages/lsp/lsp-stdio/tests/framing.spec.ts index 51763db0d4..3d82110d48 100644 --- a/packages/lsp/lsp-stdio/tests/framing.spec.ts +++ b/packages/lsp/lsp-stdio/tests/framing.spec.ts @@ -50,33 +50,33 @@ describe('MessageDecoder', () => { it('rejects a body over the size limit', () => { const decoder = new MessageDecoder(4) - expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/) + expect(() => decoder.push(frame('{"big":true}'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_MESSAGE_TOO_LARGE', message: expect.stringMatching(/exceeds the 4-byte limit/) as string })) }) it('rejects a missing Content-Length header', () => { const decoder = new MessageDecoder(1_000) - expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/) + expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/missing Content-Length/) as string })) }) it('rejects a non-numeric Content-Length', () => { const decoder = new MessageDecoder(1_000) - expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/) + expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/invalid Content-Length/) as string })) }) it('rejects a header block that never terminates', () => { const decoder = new MessageDecoder(1_000) const huge = Buffer.alloc((1 << 16) + 1, 0x41) - expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) + expect(() => decoder.push(huge)).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/exceeded .* bytes without a terminator/) as string })) }) it('rejects an oversized header block that includes its terminator', () => { const decoder = new MessageDecoder(1_000) const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii') - expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/) + expect(() => decoder.push(huge)).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/header exceeded .* bytes/) as string })) }) it('rejects a non-JSON body', () => { const decoder = new MessageDecoder(1_000) - expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) + expect(() => decoder.push(frame('not json'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/not valid JSON/) as string })) }) }) diff --git a/packages/lsp/lsp-stdio/tests/host.spec.ts b/packages/lsp/lsp-stdio/tests/host.spec.ts index d75e025af1..49400dbf07 100644 --- a/packages/lsp/lsp-stdio/tests/host.spec.ts +++ b/packages/lsp/lsp-stdio/tests/host.spec.ts @@ -7,6 +7,7 @@ import { execFile } from 'node:child_process' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' +import { FsError } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { deadline } from '@deepseek-ai/dsh-timeout' import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-stdio' @@ -54,19 +55,38 @@ describe('canonicalizeWorkspace', () => { }) it('rejects a missing workspace', async () => { - await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/) + const missing = join(root, 'nope') + await expect(canonicalizeWorkspace(fs, missing)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_FOUND', + message: `workspace root "${missing}" does not exist`, + }) }) - it('wraps a provider failure while resolving the workspace', async () => { + it('adds the workspace operand to a filesystem failure while keeping its code', async () => { + const providerFailure = new FsError('permission denied', 'FS_PERMISSION_DENIED') + fs.resolve = async () => { throw providerFailure } + await expect(canonicalizeWorkspace(fs, ws)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_PERMISSION_DENIED', + message: `workspace root "${ws}" cannot be resolved: permission denied`, + cause: providerFailure, + }) + }) + + it('propagates a non-filesystem resolve failure unchanged', async () => { fs.resolve = async () => { throw 'raw workspace resolve failure' } - await expect(canonicalizeWorkspace(fs, ws)) - .rejects.toThrow(`workspace root "${ws}" cannot be resolved: raw workspace resolve failure`) + await expect(canonicalizeWorkspace(fs, ws)).rejects.toBe('raw workspace resolve failure') }) it('rejects a non-directory workspace', async () => { const file = join(root, 'file.txt') await writeFile(file, 'x') - await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/) + await expect(canonicalizeWorkspace(fs, file)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_DIRECTORY', + message: `workspace root "${file}" is not a directory`, + }) }) it('normalizes workspace metadata cancellation and preserves other provider failures', async () => { @@ -111,29 +131,56 @@ describe('readHostSource', () => { const outside = join(root, 'outside.ts') await writeFile(outside, 'secret') await symlink(outside, join(ws, 'escape.ts')) - await expect(readSource('escape.ts')).rejects.toThrow(/outside the workspace/) + await expect(readSource('escape.ts')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_FILE_OUTSIDE_WORKSPACE', + message: 'source "escape.ts" resolves outside the workspace', + }) }) it('rejects an absolute source outside the workspace', async () => { const outside = join(root, 'out.ts') await writeFile(outside, 'x') - await expect(readSource(outside)).rejects.toThrow(/outside the workspace/) + await expect(readSource(outside)).rejects.toMatchObject({ name: 'LspError', code: 'LSP_FILE_OUTSIDE_WORKSPACE' }) }) it('rejects a missing source', async () => { - await expect(readSource('nope.ts')).rejects.toThrow(/not found/) + await expect(readSource('nope.ts')).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_FOUND', + message: expect.stringMatching(/^source "nope\.ts" could not be read: .*not found/) as string, + }) }) - it('wraps a provider failure while resolving the source', async () => { + it('adds the source operand to a resolve failure while keeping its code', async () => { const canonical = await workspace() + const providerFailure = new FsError('a parent path segment is not a directory', 'FS_NOT_FOUND') + fs.resolve = async () => { throw providerFailure } + await expect(readHostSource(fs, 'broken.ts', canonical, BIG)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_FOUND', + message: 'source "broken.ts" cannot be resolved: a parent path segment is not a directory', + cause: providerFailure, + }) fs.resolve = async () => { throw 'raw resolve failure' } - await expect(readHostSource(fs, 'broken.ts', canonical, BIG)) - .rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure') + await expect(readHostSource(fs, 'broken.ts', canonical, BIG)).rejects.toBe('raw resolve failure') + }) + + it('propagates a non-filesystem read failure unchanged', async () => { + await writeFile(join(ws, 'a.ts'), 'x') + const canonical = await workspace() + const providerFailure = new Error('stream defect') + fs.streamText = async () => { throw providerFailure } + await expect(readHostSource(fs, 'a.ts', canonical, BIG)).rejects.toBe(providerFailure) }) it('rejects a non-regular source (directory)', async () => { await mkdir(join(ws, 'dir')) - await expect(readSource('dir')).rejects.toThrow(/not a regular file/) + await expect(readSource('dir')).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_REGULAR_FILE', + message: expect.stringMatching(/^source "dir" could not be read: .*not a regular file/) as string, + }) }) // Windows has no filesystem FIFO; the directory case above pins non-regular rejection there. @@ -159,6 +206,8 @@ describe('readHostSource', () => { it('rejects an oversized source and reports the observed lower bound', async () => { await writeFile(join(ws, 'big.ts'), 'x'.repeat(100)) await expect(readSource('big.ts', 10)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_TOO_LARGE', message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes', }) }) @@ -171,7 +220,11 @@ describe('readHostSource', () => { it('rejects a non-UTF-8 source', async () => { await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) - await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/) + await expect(readSource('bin.ts')).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_TEXT', + message: expect.stringMatching(/invalid UTF-8|binary file/) as string, + }) }) it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => { diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index bcf2669fd4..10e0a783b1 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -255,7 +255,7 @@ describe('LspInstance query and abort', () => { shutdownTimeoutMs: 100, killGraceMs: 100, }, failingWriter('textDocument/didOpen')) - await expect(run(instance, 'goToDefinition')).rejects.toThrow() + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) expect(instance.dead).toBe(true) }) @@ -264,13 +264,32 @@ describe('LspInstance query and abort', () => { shutdownTimeoutMs: 100, killGraceMs: 100, }, failingWriter('textDocument/definition')) - await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: 'language server stopped accepting input: fixture textDocument/definition failure', + }) expect(instance.dead).toBe(true) }) it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_OPERATION', + message: 'server does not support goToDefinition', + }) + }) + + it('reports inconsistent instance state as LSP_NOT_INITIALIZED', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) + Reflect.set(instance, 'capabilities', undefined) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_NOT_INITIALIZED', + message: 'LSP instance is not initialized', + }) }) it('propagates a server error response even when a signal is supplied (not an abort)', async () => { @@ -278,7 +297,11 @@ describe('LspInstance query and abort', () => { // without treating it as an abort. const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) const controller = new AbortController() - await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_ERROR', + message: 'language server rejected textDocument/definition with JSON-RPC error -32000: server refused the request', + }) }) it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { @@ -293,6 +316,32 @@ describe('LspInstance query and abort', () => { expect(instance.dead).toBe(true) }) + it('reports the launch failure when the server executable cannot be spawned', async () => { + const instance = makeInstance({}, { command: join(root, 'missing-server') }) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SPAWN_FAILED', + message: expect.stringMatching(/^language server process failed: .*ENOENT/) as string, + }) + expect(instance.dead).toBe(true) + }) + + it.each([ + ['a non-object result', 'null', 'LSP initialize result was not an object'], + ['no capabilities object', '{}', 'LSP initialize result had no capabilities object'], + ])('rejects an initialize result with %s', async (_label, result, message) => { + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);' + + `if(body.method==="initialize")process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:${result}})));});` + const instance = makeInstance({}, { args: ['-e', script], shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_MALFORMED_RESPONSE', + message, + }) + }) + }) describe('LspInstance disposal', () => { diff --git a/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts b/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts index 551d1ffe05..62961d01f1 100644 --- a/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts @@ -153,7 +153,11 @@ describe('lsp-stdio end to end over a fake server', () => { LSP_FAKE_DEF: 'null', LSP_FAKE_EXIT_MARKER: marker, }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_ENCODING', + message: expect.stringMatching(/unsupported position encoding/) as string, + }) expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') await ctx.fiber.dispose() }) @@ -168,8 +172,41 @@ describe('lsp-stdio end to end over a fake server', () => { await ctx.fiber.dispose() }) - it('preserves a query failure with final disposal failure and evicts the instance', async () => { - const teardownFailure = new Error('managed range observation failed') + it('combines a non-coded query failure with a final disposal failure', async () => { + const teardownFailure = 'managed range observation failed' + const controller = new AbortController() + const cancelled = new Error('caller cancelled') + const ctx = await mount( + { LSP_FAKE_HANG: '1' }, + { shutdownTimeoutMs: 100, killGraceMs: 100 }, + undefined, + (mounted) => { + const spawn = mounted.subprocess.spawn.bind(mounted.subprocess) + vi.spyOn(mounted.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + const waitForExit = handle.waitForExit.bind(handle) + vi.spyOn(handle, 'waitForExit').mockImplementation(async (signal) => { + await waitForExit(signal) + throw teardownFailure + }) + setTimeout(() => { controller.abort(cancelled) }, 0) + return handle + }) + }, + ) + const failure = await ctx.lsp.query(query('goToDefinition'), controller.signal).then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toEqual([cancelled, teardownFailure]) + await ctx.fiber.dispose() + }) + + it.each([ + ['an Error', new Error('managed range observation failed')], + ['a non-Error value', 'managed range observation failed'], + ])('preserves a query failure with final disposal failure (%s) and evicts the instance', async (_label, teardownFailure) => { let provider: LspProvider | undefined let firstSpawn = true let restoreFirstWait: (() => void) | undefined @@ -198,13 +235,20 @@ describe('lsp-stdio end to end over a fake server', () => { () => undefined, (error: unknown) => error, ) - expect(failure).toBeInstanceOf(AggregateError) - const errors = (failure as AggregateError).errors as unknown[] + expect(failure).toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_ENCODING', + message: expect.stringMatching( + /unsupported position encoding .*; language server teardown also failed: managed range observation failed$/, + ) as string, + }) + const cause = (failure as Error).cause + expect(cause).toBeInstanceOf(AggregateError) + const errors = (cause as AggregateError).errors as unknown[] expect(errors).toHaveLength(2) - expect(errors[0]).toBeInstanceOf(Error) - expect((errors[0] as Error).message).toContain('unsupported position encoding') + expect(errors[0]).toMatchObject({ name: 'LspError', code: 'LSP_UNSUPPORTED_ENCODING' }) expect(errors[1]).toBe(teardownFailure) - expect((failure as AggregateError).message).toBe('LSP operation and teardown failed') + expect((cause as AggregateError).message).toBe('LSP operation and teardown failed') restoreFirstWait?.() if (provider === undefined) throw new Error('expected lsp-stdio to register a provider') const instances = (provider as unknown as { readonly instances: ReadonlyMap }).instances @@ -262,7 +306,11 @@ describe('lsp-stdio end to end over a fake server', () => { it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_OPERATION', + message: expect.stringMatching(/transient textDocument\/didOpen/) as string, + }) await ctx.fiber.dispose() }) @@ -274,7 +322,11 @@ describe('lsp-stdio end to end over a fake server', () => { it('fails a query for an unsupported operation', async () => { const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/) + await expect(ctx.lsp.query(query('hover'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_OPERATION', + message: 'server does not support hover', + }) await ctx.fiber.dispose() }) @@ -282,7 +334,10 @@ describe('lsp-stdio end to end over a fake server', () => { const outside = join(root, 'out.ts') await writeFile(outside, 'x') const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_FILE_OUTSIDE_WORKSPACE', + }) await ctx.fiber.dispose() }) @@ -363,6 +418,18 @@ describe('lsp-stdio end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('names a non-Error abort reason as an AbortError', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort('caller stopped') + await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + message: 'LSP query aborted', + cause: 'caller stopped', + }) + await ctx.fiber.dispose() + }) + it('surfaces the server stderr tail in the exit error', async () => { // A server that writes to stderr then exits without answering: the query rejection carries the // retained stderr tail so the failure is diagnosable. @@ -370,7 +437,11 @@ describe('lsp-stdio end to end over a fake server', () => { command: process.execPath, args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: expect.stringMatching(/^language server exited; stderr: FATAL: boom/) as string, + }) await ctx.fiber.dispose() }) @@ -383,9 +454,9 @@ describe('lsp-stdio end to end over a fake server', () => { it('fails the active query when the server crashes on open, and replaces it next query', async () => { const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) await ctx.fiber.dispose() }) diff --git a/packages/lsp/lsp-stdio/tests/translate.spec.ts b/packages/lsp/lsp-stdio/tests/translate.spec.ts index e38a9d140c..dc14808ac2 100644 --- a/packages/lsp/lsp-stdio/tests/translate.spec.ts +++ b/packages/lsp/lsp-stdio/tests/translate.spec.ts @@ -61,7 +61,11 @@ describe('negotiatePositionEncoding', () => { }) it('rejects any other encoding', () => { - expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/) + expect(() => negotiatePositionEncoding('utf-8')).toThrow(expect.objectContaining({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_ENCODING', + message: expect.stringMatching(/unsupported position encoding "utf-8"/) as string, + })) }) }) diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts index 05e2cd3643..1c7028552c 100644 --- a/packages/lsp/lsp/src/index.ts +++ b/packages/lsp/lsp/src/index.ts @@ -42,12 +42,23 @@ declare module '@deepseek-ai/cordis' { } /** - * Structured LSP failure. Extends {@link HarnessError} with a stable `code` - * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, - * `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of - * parsing `message`. + * Structured LSP failure. Extends {@link HarnessError} with a stable `code` that callers route on + * instead of parsing `message`. The seam raises `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, and + * `LSP_UNAVAILABLE`; providers raise `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and + * `LSP_MALFORMED_RESPONSE`. Providers and consumers add their own documented codes, so `code` + * stays open and consumers tolerate codes they do not know. */ -export class LspError extends HarnessError {} +export class LspError extends HarnessError { + /** + * @param message - actionable description of the failure. + * @param code - stable failure code. + * @param options - the underlying failure, when one exists. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'LspError' + } +} /** * Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` → diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml index 15406bd1ed..e8f31cabd5 100644 --- a/packages/lsp/tool-lsp/README.i18n.yaml +++ b/packages/lsp/tool-lsp/README.i18n.yaml @@ -27,8 +27,8 @@ en: c6ca5869a35edefa zh: 65378aff80dfc93c /deepseek-ai-dsh-tool-lsp/use-this-package/failures-and-recovery: - en: b1f8b3bb52434cc6 - zh: a8c9643a754c4768 + en: 93aff7952acc564a + zh: f27835dfeeb726b7 /deepseek-ai-dsh-tool-lsp/understand-the-implementation: en: 6574e484bce0e60b zh: 75688d1f9dde8f48 diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index baa4804ace..72145adbc3 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -49,7 +49,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Failures and recovery -The tool requires an Agent Session; a non-agent call fails with `LSP_WORKSPACE_REQUIRED` before any query. The working-directory service supplies and validates its current root. When no provider handles the file's extension, the query fails with `LSP_UNAVAILABLE`; malformed provider payloads remain structured `LSP_MALFORMED_RESPONSE` errors. These surface to the model as error tool results it can read and route on. +An unknown operation, a blank `file_path`, or a coordinate that is not a positive integer fails with `ToolArgsError` `INVALID_ARGS` before any query. The tool requires an Agent Session; a non-agent call fails with `LSP_WORKSPACE_REQUIRED` before any query. The working-directory service supplies and validates its current root. When no provider handles the file's extension, the query fails with `LSP_UNAVAILABLE`; malformed provider payloads remain structured `LSP_MALFORMED_RESPONSE` errors. These and every provider failure surface to the model as error tool results that keep the failure's `name` and `code`; the [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) lists the codes. ----- diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md index db825eb415..15eab32136 100644 --- a/packages/lsp/tool-lsp/README.zh.md +++ b/packages/lsp/tool-lsp/README.zh.md @@ -49,7 +49,7 @@ kind: "package-reference" ### 失败与恢复 -该工具要求 Agent Session;非 Agent 调用会在任何查询前以 `LSP_WORKSPACE_REQUIRED` 失败。工作目录服务提供并校验当前根目录。当没有提供方处理该文件扩展名时,查询以 `LSP_UNAVAILABLE` 失败;格式错误的提供方载荷仍保持为结构化 `LSP_MALFORMED_RESPONSE` 错误。这些会呈现为模型可读、可路由的错误工具结果。 +未知操作、空白 `file_path` 或不是正整数的坐标会在任何查询前以 `ToolArgsError` `INVALID_ARGS` 失败。该工具要求 Agent Session;非 Agent 调用会在任何查询前以 `LSP_WORKSPACE_REQUIRED` 失败。工作目录服务提供并校验当前根目录。当没有提供方处理该文件扩展名时,查询以 `LSP_UNAVAILABLE` 失败;格式错误的提供方载荷仍保持为结构化 `LSP_MALFORMED_RESPONSE` 错误。这些失败以及所有提供方失败都会呈现为保留失败 `name` 与 `code` 的错误工具结果;[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)列出了这些错误码。 ----- diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index ad77b2a6e2..59fd8b273b 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -6,6 +6,7 @@ * @module @deepseek-ai/dsh-tool-lsp/render */ +import { ToolArgsError } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp' import { posix, win32 } from 'node:path' @@ -41,13 +42,14 @@ export interface LspToolArgs { * positive one-based integers converted to the seam's zero-based position. * @param args - the schema-validated raw arguments. * @returns the validated input with a zero-based position. - * @throws Error when the operation is unknown or a coordinate is not a positive integer. + * @throws ToolArgsError (`INVALID_ARGS`) when the operation is unknown, `file_path` is blank, or a + * coordinate is not a positive integer. */ export function parseLspArgs(args: LspToolArgs): LspToolInput { if (!isOperation(args.operation)) { - throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`) + throw new ToolArgsError([`operation must be one of ${LSP_OPERATIONS.join(', ')}`]) } - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) const line = oneBased(args.line, 'line') const character = oneBased(args.character, 'character') return { @@ -66,7 +68,7 @@ function isOperation(value: string): value is LspOperation { /** Validate a one-based coordinate is a positive integer. */ function oneBased(value: number, name: string): number { if (!Number.isInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer (one-based)`) + throw new ToolArgsError([`${name} must be a positive integer (one-based)`]) } return value } diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index a0ea788eff..fdde3f79ec 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -102,4 +102,17 @@ describe('tool-lsp integration', () => { expect(result.error?.info?.code).toBe('TOOL_TIMEOUT') await ctx.fiber.dispose() }, 30_000) + + it('keeps provider failure names and codes on tool results', async () => { + const outside = join(root, 'outside.ts') + await writeFile(outside, 'x') + const ctx = await mount(false) + const missing = await call(ctx, { operation: 'goToDefinition', file_path: 'missing.ts', line: 1, character: 1 }) + expect(missing.isError).toBe(true) + expect(missing.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_FOUND' }) + const escaped = await call(ctx, { operation: 'goToDefinition', file_path: outside, line: 1, character: 1 }) + expect(escaped.isError).toBe(true) + expect(escaped.error?.info).toEqual({ name: 'LspError', code: 'LSP_FILE_OUTSIDE_WORKSPACE' }) + await ctx.fiber.dispose() + }, 30_000) }) diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 51fb6a5083..44a05f40b2 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -31,18 +31,18 @@ describe('parseLspArgs', () => { it('rejects an unknown operation', () => { expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 })) - .toThrow(/operation must be one of/) + .toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/operation must be one of/) as string })) }) it('rejects a blank file_path', () => { expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 })) - .toThrow(/file_path/) + .toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: 'invalid arguments: file_path must be a non-empty string' })) }) it('rejects non-positive or non-integer coordinates', () => { - expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/) - expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/) - expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/line must be a positive integer/) as string })) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/character must be a positive integer/) as string })) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/line must be a positive integer/) as string })) }) }) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 4947c40379..5e52ba20f6 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import Lsp, { LspError, LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -196,21 +196,44 @@ describe('tool-lsp execution', () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null) expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('LSP_WORKSPACE_REQUIRED') + expect(result.error?.info).toEqual({ name: 'LspError', code: 'LSP_WORKSPACE_REQUIRED' }) }) it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => { const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' })) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('LSP_UNAVAILABLE') + expect(result.error?.info).toEqual({ name: 'LspError', code: 'LSP_UNAVAILABLE' }) }) it('returns a structured INVALID_ARGS on a bad operation', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('INVALID_ARGS') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + }) + + it.each([ + ['a blank file_path', { file_path: ' ', line: 1, character: 1 }, 'file_path must be a non-empty string'], + ['a zero line', { file_path: 'a.ts', line: 0, character: 1 }, 'line must be a positive integer (one-based)'], + ['a fractional character', { file_path: 'a.ts', line: 1, character: 1.5 }, 'character must be a positive integer (one-based)'], + ])('returns a structured INVALID_ARGS for %s', async (_label, args, violation) => { + const provider = stubProvider(() => okLocations) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'hover', ...args }, workspaceRoot) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + expect(result.error?.message).toContain(violation) + expect(provider.seen).toHaveLength(0) + }) + + it('keeps a provider failure\'s name and code on the tool result', async () => { + const { ctx } = await mount(stubProvider(() => { + throw new LspError('language server exited', 'LSP_SERVER_EXITED') + })) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) }) it('forwards exec.signal to the seam query', async () => { From e9ed6104d9a1b8908f7e85b6de6e5a3b378232e4 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:37:56 +0800 Subject: [PATCH 021/149] docs: regenerate config catalog and event graph source lines --- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- docs/event-producer-consumer.md | 8 ++++---- docs/event-producer-consumer.zh.md | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7edf100e3d..527e193db9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2341,7 +2341,7 @@ export type Config = Readonly> ## `@deepseek-ai/dsh-lsp-stdio` - `inject`: `fs` · `lsp` · `subprocess` -- `source`: [`packages/lsp/lsp-stdio/src/index.ts:82`](../packages/lsp/lsp-stdio/src/index.ts) +- `source`: [`packages/lsp/lsp-stdio/src/index.ts:83`](../packages/lsp/lsp-stdio/src/index.ts) ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -4155,7 +4155,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c9ed22e1d4..76218f3d03 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2343,7 +2343,7 @@ export type Config = Readonly> ## `@deepseek-ai/dsh-lsp-stdio` - `inject`: `fs` · `lsp` · `subprocess` -- `source`: [`packages/lsp/lsp-stdio/src/index.ts:82`](../packages/lsp/lsp-stdio/src/index.ts) +- `source`: [`packages/lsp/lsp-stdio/src/index.ts:83`](../packages/lsp/lsp-stdio/src/index.ts) ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -4157,7 +4157,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d6514e9585..5d1f5d9790 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,10 +48,10 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | -| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:83`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:75`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:93`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 38d90599f9..7c9a02987b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -50,10 +50,10 @@ | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | -| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:83`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:75`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:93`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | From eee5c94a4170f861157c40338ed2d58d06792830 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 21:59:35 +0800 Subject: [PATCH 022/149] feat(attachment): make AttachmentError a HarnessError AttachmentError now extends HarnessError from the dependency-free dsh-error package, keeping its name and every ATTACHMENT_* and image admission code, so tool results record its name and code and adapter failures report the attachment code instead of UNKNOWN. An upgrade guide records the persisted code change. read_image classifies AttachmentError before the HarnessError pass-through so its ImageInputError codes stay unchanged. HarnessError imports this branch added in dsh-tool-fs and dsh-lsp-stdio now come from dsh-error, which both packages declare. --- docs/module-graph.md | 9 +++++--- docs/module-graph.zh.md | 9 +++++--- .../attachment-error-codes/guide.i18n.yaml | 16 +++++++++++++ .../attachment-error-codes/guide.md | 23 +++++++++++++++++++ .../attachment-error-codes/guide.zh.md | 23 +++++++++++++++++++ .../attachment/attachment/README.i18n.yaml | 4 ++-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/package.json | 2 ++ packages/attachment/attachment/src/error.ts | 19 ++++++++------- .../attachment/attachment/tests/index.spec.ts | 10 ++++++++ packages/attachment/attachment/tsconfig.json | 3 ++- .../extensions/tool-cordis/src/api-catalog.ts | 6 ++++- packages/fs/tool-fs/package.json | 2 ++ packages/fs/tool-fs/src/image-error.ts | 2 +- packages/fs/tool-fs/src/read-image.ts | 15 +++++++----- packages/fs/tool-fs/tsconfig.json | 3 +++ packages/lsp/lsp-stdio/package.json | 2 ++ packages/lsp/lsp-stdio/src/connection.ts | 2 +- packages/lsp/lsp-stdio/src/index.ts | 2 +- .../lsp/lsp-stdio/tests/connection.spec.ts | 2 +- packages/lsp/lsp-stdio/tsconfig.json | 3 +++ pnpm-lock.yaml | 9 ++++++++ 23 files changed, 138 insertions(+), 32 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md diff --git a/docs/module-graph.md b/docs/module-graph.md index 1f8dfaee91..883e28cd71 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -490,6 +490,7 @@ flowchart TD pkg_session --> pkg_scope pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_attachment --> pkg_error pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand @@ -649,6 +650,7 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_error pkg_lsp_stdio --> pkg_fs pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp @@ -861,6 +863,7 @@ flowchart TD pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment + pkg_tool_fs --> pkg_error pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox @@ -1691,7 +1694,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | @@ -1761,7 +1764,7 @@ flowchart TD | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`experimental-translator`](../packages/experimental/translator) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | @@ -1806,7 +1809,7 @@ flowchart TD | [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a6070b4f5e..c65ef9fdb4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -492,6 +492,7 @@ flowchart TD pkg_session --> pkg_scope pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_attachment --> pkg_error pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand @@ -651,6 +652,7 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_error pkg_lsp_stdio --> pkg_fs pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp @@ -863,6 +865,7 @@ flowchart TD pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment + pkg_tool_fs --> pkg_error pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox @@ -1693,7 +1696,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | @@ -1763,7 +1766,7 @@ flowchart TD | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`experimental-translator`](../packages/experimental/translator) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | @@ -1808,7 +1811,7 @@ flowchart TD | [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml new file mode 100644 index 0000000000..07aac6cd05 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md +/: + en: 8a0a1ff4ca3ad446 + zh: 4bd6cd51cbd791c2 +/attachment-failures-keep-their-codes: + en: 4f2b2f0db0646d3b + zh: 9467f41336a65c48 +/attachment-failures-keep-their-codes/change: + en: 1e5560d944ebe67d + zh: 235f3ec670b01ae2 +/attachment-failures-keep-their-codes/migration: + en: bceb35b3d8c551c4 + zh: e865e7752bec8d13 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md new file mode 100644 index 0000000000..8c416e5537 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "AttachmentError now extends HarnessError, so tool and model-request failures record attachment codes instead of losing them or reporting UNKNOWN." +--- + +# Attachment failures keep their codes + +English | [中文](guide.zh.md) + +## Change + +`AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: + +- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the attachment code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, or `ATTACHMENT_READ_FAILED`. +- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` converts attachment failures to `ImageInputError` codes instead. + +`instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. + +## Migration + +1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the attachment codes or route them by their `ATTACHMENT_` prefix. +2. If code catches `HarnessError` before `AttachmentError`, move the `AttachmentError` check first wherever the two need different handling. +3. Earlier Session logs keep `UNKNOWN`; readers that compare old and new logs should treat both as the same failure class for attachment reads. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md new file mode 100644 index 0000000000..3b9ab5145e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "AttachmentError 现在继承 HarnessError,工具失败与模型请求失败会记录附件错误码,不再丢失或报告为 UNKNOWN。" +--- + +# 附件失败保留其错误码 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: + +- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录附件错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。 +- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 则把附件失败转换为 `ImageInputError` 错误码。 + +`instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 + +## 迁移 + +1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入这些附件错误码,或按 `ATTACHMENT_` 前缀路由。 +2. 如果代码先捕获 `HarnessError` 再捕获 `AttachmentError`,在两者需要不同处理的地方把 `AttachmentError` 检查移到前面。 +3. 较早的 Session 日志仍保留 `UNKNOWN`;对比新旧日志的读取方应把附件读取的这两种记录视为同一类失败。 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 125d96125d..44c9125f4f 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -36,8 +36,8 @@ en: 25d9c7d6c0899d23 zh: ad8380d14810ff1c /deepseek-ai-dsh-attachment/understand-the-implementation/design-decisions: - en: 6315b2d1fb12f0ca - zh: c3f99f4c0850fb44 + en: bc4586bb4ce74464 + zh: 5ffd0a0e7261bc24 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: en: b94d13fe133d8ce6 zh: dd4064d20d510afc diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 0e0c2bc05f..075094dcc8 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads verify the immutable object against the supplied reference without consulting a Session. Optional display names and pre-normalization dimensions pass through as caller-supplied display information; the stored bytes do not verify these fields. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path. -- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. +- **Error routing by code.** `AttachmentError` extends `HarnessError` from the dependency-free `dsh-error`, so tool results keep its `name` and `code`; consumers use `isAttachmentError`, which matches the code across duplicate installations, and route on `code`. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 9c9d2c7fb5..400cc79c1e 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取会把不可变对象与传入引用比对,无需查询 Session。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回;存储字节不校验这两个字段。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 -- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 +- **按错误码路由。** `AttachmentError` 继承无依赖包 `dsh-error` 中的 `HarnessError`,因此工具结果保留其 `name` 与 `code`;消费方用 `isAttachmentError` 识别错误(它按错误码匹配,跨重复安装也有效),并按 `code` 路由。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 ### 服务操作 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 79c35fd196..dc831253b1 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -23,10 +23,12 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 0aed793f35..2a933daa08 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -1,5 +1,7 @@ /** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ +import { HarnessError } from '@deepseek-ai/dsh-error' + const IMAGE_ADMISSION_ERROR_CODES = [ 'TOO_MANY_IMAGES', 'IMAGES_TOO_LARGE', @@ -35,17 +37,14 @@ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISS const ATTACHMENT_ERROR_CODE_SET: ReadonlySet = new Set(ATTACHMENT_ERROR_CODES) /** - * Stable failures suitable for host RPC error mapping. - * - * Deliberately re-implements the `HarnessError` shape instead of extending it: - * the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this - * package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base - * would create a dependency cycle. Consumers route on `code`, never on the - * prototype chain, so the shapes stay interchangeable at the wire boundary. + * Stable failures suitable for host RPC error mapping. Extends {@link HarnessError}, so a tool + * result that fails with it records `name` `AttachmentError` and its `code`. Consumers that may + * receive an error from a duplicate package installation identify it with {@link isAttachmentError}, + * which matches the code instead of the prototype chain. */ -export class AttachmentError extends Error { +export class AttachmentError extends HarnessError { /** Stable machine-routing failure code. */ - readonly code: AttachmentErrorCode + override readonly code: AttachmentErrorCode /** * @param message - human-readable failure description without raw bytes or host paths. @@ -53,7 +52,7 @@ export class AttachmentError extends Error { * @param options - optional chained cause. */ constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions) { - super(message, options) + super(message, code, options) this.name = 'AttachmentError' this.code = code } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 9ce59d2d48..159dafd61f 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-error' import AttachmentStore, { AttachmentError, AttachmentId, @@ -218,6 +219,15 @@ describe('isImageAdmissionError', () => { }) }) +describe('AttachmentError', () => { + it('is a HarnessError that keeps its literal name and attachment code', () => { + const cause = new Error('disk full') + const error = new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'AttachmentError', code: 'ATTACHMENT_WRITE_FAILED', message: 'disk failed', cause }) + }) +}) + describe('isAttachmentError', () => { it('recognizes attachment failures from another package installation by code', () => { expect(isAttachmentError(new AttachmentError('bad base64', 'INVALID_FILE_BASE64'))).toBe(true) diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json index 33e1dd58de..3efee044ab 100644 --- a/packages/attachment/attachment/tsconfig.json +++ b/packages/attachment/attachment/tsconfig.json @@ -5,6 +5,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" } + { "path": "../../util/brand" }, + { "path": "../../util/error" } ] } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c5d2a12f4a..b67d999481 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5818,7 +5818,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AttachmentError', - declaration: 'export class AttachmentError extends Error {\n readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}', + declaration: 'export class AttachmentError extends HarnessError {\n override readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}', }, { name: 'AttachmentErrorCode', @@ -6792,6 +6792,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GrantRecord', declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}', }, + { + name: 'HarnessError', + declaration: 'export class HarnessError extends Error {\n readonly code: string;\n constructor(message: string, code: string, options?: ErrorOptions);\n}', + }, { name: 'HookBudget', declaration: 'export interface HookBudget {\n readonly ms: number;\n readonly remainingMs: number;\n}', diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 331615dce5..7c33681fa8 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts index 102942e9c6..b9839110fc 100644 --- a/packages/fs/tool-fs/src/image-error.ts +++ b/packages/fs/tool-fs/src/image-error.ts @@ -1,6 +1,6 @@ /** Typed `read_image` failures. @module @deepseek-ai/dsh-tool-fs/src/image-error */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every machine-routable `read_image` failure code: diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index b8bacbcba4..159cb2409c 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -16,7 +16,7 @@ import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' @@ -217,21 +217,24 @@ interface SaveFailureFacts { /** * Classify one `saveImage` rejection as the error `read_image` throws. - * An `AbortError` and an already classified `HarnessError` pass through - * unchanged; attachment admission codes become {@link ImageInputError} codes, - * and every other failure becomes `IMAGE_STORE_FAILED` with the original as `cause`. + * Attachment admission codes become {@link ImageInputError} codes and other + * `AttachmentError` codes become `IMAGE_STORE_FAILED`; any other `HarnessError` + * and an `AbortError` pass through unchanged; every other failure becomes + * `IMAGE_STORE_FAILED`. Converted errors keep the original as `cause`. * @param error - the value `saveImage` rejected with. * @param attachments - the store whose limits the refusal names. * @param facts - the path and media-type facts the refusal names. * @returns the error to throw from the tool body. */ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFailureFacts): unknown { - if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error const { displayPath, extension, mediaType, sniffed } = facts const refusal = (detail: string, code: ImageInputError['code']) => new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') - if (!(error instanceof AttachmentError)) return storeFailed(error instanceof Error ? error.message : String(error)) + if (!(error instanceof AttachmentError)) { + if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error + return storeFailed(error instanceof Error ? error.message : String(error)) + } // Size refusals stay recoverable tool errors: an oversized image must never // enter durable history, where it would ride every later model request past // provider-side dimension rejections. diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 69d7101ff5..8d4c6539b0 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/error" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 670566cf85..494db76732 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", @@ -45,6 +46,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index e910ac0a52..c0c1fa2a8c 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -12,7 +12,7 @@ import type { Writable } from 'node:stream' import { LspError } from '@deepseek-ai/dsh-lsp' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index 1e7fe51591..eee4962849 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import type { LspProvider, LspProviderQuery, diff --git a/packages/lsp/lsp-stdio/tests/connection.spec.ts b/packages/lsp/lsp-stdio/tests/connection.spec.ts index 9428db7ad7..d226e6e8e3 100644 --- a/packages/lsp/lsp-stdio/tests/connection.spec.ts +++ b/packages/lsp/lsp-stdio/tests/connection.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/lsp/lsp-stdio/tsconfig.json b/packages/lsp/lsp-stdio/tsconfig.json index d133816fda..8487fd28cf 100644 --- a/packages/lsp/lsp-stdio/tsconfig.json +++ b/packages/lsp/lsp-stdio/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/error" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cacf7aa38f..1eb0a0f5a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1968,6 +1968,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error packages/attachment/attachment-local: dependencies: @@ -12272,6 +12275,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-fs': specifier: workspace:* version: link:../fs @@ -13630,6 +13636,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-fs': specifier: workspace:* version: link:../../fs/fs From 43b006af2a7eedfcedda8838a8bf2da844764fbe Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Fri, 6 Nov 2026 15:17:46 +0800 Subject: [PATCH 023/149] test(goal): assert the durable blocking operation --- packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index c9be135ea1..35b2d0c3ee 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -738,7 +738,7 @@ describe('same-session goal driving', () => { await admission.promise expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) - expect(test.agent.session.snapshotEvents().some(event => event.type === 'goal/blocked')).toBe(false) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'goal/change' && event.data.operation === 'block')).toBe(false) }) it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => { From 1a3d59a0ff4d1b68a01514d3a9e6c5fe2e13e5eb Mon Sep 17 00:00:00 2001 From: dsh-ci-bot Date: Fri, 6 Nov 2026 16:06:14 +0800 Subject: [PATCH 024/149] test(ui-theme): skip dot directories in the package scan The hover-styles contract walks packages/ for package.json files. Typert generator specs create temporary roots inside that tree with mkdtempSync(join(import.meta.dirname, '....')) and remove them in afterEach, so a concurrent scan can readdir one and then hit ENOENT when its owner deletes it. Dot directories hold VCS metadata and those transient roots, never shipped source, so skip them and let the helper walk an explicit root for the regression test. --- .../ui-theme/tests/stylesheet-scan.spec.ts | 23 +++++++++++++++++++ .../client/ui-theme/tests/stylesheet-scan.ts | 12 ++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 packages/client/ui-theme/tests/stylesheet-scan.spec.ts diff --git a/packages/client/ui-theme/tests/stylesheet-scan.spec.ts b/packages/client/ui-theme/tests/stylesheet-scan.spec.ts new file mode 100644 index 0000000000..1614ee55a7 --- /dev/null +++ b/packages/client/ui-theme/tests/stylesheet-scan.spec.ts @@ -0,0 +1,23 @@ +/** Tests for the package-tree scan helpers that the stylesheet contracts range over. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { packageFiles } from './stylesheet-scan.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +it('walks shipped package files and skips dot directories holding transient roots', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-stylesheet-scan-')) + roots.push(root) + mkdirSync(join(root, 'pkg')) + writeFileSync(join(root, 'pkg', 'package.json'), '{}') + mkdirSync(join(root, '.typert-worker-failure-probe')) + writeFileSync(join(root, '.typert-worker-failure-probe', 'package.json'), '{}') + + expect(packageFiles(name => name === 'package.json', root)).toEqual([`${root}/pkg/package.json`]) +}) diff --git a/packages/client/ui-theme/tests/stylesheet-scan.ts b/packages/client/ui-theme/tests/stylesheet-scan.ts index 91311945bf..0ddb93e0f8 100644 --- a/packages/client/ui-theme/tests/stylesheet-scan.ts +++ b/packages/client/ui-theme/tests/stylesheet-scan.ts @@ -75,20 +75,24 @@ export function varReferences(value: string): string[] { * Every file shipped as package source whose name the predicate accepts, * excluding build output and installed dependencies. * @param accepts - filename predicate. - * @returns absolute paths under packages/, `/`-separated on every platform so + * @param root - tree to walk, the packages directory by default. + * @returns absolute paths under `root`, `/`-separated on every platform so * specs can match repo-relative suffixes verbatim. */ -export function packageFiles(accepts: (name: string) => boolean): string[] { +export function packageFiles(accepts: (name: string) => boolean, root: string = PACKAGES_DIR): string[] { const found: string[] = [] const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name) if (entry.isDirectory()) { - if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path) + // Dot directories hold VCS metadata and per-test temporary roots (for + // example `.typert-worker-failure-*`), never shipped package source; a + // concurrent owner can remove one while a scan reads it. + if (!entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path) } else if (accepts(entry.name)) found.push(path.replaceAll('\\', '/')) } } - walk(PACKAGES_DIR) + walk(root) return found } From c2fd342d33cb214a1fb04e619496d395d044580e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 16:19:10 +0800 Subject: [PATCH 025/149] fix(fs): address review feedback on read_image and create errors - read_image converts only AttachmentError rejections from saveImage; any other value is a store defect or cancellation and propagates unchanged instead of becoming IMAGE_STORE_FAILED. - ImageInputError is no longer exported from the dsh-tool-fs entry, and its JSDoc notes that two codes share AttachmentError spellings. - str_replace_editor create maps a guarded-creation collision (FS_NOT_OBSERVED after a concurrent create) to FS_ALREADY_EXISTS. - The fs-local directory-swap test asserts FS_NOT_REGULAR_FILE exactly. - The attachment upgrade guide lists the full AttachmentErrorCode set instead of an ATTACHMENT_ prefix. - dsh-error dependency entries sit in sorted position. --- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- .../attachment-error-codes/guide.i18n.yaml | 8 ++++---- .../attachment-error-codes/guide.md | 4 ++-- .../attachment-error-codes/guide.zh.md | 4 ++-- packages/fs/fs-local/tests/fsio.spec.ts | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 ++-- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/package.json | 4 ++-- packages/fs/tool-fs/src/image-error.ts | 6 +++++- packages/fs/tool-fs/src/index.ts | 2 -- packages/fs/tool-fs/src/read-image.ts | 12 ++++-------- packages/fs/tool-fs/tests/read-image.spec.ts | 15 +++++---------- .../fs/tool-str-replace-editor/src/index.ts | 11 ++++++++--- .../tests/tools.spec.ts | 18 ++++++++++++++++++ 16 files changed, 59 insertions(+), 43 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 527e193db9..81170e9d3d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4155,7 +4155,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ @@ -4460,7 +4460,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-str-replace-editor` - `inject`: `tools` · `fs` -- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) +- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:511`](../packages/fs/tool-str-replace-editor/src/index.ts) ```ts config-catalog /** Configuration for the string-replacement editor tool. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 76218f3d03..53619fd6e7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4157,7 +4157,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ @@ -4462,7 +4462,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-str-replace-editor` - `inject`: `tools` · `fs` -- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) +- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:511`](../packages/fs/tool-str-replace-editor/src/index.ts) ```ts config-catalog /** Configuration for the string-replacement editor tool. */ diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml index 07aac6cd05..091cae83fa 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 4f2b2f0db0646d3b zh: 9467f41336a65c48 /attachment-failures-keep-their-codes/change: - en: 1e5560d944ebe67d - zh: 235f3ec670b01ae2 + en: daa5a93cf418a352 + zh: 3b391fbb4a87f2b1 /attachment-failures-keep-their-codes/migration: - en: bceb35b3d8c551c4 - zh: e865e7752bec8d13 + en: 8599ead7a17bc7eb + zh: 1120fd02065567f5 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md index 8c416e5537..263ddf5df8 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -11,13 +11,13 @@ English | [中文](guide.zh.md) `AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: -- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the attachment code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, or `ATTACHMENT_READ_FAILED`. +- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the `AttachmentError` code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, `ATTACHMENT_READ_FAILED`, or an image admission code such as `INVALID_IMAGE` or `IMAGE_TOO_LARGE`. - A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` converts attachment failures to `ImageInputError` codes instead. `instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. ## Migration -1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the attachment codes or route them by their `ATTACHMENT_` prefix. +1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the codes of the exported `AttachmentErrorCode` type; in code, identify these failures with `isAttachmentError`. The codes do not share one prefix. 2. If code catches `HarnessError` before `AttachmentError`, move the `AttachmentError` check first wherever the two need different handling. 3. Earlier Session logs keep `UNKNOWN`; readers that compare old and new logs should treat both as the same failure class for attachment reads. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md index 3b9ab5145e..674f00805b 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -11,13 +11,13 @@ description: "AttachmentError 现在继承 HarnessError,工具失败与模型 `@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: -- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录附件错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。 +- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录 `AttachmentError` 的错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT`、`ATTACHMENT_READ_FAILED`,或 `INVALID_IMAGE`、`IMAGE_TOO_LARGE` 等图像准入错误码。 - 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 则把附件失败转换为 `ImageInputError` 错误码。 `instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 ## 迁移 -1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入这些附件错误码,或按 `ATTACHMENT_` 前缀路由。 +1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入导出类型 `AttachmentErrorCode` 中的错误码;代码中用 `isAttachmentError` 识别这些失败。这些错误码没有统一前缀。 2. 如果代码先捕获 `HarnessError` 再捕获 `AttachmentError`,在两者需要不同处理的地方把 `AttachmentError` 检查移到前面。 3. 较早的 Session 日志仍保留 `UNKNOWN`;对比新旧日志的读取方应把附件读取的这两种记录视为同一类失败。 diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 980f7a722c..4d4f9a3748 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -1058,7 +1058,7 @@ describe('Node errno classification', () => { }, }).then(() => undefined, (caught: unknown) => caught) expect(failure).toBeInstanceOf(FsError) - expect(['FS_NOT_REGULAR_FILE', 'FS_IO_ERROR']).toContain((failure as FsError).code) + expect(failure).toMatchObject({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE', cause: expect.objectContaining({ code: 'EISDIR' }) as Error }) }) it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('maps unreadable file content to FS_PERMISSION_DENIED', async () => { diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 15125f4f7e..e8c96ec826 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -30,8 +30,8 @@ en: 9bddebaf4aff0a8d zh: fea0196162e3fcd1 /deepseek-ai-dsh-tool-fs/use-this-package/failures-and-recovery: - en: 177d25a1e8f7afbd - zh: 0303980d6b2789c3 + en: 06fe9bd1d49c1d53 + zh: a8a6963daae11267 /deepseek-ai-dsh-tool-fs/understand-the-implementation: en: e30f7038c28e93f8 zh: a3868dbd90367b13 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index d7e519d577..cf3ef031df 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -83,7 +83,7 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` For `apply_patch`, syntax and preflight refusals throw with `isError: true` before any disk change. `FsError` preserves filesystem codes; `PatchToolError extends HarnessError` carries `PATCH_SYNTAX` and `PATCH_SYMLINK_SOURCE`. Once commit starts, the tool returns a value with `isError: false` even on failure or uncertainty, so known partial effects retain their diff metadata; a call cancelled during commit receives the tool registry's abort result like every other tool, and its completed changes remain visible through Review. `phase`, `cancelled`, ordered `operations` and `affectedPaths` describe the commit; each operation retains its index, kind, display paths, patch line, status, diagnostic and known changes. Full `before`/`after` text serves rendering and PTC; the Session stores the rendered result plus `meta.diffs` and `meta.stopped`. Creations store no diff, as for `write`; deletions and move sources store a removal diff only when the provider returned a bounded basis, using `newText: ''` because `FileDiff.newText` cannot be null. -`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; an `AbortError` or another `HarnessError` from the store passes through unchanged. +`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; any store failure other than an `AttachmentError`, such as an `AbortError`, another `HarnessError`, or a defect, passes through unchanged. Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include the `ToolArgsError` (`INVALID_ARGS`) messages `invalid arguments: file_path must be a non-empty string` and `invalid arguments: limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 7e81223b57..09af000f07 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" `apply_patch` 的语法与预检拒绝在磁盘变更前抛错,结果为 `isError: true`。`FsError` 保留文件系统错误码;`PatchToolError extends HarnessError` 携带 `PATCH_SYNTAX` 和 `PATCH_SYMLINK_SOURCE`。进入提交后,即使失败或结果不确定也返回 `isError: false` 的值,确保已知部分效果的 diff 元数据得以保留;提交期间被取消的调用与其他工具一样收到工具注册表的中止结果,已完成的变更仍可通过 Review 查看。`phase`、`cancelled`、有序 `operations` 与 `affectedPaths` 描述提交;每条操作保留索引、种类、展示路径、补丁行号、状态、诊断与已知变更。完整 `before`/`after` 文本供渲染和 PTC 使用;Session 保存渲染文本以及 `meta.diffs` 与 `meta.stopped`。新建与 `write` 一样不保存 diff;删除与移动源仅在提供方返回有界基线时保存删除 diff,并使用 `newText: ''`,因为 `FileDiff.newText` 不可为 null。 -`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AbortError` 或其他 `HarnessError` 原样传递。 +`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AttachmentError` 以外的失败(例如 `AbortError`、其他 `HarnessError` 或缺陷)原样传递。 失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `ToolArgsError`(`INVALID_ARGS`)消息 `invalid arguments: file_path must be a non-empty string` 与 `invalid arguments: limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 7c33681fa8..e49dad494a 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -39,8 +39,8 @@ "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", @@ -53,11 +53,11 @@ "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts index b9839110fc..a19a0b0f8d 100644 --- a/packages/fs/tool-fs/src/image-error.ts +++ b/packages/fs/tool-fs/src/image-error.ts @@ -29,7 +29,11 @@ export const IMAGE_INPUT_ERROR_CODES = [ /** One `read_image` failure code. */ export type ImageInputErrorCode = typeof IMAGE_INPUT_ERROR_CODES[number] -/** Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. */ +/** + * Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. `IMAGE_TOO_LARGE` and + * `IMAGE_TYPE_MISMATCH` share their spelling with `AttachmentError` codes, so consumers route on `name` + * and `code` together, and code-only predicates such as `isAttachmentError` do not identify this class. + */ export class ImageInputError extends HarnessError { override readonly code: ImageInputErrorCode diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 9e3e3095d5..41e3be251c 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -17,8 +17,6 @@ import { applyReadImageTool } from './read-image.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxController } from './sandbox.ts' -export { ImageInputError, type ImageInputErrorCode } from './image-error.ts' - /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 159cb2409c..9f7d714ed6 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -16,7 +16,6 @@ import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-error' import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' @@ -218,9 +217,9 @@ interface SaveFailureFacts { /** * Classify one `saveImage` rejection as the error `read_image` throws. * Attachment admission codes become {@link ImageInputError} codes and other - * `AttachmentError` codes become `IMAGE_STORE_FAILED`; any other `HarnessError` - * and an `AbortError` pass through unchanged; every other failure becomes - * `IMAGE_STORE_FAILED`. Converted errors keep the original as `cause`. + * `AttachmentError` codes become `IMAGE_STORE_FAILED`, keeping the original as + * `cause`. The attachment store reports its own failures as `AttachmentError`, + * so any other value, including cancellation, passes through unchanged. * @param error - the value `saveImage` rejected with. * @param attachments - the store whose limits the refusal names. * @param facts - the path and media-type facts the refusal names. @@ -231,10 +230,7 @@ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFa const refusal = (detail: string, code: ImageInputError['code']) => new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') - if (!(error instanceof AttachmentError)) { - if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error - return storeFailed(error instanceof Error ? error.message : String(error)) - } + if (!(error instanceof AttachmentError)) return error // Size refusals stay recoverable tool errors: an oversized image must never // enter durable history, where it would ride every later model request past // provider-side dimension rejections. diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 6c2feac7a4..bced7b466e 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -699,18 +699,13 @@ describe('image admission failures', () => { expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": ${detail}`) }) - it('wraps a non-attachment save failure as IMAGE_STORE_FAILED', async () => { - const result = await failingSave(() => new Error('unrelated infrastructure failure')) - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) - expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": the attachment store could not save the image: unrelated infrastructure failure`) + it('passes a store defect, a classified HarnessError, and an AbortError through unchanged', async () => { + const defect = await failingSave(() => new Error('unrelated infrastructure failure')) + expect(defect.isError).toBe(true) + expect(defect.error?.info).toBeUndefined() + expect(text(defect)).toBe('Error: unrelated infrastructure failure') - const nonError = await failingSave(() => 'string failure') - expect(nonError.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) - expect(text(nonError)).toContain('the attachment store could not save the image: string failure') - }) - - it('passes a classified HarnessError and an AbortError through unchanged', async () => { const fsFailure = await failingSave(() => new FsError('cannot read "x": permission denied', 'FS_PERMISSION_DENIED')) expect(fsFailure.error?.info).toEqual({ name: 'FsError', code: 'FS_PERMISSION_DENIED' }) expect(text(fsFailure)).toBe('Error: cannot read "x": permission denied') diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 099d1a6d73..fa3c5a74e6 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -248,9 +248,12 @@ async function createFile( const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) - if (await ctx.fs.stat(target, exec.signal) !== undefined) { - throw new FsError(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`, 'FS_ALREADY_EXISTS') - } + const alreadyExists = (cause?: FsError) => new FsError( + `File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`, + 'FS_ALREADY_EXISTS', + cause === undefined ? undefined : { cause }, + ) + if (await ctx.fs.stat(target, exec.signal) !== undefined) throw alreadyExists() const intent = await ctx.waterfall( 'fs/write-intent', target, @@ -267,6 +270,8 @@ async function createFile( sandboxPolicy, ) } catch (error: unknown) { + // A file created after the absence check makes guarded creation report it as unread. + if (error instanceof FsError && error.code === 'FS_NOT_OBSERVED') throw alreadyExists(error) throw policy.mapError(error, sandboxPolicy) } ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index e08c539c59..d780b856fb 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -632,6 +632,24 @@ describe('tool-str-replace-editor', () => { .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing') }) + it('reports a file created after the absence check as FS_ALREADY_EXISTS', async () => { + const { ctx, root, owner } = await setup() + const path = join(root, 'raced.txt') + await writeFile(path, 'concurrent') + const stat = ctx.fs.stat.bind(ctx.fs) + let first = true + ctx.fs.stat = async (target, signal) => { + if (!first) return stat(target, signal) + first = false + return undefined + } + const result = await call(ctx, owner, { command: 'create', path, file_text: 'mine' }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_ALREADY_EXISTS' }) + expect(text(result)).toContain(`File already exists at: ${path}`) + expect(await readFile(path, 'utf8')).toBe('concurrent') + }) + it('maps unexpected backend write failures for replace and insert', async () => { const { ctx, root, owner } = await setup() const path = join(root, 'backend-error.txt') From e80eb062a14c838aeaee98e34537f8e5e9bf6879 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 16:19:12 +0800 Subject: [PATCH 026/149] fix(lsp): derive query capabilities from the handshake result The instance's ready promise now resolves to the negotiated server capabilities, so a query can no longer observe an instance without them; LSP_NOT_INITIALIZED and its private-state test are removed. The LSP failures page qualifies the teardown combination (uncoded query failures surface as AggregateError without error.info) and the stderr tail of LSP_SERVER_EXITED, and LspQueryTeardownError documents that it does not keep the query failure's class. --- docs/subsystems/lsp.i18n.yaml | 4 ++-- docs/subsystems/lsp.md | 5 ++--- docs/subsystems/lsp.zh.md | 5 ++--- packages/lsp/lsp-stdio/README.i18n.yaml | 4 ++-- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/package.json | 4 ++-- packages/lsp/lsp-stdio/src/index.ts | 3 ++- packages/lsp/lsp-stdio/src/instance.ts | 15 ++++++--------- packages/lsp/lsp-stdio/tests/instance.spec.ts | 11 ----------- 10 files changed, 20 insertions(+), 35 deletions(-) diff --git a/docs/subsystems/lsp.i18n.yaml b/docs/subsystems/lsp.i18n.yaml index 6d21e0fc70..a89df73725 100644 --- a/docs/subsystems/lsp.i18n.yaml +++ b/docs/subsystems/lsp.i18n.yaml @@ -18,5 +18,5 @@ en: 9977196522f2d7e5 zh: f971ba2c60beda68 /lsp-navigation/failures: - en: 192bd5699c1a23af - zh: f221bff65286231a + en: 50959870fd65acce + zh: 9f9e0c2b04572235 diff --git a/docs/subsystems/lsp.md b/docs/subsystems/lsp.md index 275ba3b236..647fd2920b 100644 --- a/docs/subsystems/lsp.md +++ b/docs/subsystems/lsp.md @@ -167,7 +167,7 @@ interface LspService { ## Failures -Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message. +Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A coded query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message; a query failure without a code, such as a cancellation, surfaces with the teardown failure as an `AggregateError` without `error.info`. | Code | Raised by | Meaning | Consumer action | |---|---|---|---| @@ -181,11 +181,10 @@ Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` an | `LSP_FILE_OUTSIDE_WORKSPACE` | Stdio provider | The source resolves outside the canonical workspace | Query a file inside the workspace | | `LSP_UNSUPPORTED_ENCODING` | Stdio provider | The server negotiated a position encoding other than UTF-16 | Use a server that supports UTF-16 | | `LSP_SPAWN_FAILED` | Stdio provider | The subprocess provider failed to launch or run the server; a provider `HarnessError` keeps its own `name` and `code` instead | Fix the server command or installation | -| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | +| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; after an exit, the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | | `LSP_PROTOCOL_ERROR` | Stdio provider | The server's output broke base-protocol framing or JSON | Report the server defect | | `LSP_MESSAGE_TOO_LARGE` | Stdio provider | A server message exceeded `maxMessageBytes` | Narrow the query or raise `maxMessageBytes` | | `LSP_SERVER_ERROR` | Stdio provider | The server answered with a JSON-RPC error; the message carries its numeric code and text | Act on the server's message | -| `LSP_NOT_INITIALIZED` | Stdio provider | A query found the server instance without negotiated capabilities after its handshake succeeded; this is inconsistent internal state, not a server condition | Report the defect | After a transport failure (`LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`), the stdio provider retries the read-only query once on a fresh process when the failed process tears down cleanly. diff --git a/docs/subsystems/lsp.zh.md b/docs/subsystems/lsp.zh.md index 65612a8eac..7a6ef8d3c6 100644 --- a/docs/subsystems/lsp.zh.md +++ b/docs/subsystems/lsp.zh.md @@ -167,7 +167,7 @@ interface LspService { ## 失败 -`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中。 +`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果带错误码的查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中;不带错误码的查询失败(例如取消)与拆除失败一起以 `AggregateError` 呈现,不带 `error.info`。 | 错误码 | 抛出方 | 含义 | 消费方操作 | |---|---|---|---| @@ -181,11 +181,10 @@ interface LspService { | `LSP_FILE_OUTSIDE_WORKSPACE` | stdio 提供方 | 源文件解析到规范工作区之外 | 查询工作区内的文件 | | `LSP_UNSUPPORTED_ENCODING` | stdio 提供方 | 服务器协商了 UTF-16 以外的位置编码 | 使用支持 UTF-16 的服务器 | | `LSP_SPAWN_FAILED` | stdio 提供方 | 子进程提供方未能启动或运行服务器;提供方的 `HarnessError` 则保留其自身的 `name` 和 `code` | 修正服务器命令或安装 | -| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | +| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;进程退出时消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | | `LSP_PROTOCOL_ERROR` | stdio 提供方 | 服务器输出破坏了基础协议分帧或 JSON | 报告服务器缺陷 | | `LSP_MESSAGE_TOO_LARGE` | stdio 提供方 | 某条服务器消息超过 `maxMessageBytes` | 缩小查询范围或调大 `maxMessageBytes` | | `LSP_SERVER_ERROR` | stdio 提供方 | 服务器以 JSON-RPC 错误应答;消息携带其数字错误码与文本 | 按服务器消息处理 | -| `LSP_NOT_INITIALIZED` | stdio 提供方 | 握手已成功,但查询时服务器实例没有已协商的能力;这是内部状态不一致,不是服务器的情况 | 报告该缺陷 | 传输失败(`LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`)后,如果失败的进程干净地完成拆除,stdio 提供方会在新进程上把只读查询重试一次。 diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 369a475ea4..f4f7a2db0e 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41363baf8fdba020 zh: d1a6193b1aa813ba /deepseek-ai-dsh-lsp-stdio/use-this-package/observable-success-and-failures: - en: 27766a0ecd0f70fa - zh: 532bc93a8ce5e701 + en: 9b947af73dbe2305 + zh: e40b163f476deaf9 /deepseek-ai-dsh-lsp-stdio/use-this-package/security-boundary: en: f1f7fef771bcaa6d zh: f11dcf771a6fecfc diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 8f30301f48..418e44520b 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -68,7 +68,7 @@ On the first query for a workspace, the provider launches one server process for ### Observable success and failures -A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`; a query that finds the instance without negotiated capabilities after a successful handshake reports `LSP_NOT_INITIALIZED`, which marks a provider defect. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. +A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. ### Security boundary diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index f54eda31c1..e16e912cef 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 可观察的成功与失败 -成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`;握手成功后查询却发现实例没有已协商的能力时报告 `LSP_NOT_INITIALIZED`,表示提供方存在缺陷。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 +成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 ### 安全边界 diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 494db76732..4afa5b8d0d 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -32,9 +32,9 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", @@ -46,9 +46,9 @@ "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index eee4962849..7c42effa43 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -119,7 +119,8 @@ function throwTeardownFailures(results: readonly PromiseSettledResult[], m /** * A coded query failure whose instance teardown also failed. It keeps the query failure's `name` - * and `code` so tool results route on the query outcome; `cause` aggregates both failures. + * and `code` so tool results route on the query outcome; `cause` aggregates both failures. It does + * not keep the query failure's class, so route on `name` and `code`, not `instanceof`. */ class LspQueryTeardownError extends HarnessError { constructor(query: HarnessError, teardown: unknown, cause: AggregateError) { diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index f72a892fd5..30c5225468 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -45,7 +45,6 @@ export interface InstanceSpec extends ConnectionSpec { */ export class LspInstance { private readonly connection: LspConnection - private capabilities: WireServerCapabilities | undefined /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ private queue: Promise = Promise.resolve() private disposed = false @@ -53,8 +52,8 @@ export class LspInstance { private teardownPromise: Promise | undefined /** Set once the process closes, so the pool can synchronously skip a dead instance. */ private processClosed = false - /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ - private readonly ready: Promise + /** The server's negotiated capabilities once `initialize` succeeds; a failed handshake rejects every query. */ + private readonly ready: Promise /** * @param spec - the launch, initialize, and teardown parameters. @@ -109,7 +108,7 @@ export class LspInstance { return run } - private async initialize(): Promise { + private async initialize(): Promise { const initializeResult = await this.connection.request('initialize', { // A subprocess provider may run in another PID namespace or machine; // the host PID would let the server monitor an unrelated process. @@ -122,8 +121,8 @@ export class LspInstance { const capabilities = initializeCapabilities(initializeResult) // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) - this.capabilities = capabilities await this.connection.notify('initialized', {}) + return capabilities } private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { @@ -134,17 +133,15 @@ export class LspInstance { // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 // negotiation, malformed result) without the process exiting — tear the instance down so a // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. + let capabilities: WireServerCapabilities try { - await abortable(this.ready, signal) + capabilities = await abortable(this.ready, signal) } catch (error) { if (!this.dead) { await this.awaitTeardownAttempt() } throw error } - const capabilities = this.capabilities - // `ready` resolves only after capabilities are set; reaching this means instance state is inconsistent. - if (capabilities === undefined) throw new LspError('LSP instance is not initialized', 'LSP_NOT_INITIALIZED') if (!supportsOperation(capabilities, request.operation)) { throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index 10e0a783b1..e34366042d 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -281,17 +281,6 @@ describe('LspInstance query and abort', () => { }) }) - it('reports inconsistent instance state as LSP_NOT_INITIALIZED', async () => { - const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) - Reflect.set(instance, 'capabilities', undefined) - await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ - name: 'LspError', - code: 'LSP_NOT_INITIALIZED', - message: 'LSP instance is not initialized', - }) - }) - it('propagates a server error response even when a signal is supplied (not an abort)', async () => { // A live signal is passed, but the request fails for a server reason; the catch must rethrow // without treating it as an abort. From 646e46e0afeb47bce742a1eae4e473b8d6f51a99 Mon Sep 17 00:00:00 2001 From: dsh-ci-bot Date: Fri, 6 Nov 2026 16:24:29 +0800 Subject: [PATCH 027/149] test(ui-theme): scope the package-scan spec to the client face packageFiles() reports '/'-separated paths on every platform, so the fixture's temporary root must be normalized before it forms the expectation; otherwise the Windows coverage lane fails. The spec also belongs to the client aggregate, because an unsuffixed spec file under packages/client is compiled by both aggregates. --- ...stylesheet-scan.spec.ts => stylesheet-scan.client.spec.ts} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename packages/client/ui-theme/tests/{stylesheet-scan.spec.ts => stylesheet-scan.client.spec.ts} (82%) diff --git a/packages/client/ui-theme/tests/stylesheet-scan.spec.ts b/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts similarity index 82% rename from packages/client/ui-theme/tests/stylesheet-scan.spec.ts rename to packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts index 1614ee55a7..312ee01382 100644 --- a/packages/client/ui-theme/tests/stylesheet-scan.spec.ts +++ b/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts @@ -19,5 +19,7 @@ it('walks shipped package files and skips dot directories holding transient root mkdirSync(join(root, '.typert-worker-failure-probe')) writeFileSync(join(root, '.typert-worker-failure-probe', 'package.json'), '{}') - expect(packageFiles(name => name === 'package.json', root)).toEqual([`${root}/pkg/package.json`]) + // The helper reports `/`-separated paths on every platform, so the Windows + // temporary root's separators must not reach the expectation. + expect(packageFiles(name => name === 'package.json', root)).toEqual([`${root.replaceAll('\\', '/')}/pkg/package.json`]) }) From 4ef46e21b1ca0af4eab649670ffd0929d8124b11 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:41:02 +0800 Subject: [PATCH 028/149] feat(subprocess): add SubprocessError and make Win32Error a HarnessError The subprocess Service Definition exports SubprocessError with a closed SUBPROCESS_* code list, rebases SubprocessExecutableNotFoundError on it with the same name, and adds subprocessSpawnFailure() so consumers that report a spawn rejection classify ENOENT and other platform errors with the original as cause. subprocess-local codes empty argv, Windows Job runner failures, runner IPC failures, and an unloadable node-pty, and names its pre-aborted spawn error AbortError so the tool registry classifies it as cancellation. Win32Error now extends HarnessError with code WIN32_ERROR, keeping its name, api, and win32Code fields. Refs #7869 --- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 19 +++- .../subprocess/subprocess-local/src/spawn.ts | 9 +- .../subprocess-local/src/windows-job.ts | 36 ++++++-- .../subprocess-local/tests/local.spec.ts | 30 +++++++ .../subprocess-local/tests/spawn.spec.ts | 11 +-- .../tests/windows-job.spec.ts | 14 ++- .../subprocess/subprocess/README.i18n.yaml | 8 +- packages/subprocess/subprocess/README.md | 3 +- packages/subprocess/subprocess/README.zh.md | 3 +- packages/subprocess/subprocess/package.json | 6 +- packages/subprocess/subprocess/src/error.ts | 87 +++++++++++++++++++ packages/subprocess/subprocess/src/index.ts | 19 ++-- .../subprocess/subprocess/tests/error.spec.ts | 48 ++++++++++ packages/subprocess/subprocess/tsconfig.json | 3 + .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- .../subprocess/win32-process/package.json | 6 +- .../subprocess/win32-process/src/errors.ts | 14 ++- .../win32-process/tests/ffi.spec.ts | 5 ++ .../subprocess/win32-process/tsconfig.json | 3 + pnpm-lock.yaml | 6 ++ 25 files changed, 292 insertions(+), 54 deletions(-) create mode 100644 packages/subprocess/subprocess/src/error.ts create mode 100644 packages/subprocess/subprocess/tests/error.spec.ts diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index e46cbba8b2..b81bef5b96 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: cd0b8a5dc61335a5 zh: ac5077a9ac277141 /deepseek-ai-dsh-subprocess-local/use-this-package/what-can-go-wrong: - en: 1e1ce532b004c92b - zh: ca7274b7a9207a5d + en: 8320b322423ed5a4 + zh: a32e769d2a351694 /deepseek-ai-dsh-subprocess-local/understand-the-implementation: en: 531a48766c666ea0 zh: b40df533a2fd7244 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 6d943b32e6..6ede37c8d1 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -69,7 +69,7 @@ Linux ordinary and terminal cancellation preserves the observed termination sign ### What can go wrong -An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below. +An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began: a platform spawn error stays the original Node error, while a Windows Job runner that fails after it started or breaks its IPC channel rejects with `SubprocessError` `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`. A terminal request on a host where node-pty cannot load throws `SUBPROCESS_PTY_UNAVAILABLE`. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below. ----- diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index d82d971cdf..0240ea3c50 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -69,7 +69,7 @@ Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取 ### 可能出错的地方 -无法解析的可执行文件会明确报出稳定错误。当 spawn 或提供方故障使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。 +无法解析的可执行文件会明确报出稳定错误。当 spawn 或提供方故障使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行:平台 spawn 错误保持为原始 Node 错误,而 Windows Job runner 启动后失败或其 IPC 通道损坏时以 `SubprocessError` 的 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED` reject。在无法加载 node-pty 的主机上请求终端会抛出 `SUBPROCESS_PTY_UNAVAILABLE`。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。 ----- diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index be24927093..e9960d0f0b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -17,7 +17,7 @@ import { Context } from '@deepseek-ai/cordis' import type * as NodePty from 'node-pty' import type { IPtyForkOptions, IWindowsPtyForkOptions } from 'node-pty' import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require' -import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessError, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec, @@ -54,6 +54,19 @@ import { prepareShellActivity } from './shell-activity.ts' const requireNodePty = createLazyRequire('node-pty', import.meta.url) +/** Load node-pty, reporting a missing or unloadable native module as `SUBPROCESS_PTY_UNAVAILABLE`. */ +function loadNodePty(): typeof NodePty { + try { + return requireNodePty() + } catch (error) { + throw new SubprocessError( + `subprocess-local: terminals are unavailable because node-pty cannot be loaded: ${String(error)}`, + 'SUBPROCESS_PTY_UNAVAILABLE', + { cause: error }, + ) + } +} + /** * Local subprocess service: platform-selected managed ranges, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill @@ -290,7 +303,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { const file = spec.argv[0] if (file === undefined || file.length === 0) { - throw new Error('subprocess-local: terminal argv must contain a program') + throw new SubprocessError('subprocess-local: terminal argv must contain a program', 'SUBPROCESS_INVALID_ARGV') } spec.signal?.throwIfAborted() const inspector = this.terminalInspector ?? createProcessInspector() @@ -319,7 +332,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ? undefined : prepareLinuxTerminalScope(launch, { ...activity?.env ?? env, PWD: spec.cwd, TERM: spec.terminalType }, linuxScope.literalArgv) if (scope !== undefined) { options.cwd = scope.cwd; options.env = scope.env } - terminal = requireNodePty().spawn( + terminal = loadNodePty().spawn( scope?.command ?? file, scope?.args ?? [...launch.argv.slice(1)], options, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8da083e6f5..e3a47ee818 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -11,7 +11,7 @@ import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process' import type { Readable } from 'node:stream' import { setTimeout as sleepMs } from 'node:timers/promises' -import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, SubprocessError } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessCollect, @@ -172,11 +172,14 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { } catch { // Arbitrary caller-owned reasons cannot escape the stable Error boundary. } - throw new Error(`aborted before spawn: ${reason}`) + // Named as a cancellation so the tool registry classifies it as the caller's abort. + const error = new Error(`aborted before spawn: ${reason}`) + error.name = 'AbortError' + throw error } const [program] = spec.argv if (program === undefined || program.length === 0) { - throw new Error('invalid argv: expected a non-empty program name at argv[0]') + throw new SubprocessError('invalid argv: expected a non-empty program name at argv[0]', 'SUBPROCESS_INVALID_ARGV') } } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 57f7c46afc..34bfff74d5 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -5,6 +5,7 @@ import { spawn } from 'node:child_process' import { closeSync, openSync } from 'node:fs' import { devNull } from 'node:os' import type { Readable, Writable } from 'node:stream' +import { SubprocessError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { loadWin32ProcessBindings, @@ -26,6 +27,16 @@ import { } from './runner-launch.ts' import type { RunnerInvocation } from './runner-launch.ts' +/** Classify a failure on the private runner channel, keeping an already coded failure. */ +function runnerIpcFailure(error: unknown): SubprocessError { + if (error instanceof SubprocessError) return error + return new SubprocessError( + `subprocess-local: Windows runner IPC failed: ${String(error)}`, + 'SUBPROCESS_RUNNER_IPC_FAILED', + { cause: error }, + ) +} + /** Test seams for runner launch and dynamic capability checks. */ export interface WindowsJobInternals { spawn?: typeof spawn @@ -171,8 +182,9 @@ export function launchWindowsJob( const status = signal !== null ? `signal ${signal}` : exitCode === null ? 'without an exit status' : `exit code ${String(exitCode)}` - failInfrastructure(new Error( + failInfrastructure(new SubprocessError( `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`, + 'SUBPROCESS_RUNNER_FAILED', )) } @@ -184,7 +196,7 @@ export function launchWindowsJob( ) child.on('message', (value: unknown) => { if (directResultType !== undefined) { - const error = new Error('subprocess-local: Windows runner emitted more than one direct result') + const error = new SubprocessError('subprocess-local: Windows runner emitted more than one direct result', 'SUBPROCESS_RUNNER_IPC_FAILED') failInfrastructure(error) owner.terminateForHostExit() return @@ -193,7 +205,11 @@ export function launchWindowsJob( try { result = parseWindowsRunnerResult(value) } catch (error) { - failInfrastructure(error) + failInfrastructure(new SubprocessError( + `subprocess-local: Windows runner emitted a malformed result: ${String(error)}`, + 'SUBPROCESS_RUNNER_IPC_FAILED', + { cause: error }, + )) owner.terminateForHostExit() return } @@ -208,14 +224,16 @@ export function launchWindowsJob( child.once('spawn', () => { runnerSpawned = true try { - if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel') + if (child.send === undefined) { + throw new SubprocessError('subprocess-local: Windows runner has no IPC channel', 'SUBPROCESS_RUNNER_IPC_FAILED') + } child.send({ type: 'start', cwd: spec.cwd, env: targetEnv, ...spec.stdio.control === undefined ? {} : { control: spec.stdio.control } }, (error) => { if (error === null) return - failInfrastructure(error) + failInfrastructure(runnerIpcFailure(error)) owner.terminateForHostExit() }) } catch (error) { - failInfrastructure(error) + failInfrastructure(runnerIpcFailure(error)) owner.terminateForHostExit() } }) @@ -225,7 +243,11 @@ export function launchWindowsJob( rangeExit.resolve() return } - failInfrastructure(error) + failInfrastructure(new SubprocessError( + `subprocess-local: Windows Job runner failed after it started: ${String(error)}`, + 'SUBPROCESS_RUNNER_FAILED', + { cause: error }, + )) }) child.once('exit', (exitCode, signal) => { runnerExit = { exitCode, signal } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 2c4c0b2f67..3229c0837f 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -490,6 +490,36 @@ describe('LocalSubprocessRuntime', () => { } }) + it('reports an unloadable node-pty as SUBPROCESS_PTY_UNAVAILABLE', async () => { + const loadFailure = new Error('Cannot find module node-pty') + vi.resetModules() + mockWin32ForIsolatedRuntime() + vi.doMock('@deepseek-ai/dsh-lazy-require', () => ({ + createLazyRequire: () => () => { throw loadFailure }, + })) + vi.doMock('../src/linux-scope.ts', async importOriginal => ({ + ...await importOriginal(), + probeLinuxNative: () => undefined, + })) + try { + const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') + const ctx = new Context() + const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) + await expect(ctx.subprocess.spawnTerminal({ + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, + })).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_PTY_UNAVAILABLE', cause: loadFailure }) + await expect(ctx.subprocess.spawnTerminal({ + argv: [''], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, + })).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' }) + await fiber.dispose() + } finally { + unmockLazyRequireForIsolatedRuntime() + vi.doUnmock('../src/linux-scope.ts') + unmockWin32ForIsolatedRuntime() + vi.resetModules() + } + }) + it('allocates Windows terminals through the console host node-pty ships', async () => { const exitListeners: ((event: { exitCode: number; signal?: number }) => void)[] = [] const terminal = { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 27e5ab93ac..1ba8c38dc5 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -352,7 +352,7 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('throws a stable Error when already aborted before spawn', () => { + it('throws a stable AbortError when already aborted before spawn', () => { for (const [reason, message] of [ ['too late', 'aborted before spawn: too late'], [null, 'aborted before spawn: aborted'], @@ -360,7 +360,7 @@ describe('spawnSubprocess', () => { const controller = new AbortController() controller.abort(reason) expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) - .toThrow(new Error(message)) + .toThrow(expect.objectContaining({ name: 'AbortError', message })) } const controller = new AbortController() @@ -370,7 +370,7 @@ describe('spawnSubprocess', () => { }, }) expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) - .toThrow(new Error('aborted before spawn: aborted')) + .toThrow(expect.objectContaining({ name: 'AbortError', message: 'aborted before spawn: aborted' })) }) it('rejects with a spawn error for a nonexistent cwd', async () => { @@ -1446,7 +1446,8 @@ describe('coverage seams 2', () => { describe('argv validation', () => { it('rejects an empty argv before spawning', () => { - expect(() => { validateSubprocessSpec({ ...spec('true'), argv: [] }) }).toThrow(/non-empty program name/) + expect(() => { validateSubprocessSpec({ ...spec('true'), argv: [] }) }) + .toThrow(expect.objectContaining({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' })) }) it('rejects an empty program name before spawning', () => { @@ -1468,7 +1469,7 @@ describe('abort edge cases', () => { removeEventListener() {}, } as unknown as AbortSignal expect(() => { validateSubprocessSpec(spec('echo hi', { signal: bare })) }) - .toThrow(new Error('aborted before spawn: aborted')) + .toThrow(expect.objectContaining({ name: 'AbortError', message: 'aborted before spawn: aborted' })) }) it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index c2f30a682b..869f7b89b0 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -350,6 +350,7 @@ describe('Windows parent runner contract', () => { statusless.child.connected = false statusless.child.exit( null, null) await expect(statusless.result.direct).rejects.toThrow('without an exit status') + await expect(statusless.result.direct).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_FAILED' }) }) it('fails closed on malformed/duplicate result, runner spawn error, and start-send error', async () => { @@ -357,6 +358,7 @@ describe('Windows parent runner contract', () => { malformed.child.emit('message', { type: 'target-exit', exitCode: -1 }) expect(malformed.child.killed).toEqual(['SIGKILL']) await expect(malformed.result.direct).rejects.toThrow('invalid target-exit') + await expect(malformed.result.direct).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED' }) await expect(malformed.result.owner.waitForExit()).rejects.toThrow('invalid target-exit') const duplicate = launch() @@ -364,6 +366,7 @@ describe('Windows parent runner contract', () => { duplicate.child.emit('message', { type: 'target-exit', exitCode: 0 }) await expect(duplicate.result.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(duplicate.result.owner.waitForExit()).rejects.toThrow('more than one direct result') + await expect(duplicate.result.owner.waitForExit()).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED' }) const errored = launch(new FakeChild(), spec, false) const spawnError = new Error('runner executable missing') @@ -375,8 +378,9 @@ describe('Windows parent runner contract', () => { const postSpawnError = launch() const infrastructureError = new Error('runner failed after spawn') postSpawnError.child.emit('error', infrastructureError) - await expect(postSpawnError.result.direct).rejects.toBe(infrastructureError) - await expect(postSpawnError.result.owner.waitForExit()).rejects.toBe(infrastructureError) + const runnerFailure = { name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_FAILED', cause: infrastructureError } + await expect(postSpawnError.result.direct).rejects.toMatchObject(runnerFailure) + await expect(postSpawnError.result.owner.waitForExit()).rejects.toMatchObject(runnerFailure) const sendFailedChild = new FakeChild() sendFailedChild.sendError = new Error('IPC send failed') @@ -389,14 +393,16 @@ describe('Windows parent runner contract', () => { Object.defineProperty(noIpc, 'send', { value: undefined }) const noIpcResult = launch(noIpc).result await expect(noIpcResult.direct).rejects.toThrow('has no IPC channel') + await expect(noIpcResult.direct).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED' }) await expect(noIpcResult.owner.waitForExit()).rejects.toThrow('has no IPC channel') const nonError = new FakeChild() nonError.throwOnSendCall = 1 nonError.sendThrown = 'start send failed' const nonErrorResult = launch(nonError).result - await expect(nonErrorResult.direct).rejects.toBe('start send failed') - await expect(nonErrorResult.owner.waitForExit()).rejects.toBe('start send failed') + const ipcFailure = { name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED', cause: 'start send failed' } + await expect(nonErrorResult.direct).rejects.toMatchObject(ipcFailure) + await expect(nonErrorResult.owner.waitForExit()).rejects.toMatchObject(ipcFailure) }) it('fails infrastructure and kills the runner when termination delivery fails', async () => { diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index f76390eb90..762893c5a0 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -39,8 +39,8 @@ en: 4d4cbb311575d071 zh: 7be2d265adb661bc /deepseek-ai-dsh-subprocess/use-this-package/what-can-go-wrong: - en: 51f2f43087a1ed18 - zh: 6d2ebb099b707df3 + en: 718494d6530da700 + zh: a41b7d48a9d12178 /deepseek-ai-dsh-subprocess/understand-the-implementation: en: 25cd344c959cfd9e zh: 045880776ab82d1e @@ -48,8 +48,8 @@ en: 07201ec2256c25f6 zh: 046109cfa92194f3 /deepseek-ai-dsh-subprocess/understand-the-implementation/source-map: - en: 85e3d2109f49356d - zh: 071ccfef37e8e33f + en: e56d6825d1b990a5 + zh: c70cabda3bfcd2ab /deepseek-ai-dsh-subprocess/understand-the-implementation/data-model-and-flow: en: 9998dc0a990fbcf1 zh: ba179617f60cb9bc diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 4839a508e5..337afd9fba 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -81,7 +81,7 @@ Children never inherit the harness's ambient secrets: credential-shaped names an ### What can go wrong -An executable that cannot be resolved fails loudly with a stable error. A spawn that never starts rejects `done`; there is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. +Coded failures are `SubprocessError` instances whose `code` is one of `SUBPROCESS_ERROR_CODES`: an empty argv is `SUBPROCESS_INVALID_ARGV`, an executable lookup miss is `SubprocessExecutableNotFoundError` (`SUBPROCESS_EXECUTABLE_NOT_FOUND`), a provider's private runner that fails or breaks its channel is `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`, and an unloadable terminal implementation is `SUBPROCESS_PTY_UNAVAILABLE`. A spawn requested on an already aborted signal throws an error named `AbortError`. A spawn that never starts rejects `done` with the platform's original error, so consumers can inspect its errno fields; a consumer that reports the rejection as its own failure converts it with `subprocessSpawnFailure()`, which yields `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` otherwise. There is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. ----- @@ -103,6 +103,7 @@ The seam is built on one separation: the service owns process coordinates and li |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `SubprocessRuntime`, `ctx.subprocess` registration, the shared `scrubbedParentEnv` scrub | | [`src/types.ts`](src/types.ts) | Vocabulary: spawn spec, stdio modes, handles, readers, outcomes, `DSH_*` namespace | +| [`src/error.ts`](src/error.ts) | `SubprocessError`, its closed code list, and spawn-rejection classification | ### Data model and flow diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 843dcdb568..da111f517a 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -81,7 +81,7 @@ const output = handle.collected.stdout?.readFrom(0) ### 可能出错的地方 -无法解析可执行文件时,服务会明确报出稳定的错误。从未启动成功的 spawn 会让 `done` reject;从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 +带 code 的失败都是 `SubprocessError`,其 `code` 属于 `SUBPROCESS_ERROR_CODES`:argv 为空是 `SUBPROCESS_INVALID_ARGV`,可执行文件查找未命中是 `SubprocessExecutableNotFoundError`(`SUBPROCESS_EXECUTABLE_NOT_FOUND`),提供方私有 runner 失败或通道损坏是 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED`,终端实现无法加载是 `SUBPROCESS_PTY_UNAVAILABLE`。在已 abort 的 signal 上请求 spawn 会抛出名为 `AbortError` 的错误。从未启动成功的 spawn 会让 `done` 以平台原始错误 reject,便于消费者检查 errno 字段;把该 reject 报告为自身失败的消费者用 `subprocessSpawnFailure()` 转换,`ENOENT` 得到 `SUBPROCESS_SPAWN_NOT_FOUND`,其余得到 `SUBPROCESS_SPAWN_FAILED`。从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 ----- @@ -103,6 +103,7 @@ const output = handle.collected.stdout?.readFrom(0) |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SubprocessRuntime`、`ctx.subprocess` 注册、共享的 `scrubbedParentEnv` 清除 | | [`src/types.ts`](src/types.ts) | 词汇:spawn spec、stdio 模式、句柄、读取器、结果、`DSH_*` 命名空间 | +| [`src/error.ts`](src/error.ts) | `SubprocessError`、其封闭 code 列表与 spawn reject 分类 | ### 数据模型与流程 diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 9fb7353455..4d4968fbfb 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -36,11 +36,13 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-http-proxy": "workspace:*" + "@deepseek-ai/dsh-http-proxy": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*", - "@deepseek-ai/dsh-launch-environment": "workspace:*" + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" } } diff --git a/packages/subprocess/subprocess/src/error.ts b/packages/subprocess/subprocess/src/error.ts new file mode 100644 index 0000000000..064dc494ae --- /dev/null +++ b/packages/subprocess/subprocess/src/error.ts @@ -0,0 +1,87 @@ +/** Machine-routable subprocess failures shared by every `ctx.subprocess` provider and its consumers. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every {@link SubprocessError} code: + * - `SUBPROCESS_INVALID_ARGV` — the request names no program; the caller fixes its argv. + * - `SUBPROCESS_EXECUTABLE_NOT_FOUND` — executable lookup found no executable file. + * - `SUBPROCESS_SPAWN_NOT_FOUND` — the platform reported `ENOENT` for the program or the working directory. + * - `SUBPROCESS_SPAWN_FAILED` — the platform refused the spawn for another reason; `cause` holds the original error. + * - `SUBPROCESS_RUNNER_FAILED` — the provider's private runner exited or reported a result before proving the process range empty. + * - `SUBPROCESS_RUNNER_IPC_FAILED` — the provider's private runner channel was missing or carried a malformed message. + * - `SUBPROCESS_PTY_UNAVAILABLE` — the host cannot load its terminal implementation. + */ +export const SUBPROCESS_ERROR_CODES = [ + 'SUBPROCESS_INVALID_ARGV', + 'SUBPROCESS_EXECUTABLE_NOT_FOUND', + 'SUBPROCESS_SPAWN_NOT_FOUND', + 'SUBPROCESS_SPAWN_FAILED', + 'SUBPROCESS_RUNNER_FAILED', + 'SUBPROCESS_RUNNER_IPC_FAILED', + 'SUBPROCESS_PTY_UNAVAILABLE', +] as const + +/** One code from {@link SUBPROCESS_ERROR_CODES}. */ +export type SubprocessErrorCode = typeof SUBPROCESS_ERROR_CODES[number] + +/** Subprocess failure carrying a stable {@link SubprocessErrorCode}. */ +export class SubprocessError extends HarnessError { + override readonly code: SubprocessErrorCode + + /** + * @param message - names the process operation and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining; foreign errno and Win32 codes stay there and in `message`. + */ + constructor(message: string, code: SubprocessErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubprocessError' + this.code = code + } +} + +/** Executable lookup completed without finding an executable file; `code` is `SUBPROCESS_EXECUTABLE_NOT_FOUND`. */ +export class SubprocessExecutableNotFoundError extends SubprocessError { + /** + * @param message - provider-specific lookup diagnostic. + * @param options - original provider failure, when available. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, 'SUBPROCESS_EXECUTABLE_NOT_FOUND', options) + this.name = 'SubprocessExecutableNotFoundError' + } +} + +/** + * Classify a spawn rejection for a consumer that reports it as its own + * failure. Providers reject with the platform's original error so that + * consumers can inspect errno fields; the consumer that turns the rejection + * into a tool or service failure converts it here and keeps the original as + * `cause`. + * @param error - the provider's rejection value. + * @param program - the program the consumer asked to spawn, named in the message. + * @returns `error` unchanged when it is a `HarnessError` or a cancellation + * (`name === 'AbortError'`); otherwise a {@link SubprocessError} with + * `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` for + * any other value. + */ +export function subprocessSpawnFailure(error: unknown, program: string): unknown { + if (error instanceof HarnessError) return error + if (error instanceof Error && error.name === 'AbortError') return error + const errno = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined + let detail = 'unprintable failure' + try { + detail = error instanceof Error ? error.message : String(error) + } catch { + // A provider-owned rejection whose conversion throws still yields a coded failure. + } + if (errno === 'ENOENT') { + return new SubprocessError( + `cannot start ${JSON.stringify(program)}: the program or the working directory does not exist (${detail})`, + 'SUBPROCESS_SPAWN_NOT_FOUND', + { cause: error }, + ) + } + return new SubprocessError(`cannot start ${JSON.stringify(program)}: ${detail}`, 'SUBPROCESS_SPAWN_FAILED', { cause: error }) +} diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 6dbbc62719..b134ffb6c1 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -15,6 +15,13 @@ import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' import type { SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' +export { + SUBPROCESS_ERROR_CODES, + SubprocessError, + SubprocessExecutableNotFoundError, + subprocessSpawnFailure, + type SubprocessErrorCode, +} from './error.ts' export type { CollectedOutput, DshEnvironment, @@ -163,15 +170,3 @@ export abstract class SubprocessRuntime extends Service { } export default SubprocessRuntime - -/** Executable lookup completed without finding an executable file. */ -export class SubprocessExecutableNotFoundError extends Error { - /** - * @param message - provider-specific lookup diagnostic. - * @param options - original provider failure, when available. - */ - constructor(message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'SubprocessExecutableNotFoundError' - } -} diff --git a/packages/subprocess/subprocess/tests/error.spec.ts b/packages/subprocess/subprocess/tests/error.spec.ts new file mode 100644 index 0000000000..ecdcb89a98 --- /dev/null +++ b/packages/subprocess/subprocess/tests/error.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { + SUBPROCESS_ERROR_CODES, + SubprocessError, + SubprocessExecutableNotFoundError, + subprocessSpawnFailure, +} from '../src/index.ts' + +describe('SubprocessError', () => { + it('carries a literal name and a code from the closed list', () => { + const cause = new Error('low level') + const error = new SubprocessError('runner failed', 'SUBPROCESS_RUNNER_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_FAILED', cause }) + expect(SUBPROCESS_ERROR_CODES).toContain(error.code) + }) + + it('keeps the executable lookup miss as its own subclass', () => { + const error = new SubprocessExecutableNotFoundError('not on PATH') + expect(error).toBeInstanceOf(SubprocessError) + expect(error).toMatchObject({ name: 'SubprocessExecutableNotFoundError', code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' }) + }) +}) + +describe('subprocessSpawnFailure', () => { + it('classifies ENOENT and other spawn rejections, keeping the original as cause', () => { + const missing = Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT', syscall: 'spawn pwsh' }) + expect(subprocessSpawnFailure(missing, 'pwsh')).toMatchObject({ + name: 'SubprocessError', + code: 'SUBPROCESS_SPAWN_NOT_FOUND', + cause: missing, + message: 'cannot start "pwsh": the program or the working directory does not exist (spawn pwsh ENOENT)', + }) + const denied = Object.assign(new Error('spawn bash EACCES'), { code: 'EACCES' }) + expect(subprocessSpawnFailure(denied, 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', cause: denied }) + expect(subprocessSpawnFailure('raw', 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', message: 'cannot start "bash": raw' }) + const unprintable = { [Symbol.toPrimitive]() { throw new Error('no') } } + expect(subprocessSpawnFailure(unprintable, 'bash')).toMatchObject({ message: 'cannot start "bash": unprintable failure' }) + }) + + it('returns coded errors and cancellations unchanged', () => { + const coded = new HarnessError('sandbox unavailable', 'SANDBOX_UNAVAILABLE') + expect(subprocessSpawnFailure(coded, 'bash')).toBe(coded) + const abort = new DOMException('aborted', 'AbortError') + expect(subprocessSpawnFailure(abort, 'bash')).toBe(abort) + }) +}) diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index e72e6a3dd7..a39b295358 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../util/launch-environment" + }, + { + "path": "../../llm/llm" } ] } diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 6cfa81bc6d..8c9fb0557c 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -15,8 +15,8 @@ en: a3450c8cdd5f5802 zh: 15eec66f42e42ce7 /deepseek-ai-dsh-win32-process/behavior: - en: 77806095cd7f781d - zh: 69d09cabc4d49185 + en: 9b81bbd4b1a16ddc + zh: b07fc32b41c3cc66 /deepseek-ai-dsh-win32-process/header-verification: en: 2b60a9001b480546 zh: 12a621e421c7d789 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index af7cc79503..23ce6f94dd 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -24,7 +24,7 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. A failed checked call throws `Win32Error`, a `HarnessError` with code `WIN32_ERROR` whose `api` and `win32Code` fields name the call; callers that own the failing operation wrap it in their own coded error with the `Win32Error` as `cause`. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the restricted-token null-environment policy, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 7c5685b77a..c3df8aa576 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -24,7 +24,7 @@ kind: "package-library" ## 行为 -- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让沙箱策略通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让沙箱策略通过同一组已加载库绑定剩余 API。受检调用失败时抛出 `Win32Error`,它是 code 为 `WIN32_ERROR` 的 `HarnessError`,`api` 与 `win32Code` 字段标明失败的调用;拥有该操作的调用方再把它包装为自己的带 code 错误,并以 `Win32Error` 作为 `cause`。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求沙箱的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引号处理、cwd、restricted-token null 环境策略、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7f3d76d56f..7eae3438fc 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -30,13 +30,15 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", "koffi": "3.1.1" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*" } } diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts index f6e90805a1..d8cd4caa86 100644 --- a/packages/subprocess/win32-process/src/errors.ts +++ b/packages/subprocess/win32-process/src/errors.ts @@ -1,12 +1,22 @@ +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Code of every {@link Win32Error}; the failing API and its numeric code stay in `api`, `win32Code`, and `message`. */ +export const WIN32_ERROR = 'WIN32_ERROR' + /** Win32 call failure with the exact API name and error code. */ -export class Win32Error extends Error { +export class Win32Error extends HarnessError { /** Win32 function whose checked result failed. */ readonly api: string /** Exact GetLastError value or direct Win32 API error code. */ readonly win32Code: number + /** + * @param api - Win32 function whose checked result failed. + * @param win32Code - exact GetLastError value or direct API error code. + * @param detail - optional subject, such as the path the call targeted. + */ constructor(api: string, win32Code: number, detail?: string) { - super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`, WIN32_ERROR) this.name = 'Win32Error' this.api = api this.win32Code = win32Code diff --git a/packages/subprocess/win32-process/tests/ffi.spec.ts b/packages/subprocess/win32-process/tests/ffi.spec.ts index efb4db205a..a202503369 100644 --- a/packages/subprocess/win32-process/tests/ffi.spec.ts +++ b/packages/subprocess/win32-process/tests/ffi.spec.ts @@ -1,6 +1,8 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { + WIN32_ERROR, Win32Error, allocPtrSlot, decodePtr, @@ -35,6 +37,9 @@ describe('shared Win32 process ABI', () => { expect(errorText(api, 5)).toBe('access denied') expect(() => throwLastError(api, 'Probe')).toThrow(Win32Error) expect(new Win32Error('CloseHandle', 6).message).toBe('CloseHandle failed (Win32 6)') + const error = new Win32Error('CloseHandle', 6) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'Win32Error', code: WIN32_ERROR, api: 'CloseHandle', win32Code: 6 }) }) it('decodes a pointer stored by Koffi', () => { diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index 93abec7c46..ac86e810e8 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -11,6 +11,9 @@ }, { "path": "../../util/lazy-require" + }, + { + "path": "../../llm/llm" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca94685474..1884575900 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16984,6 +16984,9 @@ importers: '@deepseek-ai/dsh-launch-environment': specifier: workspace:* version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm packages/subprocess/subprocess-local: dependencies: @@ -17025,6 +17028,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm packages/team/agent-team: dependencies: From 9626ccfae8c1d0ac626427d86823a42b48ad3381 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:51:19 +0800 Subject: [PATCH 029/149] feat(sandbox): code escalation outcomes and Windows ACL preparation failures The sandbox Service Definition adds SandboxEscalationError with SANDBOX_ESCALATION_INVALID_ARGUMENT, _UNAVAILABLE, _REJECTED, and _CANCELLED, thrown by validateEscalationArgs() and approveEscalation() with their existing messages. A cancelled approval for an aborted call rethrows the call's cancellation instead. It also adds SandboxPreparationError. sandbox-local wraps Windows ACL SID, grant, temp-directory, and path-resolution failures as SANDBOX_PREPARATION_FAILED with the Win32Error or Node error (or an AggregateError with cleanup failures) as cause, and reports a temp root inside the workspace as SANDBOX_INVALID_TEMP_ROOT. sandbox-windows-acl exports isTempRootInsideWorkspace() for that check. Refs #7869 --- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 2 +- docs/subsystems/sandbox.zh.md | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/src/index.ts | 65 ++++++++++++--- .../sandbox-local/tests/acl-grants.spec.ts | 83 ++++++++++++++++--- .../sandbox/sandbox-windows-acl/src/index.ts | 2 +- .../sandbox-windows-acl/src/path-boundary.ts | 15 +++- packages/sandbox/sandbox/README.i18n.yaml | 8 +- packages/sandbox/sandbox/README.md | 4 +- packages/sandbox/sandbox/README.zh.md | 4 +- packages/sandbox/sandbox/src/escalation.ts | 77 ++++++++++++++--- packages/sandbox/sandbox/src/index.ts | 42 +++++++++- .../sandbox/sandbox/tests/escalation.spec.ts | 42 +++++++++- .../sandbox/sandbox/tests/vocabulary.spec.ts | 13 ++- 17 files changed, 317 insertions(+), 54 deletions(-) diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 3d2954b1c4..6f92d1861f 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -15,5 +15,5 @@ en: 2ba8930762e92e40 zh: 220f64da1a9e5d57 /process-sandbox/provider-and-fail-closed-errors: - en: a90a38fe4b11737c - zh: 2397768e48712617 + en: a1f9649b3cde0aca + zh: 6708348a70eb2451 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c530276e90..e5c3021503 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -161,7 +161,7 @@ The [local provider](../../packages/sandbox/sandbox-local/README.md) owns operat ## Provider and fail-closed errors -`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. +`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists, or with `SandboxPreparationError` (`SANDBOX_PREPARATION_FAILED`, `SANDBOX_INVALID_TEMP_ROOT`) when the provider cannot prepare host state for the mode. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. Provider selection, probing, caching, and backend-specific enforcement reports belong to the [local provider](../../packages/sandbox/sandbox-local/README.md). diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index c3cfd0720e..10093b09e2 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -161,7 +161,7 @@ interface ConfinedArgv { ## 提供方与 fail-closed 错误 -`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。 +`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝,提供方无法为该模式准备主机状态时以 `SandboxPreparationError`(`SANDBOX_PREPARATION_FAILED`、`SANDBOX_INVALID_TEMP_ROOT`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。 提供方选择、探测、缓存和后端特定的强制执行报告归[本地提供方](../../packages/sandbox/sandbox-local/README.zh.md)所有。 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index b660d61a20..84b8ff94c7 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7282c9ec77fd6f01 zh: 10e385a9489105e3 /deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery: - en: b1f9aeca3baf9561 - zh: a5681f4f49259468 + en: 3859a87c6122a1bf + zh: 55d7be15527f0347 /deepseek-ai-dsh-sandbox-local/understand-the-implementation: en: 15db6ec303151ccc zh: 91f62c1f64192f92 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 578caa7c63..55d5b9cfe8 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly. -Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. The Windows ACL backend does not inspect these extra roots and keeps workspace and private-temp access only. +Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. The Windows ACL backend does not inspect these extra roots and keeps workspace and private-temp access only. Its per-session preparation rejects with `SandboxPreparationError` before the command starts: `SANDBOX_INVALID_TEMP_ROOT` when the host temp root lies inside the workspace, and `SANDBOX_PREPARATION_FAILED` when a SID, ACL grant, or private temp directory cannot be created, with the `Win32Error` or Node error as `cause` and partial grants rolled back. ----- diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 7787decc5b..69103da191 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。 -Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。Windows ACL 后端不检查这些额外根目录,仅保留工作区与私有临时目录访问权限。 +Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。Windows ACL 后端不检查这些额外根目录,仅保留工作区与私有临时目录访问权限。其按 session 的准备阶段会在命令启动前以 `SandboxPreparationError` 拒绝:主机临时根目录位于工作区内时为 `SANDBOX_INVALID_TEMP_ROOT`;无法创建 SID、ACL 授权或私有临时目录时为 `SANDBOX_PREPARATION_FAILED`,以 `Win32Error` 或 Node 错误作为 `cause`,并回滚已创建的部分授权。 ----- diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 52cea43d23..af8dae004f 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -34,13 +34,31 @@ import { } from '@deepseek-ai/node-addon-system/landlock-run' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox' +import { SandboxPreparationError, SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, isTempRootInsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { assertNever } from '@deepseek-ai/dsh-util-values' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' +/** + * Classify one failed Windows ACL preparation step; the platform failure, or an + * `AggregateError` of it and its cleanup failures, becomes the `cause`. + * @param step - the preparation step, as the message names it. + * @param error - the step's own failure, typically a `Win32Error` or Node errno error. + * @param cleanupFailures - rollback failures that followed it. + * @returns the coded preparation failure. + */ +function aclPreparationFailure(step: string, error: unknown, cleanupFailures: readonly unknown[] = []): SandboxPreparationError { + const failed = cleanupFailures.length === 0 ? `${step} failed` : `${step} failed and its cleanup also failed` + return new SandboxPreparationError( + `sandbox-local: windows-acl ${failed}: ${String(error)}. The command did not run; ` + + 'check that the current user can change permissions on the workspace and temp directories, or run with danger-full-access.', + 'SANDBOX_PREPARATION_FAILED', + { cause: cleanupFailures.length === 0 ? error : new AggregateError([error, ...cleanupFailures], `sandbox-local windows-acl ${failed}`) }, + ) +} + /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** @@ -397,16 +415,37 @@ export class LocalSandboxProvider extends SandboxProvider { * SID to enter it. A fresh provider always chooses a new path; crash * residue therefore cannot collide with or authorize a resumed session. * Fail-closed: a half-materialized temp grant is revoked and its directory - * removed before the error propagates. + * removed before the error propagates. A temp root inside the workspace + * throws `SandboxPreparationError` `SANDBOX_INVALID_TEMP_ROOT` before any ACL + * change; any other failure throws `SANDBOX_PREPARATION_FAILED` with the + * platform error as `cause`. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. * @returns the pair's private temp directory and write capability. */ private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { - assertTempRootOutsideWorkspace(workspaceRoot, tmpdir()) + let tempInsideWorkspace: boolean + try { + tempInsideWorkspace = isTempRootInsideWorkspace(workspaceRoot, tmpdir()) + } catch (error) { + throw aclPreparationFailure('workspace and temp directory resolution', error) + } + if (tempInsideWorkspace) { + throw new SandboxPreparationError( + `sandbox-local: windows-acl temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tmpdir()}. ` + + 'A private temp directory there would inherit the workspace grant, so the command did not run. ' + + 'Set TEMP and TMP to a directory outside the workspace, or open a workspace that does not contain the temp directory.', + 'SANDBOX_INVALID_TEMP_ROOT', + ) + } const writeSid = workspaceWriteSid(workspaceRoot) if (!this.workspaceGrants.has(workspaceRoot)) { - const grant = AclWriteGrant.create(writeSid) + let grant: AclWriteGrant + try { + grant = AclWriteGrant.create(writeSid) + } catch (error) { + throw aclPreparationFailure('workspace grant', error) + } try { grant.add(workspaceRoot, true) } catch (error) { @@ -416,16 +455,21 @@ export class LocalSandboxProvider extends SandboxProvider { try { grant.dispose() } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') + throw aclPreparationFailure('workspace grant', error, [cleanupError]) } - throw error + throw aclPreparationFailure('workspace grant', error) } this.workspaceGrants.set(workspaceRoot, grant) } const key = JSON.stringify([String(sessionId), workspaceRoot]) const existing = this.tempCapabilities.get(key) if (existing !== undefined) return existing - const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + let tempDir: string + try { + tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + } catch (error) { + throw aclPreparationFailure('private temp directory creation', error) + } const tempSid = tempWriteSid(tempDir) let grant: AclWriteGrant | undefined try { @@ -445,10 +489,7 @@ export class LocalSandboxProvider extends SandboxProvider { } catch (cleanupError) { cleanupFailures.push(cleanupError) } - if (cleanupFailures.length > 0) { - throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') - } - throw error + throw aclPreparationFailure('temp grant materialization', error, cleanupFailures) } const capability = { dir: tempDir, writeSid: tempSid, grant } this.tempCapabilities.set(key, capability) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 7f69bc3ddf..c98fbf0eb9 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -5,7 +5,7 @@ * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,6 +21,7 @@ const mockState = vi.hoisted(() => ({ /** Restrict an add failure to standing (workspace) or revocable (temp). */ addFailureStanding: undefined as boolean | undefined, createTempFailure: undefined as Error | undefined, + createWorkspaceFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -35,6 +36,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { } static create(writeSid: string): MockAclWriteGrant { if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure + if (!writeSid.startsWith('TEMP:') && mockState.createWorkspaceFailure !== undefined) throw mockState.createWorkspaceFailure return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { @@ -52,12 +54,10 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { return { registerAclDiagnosisSkill: vi.fn(), AclWriteGrant: MockAclWriteGrant, - assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { + isTempRootInsideWorkspace: (workspaceRoot: string, tempRoot: string) => { const workspace = realpathSync.native(workspaceRoot) const temp = realpathSync.native(tempRoot) - if (temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`)) { - throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) - } + return temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`) }, workspaceWriteSid: () => 'S-1-4-42-42', tempWriteSid: (path: string) => `TEMP:${path}`, @@ -91,6 +91,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = undefined mockState.addFailureStanding = undefined mockState.createTempFailure = undefined + mockState.createWorkspaceFailure = undefined mockState.disposeFailure = undefined }) @@ -250,25 +251,87 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = new Error('workspace grant exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'), - })).rejects.toThrow('workspace grant exploded') + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + cause: mockState.addFailure, + message: expect.stringContaining('workspace grant failed: Error: workspace grant exploded') as string, + }) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) mockState.disposeFailure = new Error('workspace cleanup exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'), - })).rejects.toThrow(/workspace grant failed and its cleanup also failed/u) + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + message: expect.stringMatching(/workspace grant failed and its cleanup also failed/u) as string, + cause: expect.objectContaining({ errors: [mockState.addFailure, mockState.disposeFailure] }) as unknown, + }) expect(mockState.grants).toHaveLength(2) } finally { cleanup() } }) + it('codes a workspace SID allocation failure and an unresolvable workspace before any grant', async () => { + const { sandbox } = await setup() + try { + const ws = workspaceRoot() + scratch.push(ws) + mockState.createWorkspaceFailure = new Error('ConvertStringSidToSidW failed (Win32 1337)') + await expect(sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sid-fail'), + })).rejects.toMatchObject({ name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', cause: mockState.createWorkspaceFailure }) + await expect(sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: join(ws, 'missing'), sessionId: SessionId('missing'), + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + message: expect.stringContaining('workspace and temp directory resolution failed') as string, + cause: expect.objectContaining({ code: 'ENOENT' }) as unknown, + }) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('codes a private temp directory that cannot be created', async () => { + const { sandbox } = await setup() + const ws = workspaceRoot() + const lockedTemp = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-acl-locked-temp-'))) + scratch.push(ws, lockedTemp) + const previous = process.env.TMPDIR + chmodSync(lockedTemp, 0o500) + process.env.TMPDIR = lockedTemp + try { + await expect(sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('mkdtemp-fail'), + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + message: expect.stringContaining('private temp directory creation failed') as string, + cause: expect.objectContaining({ code: 'EACCES' }) as unknown, + }) + } finally { + if (previous === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = previous + chmodSync(lockedTemp, 0o700) + cleanup() + } + }) + it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => { const { sandbox } = await setup() await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'), - })).rejects.toThrow(/temp root must be outside the workspace/u) + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_INVALID_TEMP_ROOT', + message: expect.stringMatching(/temp root must be outside the workspace/u) as string, + }) expect(mockState.grants).toHaveLength(0) }) @@ -281,7 +344,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.createTempFailure = new Error('temp SID creation exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'), - })).rejects.toThrow('temp SID creation exploded') + })).rejects.toMatchObject({ code: 'SANDBOX_PREPARATION_FAILED', cause: mockState.createTempFailure }) expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed mockState.createTempFailure = undefined @@ -289,7 +352,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = new Error('temp add exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'), - })).rejects.toThrow('temp add exploded') + })).rejects.toMatchObject({ name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', cause: mockState.addFailure }) const failedTempGrant = mockState.grants.at(-1) expect(failedTempGrant?.disposed).toBe(true) expect(failedTempGrant?.added).toHaveLength(1) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 5de5d47b72..2af0d2d6c7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -56,7 +56,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' -export { assertTempRootOutsideWorkspace } from './path-boundary.ts' +export { assertTempRootOutsideWorkspace, isTempRootInsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ diff --git a/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts index 7c8dcb2596..c801bbb995 100644 --- a/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts +++ b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts @@ -14,13 +14,24 @@ function containsDirectory(root: string, candidate: string): boolean { } /** - * Reject a temp parent that is inside the workspace: every child created + * Whether a temp parent is inside the workspace, where every child created * below it would inherit the standing workspace capability. * @param workspaceRoot - the canonical workspace root that receives the standing ACE. * @param tempRoot - the existing parent beneath which a private temp child would be created. + * @returns whether the canonical workspace contains the canonical temp root. + * @throws the filesystem error when either directory cannot be resolved. + */ +export function isTempRootInsideWorkspace(workspaceRoot: string, tempRoot: string): boolean { + return containsDirectory(workspaceRoot, tempRoot) +} + +/** + * Reject a temp parent that is inside the workspace (see {@link isTempRootInsideWorkspace}). + * @param workspaceRoot - the canonical workspace root that receives the standing ACE. + * @param tempRoot - the existing parent beneath which a private temp child would be created. */ export function assertTempRootOutsideWorkspace(workspaceRoot: string, tempRoot: string): void { - if (containsDirectory(workspaceRoot, tempRoot)) { + if (isTempRootInsideWorkspace(workspaceRoot, tempRoot)) { throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) } } diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 9bdd154f3a..7cd5cd3e7c 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -27,11 +27,11 @@ en: d3c906ddb4c47cbc zh: d264b9103e3566d6 /deepseek-ai-dsh-sandbox/use-this-package/denied-calls-and-escalation: - en: 3960c33a1e322cf9 - zh: 4d85c962067fe7a1 + en: 0572a2aa3208873f + zh: 07e5d481a637a5af /deepseek-ai-dsh-sandbox/use-this-package/fail-closed-behavior: - en: 6bf240632a7a6bf5 - zh: ebf414f5cf435875 + en: b3d36b28566cadd4 + zh: 22cf905bf574865a /deepseek-ai-dsh-sandbox/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 3bd771b271..3db483c834 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -63,11 +63,11 @@ Enforcement is reported per call: `full` means the backend governs every promise ### Denied calls and escalation -When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the approval service obtains consent for the operation. The request retains its audit reason and supplies English, Chinese, and Japanese presentation copy from `src/locales.ts`, with the model's justification unchanged. Missing translations follow the locale service's fallback chain, ultimately English; requesters own their translated presentation text. A wider mode requires approval and applies to that one call only. Repeating the call's effective mode succeeds without approval; narrower targets remain invalid. +When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the approval service obtains consent for the operation. The request retains its audit reason and supplies English, Chinese, and Japanese presentation copy from `src/locales.ts`, with the model's justification unchanged. Missing translations follow the locale service's fallback chain, ultimately English; requesters own their translated presentation text. A wider mode requires approval and applies to that one call only. Repeating the call's effective mode succeeds without approval; narrower targets remain invalid. An escalation that yields no grant throws `SandboxEscalationError` before anything runs: `SANDBOX_ESCALATION_INVALID_ARGUMENT` for unpaired fields, a blank justification, or a target that is not strictly wider; `SANDBOX_ESCALATION_UNAVAILABLE` when the composition has no sandboxing executor, approval service, calling Agent, or approval channel; `SANDBOX_ESCALATION_REJECTED` when the user declines; and `SANDBOX_ESCALATION_CANCELLED` when the approval request ends without a decision. If the call itself was cancelled, the call's cancellation propagates instead. ### Fail-closed behavior -When no backend can enforce the requested mode, the call fails with `SANDBOX_UNAVAILABLE` rather than running unconfined; the error text names the missing platform runner. A backend that fails after starting also reports a structured runner-failure signature, so a broken sandbox is distinguishable from a command failure. +When no backend can enforce the requested mode, the call fails with `SANDBOX_UNAVAILABLE` rather than running unconfined; the error text names the missing platform runner. A backend that fails after starting also reports a structured runner-failure signature, so a broken sandbox is distinguishable from a command failure. A provider that cannot prepare host state for an enforceable mode, such as a Windows ACL grant or private temp directory, rejects with `SandboxPreparationError`: `SANDBOX_PREPARATION_FAILED` carries the platform error as `cause`, and `SANDBOX_INVALID_TEMP_ROOT` reports a host temp root inside the workspace. ----- diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index f2757c7300..6e9710cde9 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -63,11 +63,11 @@ kind: "package-reference" ### 被拒绝的调用与升权 -受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;审批服务为操作取得同意。请求保留审计原因,同时从 `src/locales.ts` 提供英文、中文和日文展示文案,模型给出的理由保持原样。缺失的译文遵循语言服务的回退链,最终回退到英文;请求方负责自己的展示译文。更宽的模式需要审批,且只作用于该次调用。重复指定调用的生效模式无需审批即可成功;更窄的目标仍然无效。 +受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;审批服务为操作取得同意。请求保留审计原因,同时从 `src/locales.ts` 提供英文、中文和日文展示文案,模型给出的理由保持原样。缺失的译文遵循语言服务的回退链,最终回退到英文;请求方负责自己的展示译文。更宽的模式需要审批,且只作用于该次调用。重复指定调用的生效模式无需审批即可成功;更窄的目标仍然无效。未取得授权的升权会在任何命令运行前抛出 `SandboxEscalationError`:字段未配对、理由为空或目标并非严格更宽时为 `SANDBOX_ESCALATION_INVALID_ARGUMENT`;组合中没有受限执行器、审批服务、调用方 Agent 或审批通道时为 `SANDBOX_ESCALATION_UNAVAILABLE`;用户拒绝时为 `SANDBOX_ESCALATION_REJECTED`;审批请求未作决定即结束时为 `SANDBOX_ESCALATION_CANCELLED`。若调用本身已被取消,则改为传播该调用的取消。 ### 故障关闭行为 -没有后端能强制执行所请求的模式时,调用以 `SANDBOX_UNAVAILABLE` 失败,而不是不受限制地运行;错误文本会指明缺失的平台 runner。启动后失败的后端还会报告结构化的 runner 失败签名,因此损坏的沙箱可以与命令失败区分开。 +没有后端能强制执行所请求的模式时,调用以 `SANDBOX_UNAVAILABLE` 失败,而不是不受限制地运行;错误文本会指明缺失的平台 runner。启动后失败的后端还会报告结构化的 runner 失败签名,因此损坏的沙箱可以与命令失败区分开。提供方无法为可强制执行的模式准备主机状态(例如 Windows ACL 授权或私有临时目录)时,以 `SandboxPreparationError` 拒绝:`SANDBOX_PREPARATION_FAILED` 以平台错误作为 `cause`,`SANDBOX_INVALID_TEMP_ROOT` 表示主机临时根目录位于工作区内。 ----- diff --git a/packages/sandbox/sandbox/src/escalation.ts b/packages/sandbox/sandbox/src/escalation.ts index c912fdd080..84d5f49561 100644 --- a/packages/sandbox/sandbox/src/escalation.ts +++ b/packages/sandbox/sandbox/src/escalation.ts @@ -16,10 +16,45 @@ * @module dsh-sandbox/escalation */ +import { HarnessError } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' import { escalationDisplayReason } from './locales.ts' import type { SandboxMode } from './index.ts' +/** + * Every {@link SandboxEscalationError} code: + * - `SANDBOX_ESCALATION_INVALID_ARGUMENT` — the escalation fields are unpaired, the justification is blank, or the + * target is not strictly wider than the call's effective mode; the model corrects the request. + * - `SANDBOX_ESCALATION_UNAVAILABLE` — the composition cannot ask for approval: no sandboxing executor, no approval + * service, no calling Agent, or no approval channel. + * - `SANDBOX_ESCALATION_REJECTED` — the user declined the escalation; the operation stays denied. + * - `SANDBOX_ESCALATION_CANCELLED` — the approval request ended without a decision while the call itself was not cancelled. + */ +export const SANDBOX_ESCALATION_ERROR_CODES = [ + 'SANDBOX_ESCALATION_INVALID_ARGUMENT', + 'SANDBOX_ESCALATION_UNAVAILABLE', + 'SANDBOX_ESCALATION_REJECTED', + 'SANDBOX_ESCALATION_CANCELLED', +] as const + +/** One code from {@link SANDBOX_ESCALATION_ERROR_CODES}. */ +export type SandboxEscalationErrorCode = typeof SANDBOX_ESCALATION_ERROR_CODES[number] + +/** A `sandbox_permissions` request that did not produce a grant; nothing executed. */ +export class SandboxEscalationError extends HarnessError { + override readonly code: SandboxEscalationErrorCode + + /** + * @param message - the model-facing explanation and the safe next step. + * @param code - the failure class consumers route on. + */ + constructor(message: string, code: SandboxEscalationErrorCode) { + super(message, code) + this.name = 'SandboxEscalationError' + this.code = code + } +} + /** * The strictly-wider table: what a call whose effective mode is the key may * escalate TO. Checked at EXECUTION, never baked into a tool schema — the @@ -46,18 +81,19 @@ export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'd * `sandbox_permissions` and `justification` travel together — an approval * prompt without a reason, or a reason driving nothing, is a malformed ask — * and the justification must be a non-empty sentence. + * A violation throws {@link SandboxEscalationError} `SANDBOX_ESCALATION_INVALID_ARGUMENT`. * @param sandboxPermissions - the raw `sandbox_permissions` argument, if given. * @param justification - the raw `justification` argument, if given. */ export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void { if (sandboxPermissions !== undefined && justification === undefined) { - throw new Error('invalid escalation: sandbox_permissions requires a justification') + throw new SandboxEscalationError('invalid escalation: sandbox_permissions requires a justification', 'SANDBOX_ESCALATION_INVALID_ARGUMENT') } if (justification !== undefined && sandboxPermissions === undefined) { - throw new Error('invalid escalation: justification is only valid together with sandbox_permissions') + throw new SandboxEscalationError('invalid escalation: justification is only valid together with sandbox_permissions', 'SANDBOX_ESCALATION_INVALID_ARGUMENT') } if (justification !== undefined && justification.trim().length === 0) { - throw new Error('invalid justification: expected a non-empty sentence') + throw new SandboxEscalationError('invalid justification: expected a non-empty sentence', 'SANDBOX_ESCALATION_INVALID_ARGUMENT') } } @@ -164,7 +200,8 @@ export interface EscalationRequest { * effective mode returns it without approval. A strictly wider mode requires * approval and applies only to this call. Narrower or unsupported targets, * missing approval services or agents for widening, and non-grant outcomes - * throw before execution. + * throw {@link SandboxEscalationError} before execution; a `cancelled` + * outcome while `approval.signal` is aborted rethrows the signal's reason. * @param request - the escalation to judge (see {@link EscalationRequest}). * @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}). * @returns the granted mode, consumed by the one call that asked. @@ -176,13 +213,22 @@ export async function approveEscalation(request: EscalationRequest, approv // deliberately not a schema constraint (the enum is the closed target // vocabulary; the effective mode is per-call truth). if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) { - throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`) + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`, + 'SANDBOX_ESCALATION_INVALID_ARGUMENT', + ) } if (approval.approver === undefined) { - throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`) + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" requires approval, but no approval service is composed`, + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } if (approval.agent === undefined) { - throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`) + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`, + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } // Self-contained for the audit trail: approval/asked stores this reason, // and the target mode is part of the grant's identity. @@ -198,9 +244,20 @@ export async function approveEscalation(request: EscalationRequest, approv // The schema enum already pinned `mode` to the closed target vocabulary; // the check above proved it is strictly wider. case 'allowed-once': return mode as SandboxMode - case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"; it stays denied, so stop and explain instead of working around it`) - case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) - case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`) + case 'rejected': + throw new SandboxEscalationError( + `the user rejected escalating this ${subject} to "${mode}"; it stays denied, so stop and explain instead of working around it`, + 'SANDBOX_ESCALATION_REJECTED', + ) + case 'cancelled': + // A cancelled call keeps its own cancellation so the registry records it as aborted. + approval.signal?.throwIfAborted() + throw new SandboxEscalationError(`approval for escalating to "${mode}" was cancelled`, 'SANDBOX_ESCALATION_CANCELLED') + case 'unavailable': + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" requires approval, but no approval channel is available`, + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) default: return assertNever(outcome, 'EscalationOutcome') } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index bd22dcb5d6..69d9100f66 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -11,6 +11,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session' export { ESCALATION_TARGETS, + SANDBOX_ESCALATION_ERROR_CODES, + SandboxEscalationError, WIDER_MODES, approveEscalation, escalationHintMarker, @@ -18,7 +20,13 @@ export { sandboxPermissionsDescription, validateEscalationArgs, } from './escalation.ts' -export type { EscalationApproval, EscalationApprover, EscalationOutcome, EscalationRequest } from './escalation.ts' +export type { + EscalationApproval, + EscalationApprover, + EscalationOutcome, + EscalationRequest, + SandboxEscalationErrorCode, +} from './escalation.ts' export { canonicalPath, workspaceWritableRoots, writableRoots, SandboxInvalidRootError } from './roots.ts' /** @@ -152,6 +160,38 @@ export class SandboxUnavailableError extends HarnessError { } } +/** + * Every {@link SandboxPreparationError} code: + * - `SANDBOX_PREPARATION_FAILED` — the provider could not set up host state the confined process needs, such as a + * Windows ACL grant or a private temp directory; `cause` holds the platform failure. + * - `SANDBOX_INVALID_TEMP_ROOT` — the host temp root lies inside the workspace, so a private temp directory would + * inherit the workspace grant; move the temp root or the workspace. + */ +export const SANDBOX_PREPARATION_ERROR_CODES = ['SANDBOX_PREPARATION_FAILED', 'SANDBOX_INVALID_TEMP_ROOT'] as const + +/** One code from {@link SANDBOX_PREPARATION_ERROR_CODES}. */ +export type SandboxPreparationErrorCode = typeof SANDBOX_PREPARATION_ERROR_CODES[number] + +/** + * Thrown by {@link SandboxProvider.confine} when host preparation for an + * enforceable mode fails. The provider rolls back what it prepared before + * throwing, and the command never runs. + */ +export class SandboxPreparationError extends HarnessError { + override readonly code: SandboxPreparationErrorCode + + /** + * @param message - names the preparation step and the safe next step. + * @param code - the failure class consumers route on. + * @param options - the platform failure, or an `AggregateError` with the cleanup failures, as `cause`. + */ + constructor(message: string, code: SandboxPreparationErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SandboxPreparationError' + this.code = code + } +} + declare module '@deepseek-ai/cordis' { interface Context { sandbox: SandboxProvider diff --git a/packages/sandbox/sandbox/tests/escalation.spec.ts b/packages/sandbox/sandbox/tests/escalation.spec.ts index 987ac0105b..f485bbd1df 100644 --- a/packages/sandbox/sandbox/tests/escalation.spec.ts +++ b/packages/sandbox/sandbox/tests/escalation.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest' import { ESCALATION_TARGETS, + SandboxEscalationError, WIDER_MODES, approveEscalation, escalationHintMarker, @@ -16,7 +17,20 @@ import { sandboxPermissionsDescription, validateEscalationArgs, } from '@deepseek-ai/dsh-sandbox' -import type { EscalationApprover, EscalationOutcome } from '@deepseek-ai/dsh-sandbox' +import type { EscalationApprover, EscalationOutcome, SandboxEscalationErrorCode } from '@deepseek-ai/dsh-sandbox' + +/** The structured identity a failed escalation records on `tool/result`. */ +const escalationError = (code: SandboxEscalationErrorCode): unknown => expect.objectContaining({ name: 'SandboxEscalationError', code }) + +/** The synchronous failure `run` throws. */ +function thrown(run: () => void): unknown { + try { + run() + } catch (error: unknown) { + return error + } + throw new Error('expected a throw') +} describe('the strictly-wider ladder', () => { it('read-only escalates to either wider mode; workspace-write only to full access', () => { @@ -40,6 +54,11 @@ describe('validateEscalationArgs', () => { expect(() => { validateEscalationArgs('workspace-write', undefined) }).toThrow(/requires a justification/) expect(() => { validateEscalationArgs(undefined, 'orphan reason') }).toThrow(/only valid together with sandbox_permissions/) expect(() => { validateEscalationArgs('workspace-write', ' ') }).toThrow(/non-empty sentence/) + for (const [mode, justification] of [['workspace-write', undefined], [undefined, 'orphan reason'], ['workspace-write', ' ']] as const) { + const error = thrown(() => { validateEscalationArgs(mode, justification) }) + expect(error).toBeInstanceOf(SandboxEscalationError) + expect(error).toEqual(escalationError('SANDBOX_ESCALATION_INVALID_ARGUMENT')) + } }) }) @@ -113,12 +132,16 @@ describe('approveEscalation', () => { .rejects.toThrow(/not strictly wider/) await expect(approveEscalation(req({ requestedMode: 'unknown-mode' }), spy)) .rejects.toThrow(/not strictly wider/) + await expect(approveEscalation(req({ requestedMode: 'unknown-mode' }), spy)) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_INVALID_ARGUMENT')) expect(seen).toEqual([]) }) it('a missing approval service and an agent-less call each fail closed with distinct text', async () => { await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toThrow(/no approval service is composed/) await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toThrow(/no agent to route it through/) + await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toEqual(escalationError('SANDBOX_ESCALATION_UNAVAILABLE')) + await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toEqual(escalationError('SANDBOX_ESCALATION_UNAVAILABLE')) }) it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => { @@ -130,6 +153,23 @@ describe('approveEscalation', () => { .rejects.toThrow('no approval channel is available') }) + it('codes each non-grant outcome', async () => { + await expect(approveEscalation(req(), ingredients({ approver: approver('rejected') }))) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_REJECTED')) + await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled') }))) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_CANCELLED')) + await expect(approveEscalation(req(), ingredients({ approver: approver('unavailable') }))) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_UNAVAILABLE')) + }) + + it('a cancelled approval for an aborted call rethrows the call\'s cancellation', async () => { + const controller = new AbortController() + const reason = new DOMException('caller stopped', 'AbortError') + controller.abort(reason) + await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled'), signal: controller.signal }))) + .rejects.toBe(reason) + }) + it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => { await expect(approveEscalation(req(), ingredients({ approver: approver('bogus' as never) }))).rejects.toThrow() }) diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts index caba42b2a7..26db8fe796 100644 --- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -7,7 +7,8 @@ */ import { describe, expect, it } from 'vitest' -import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { SANDBOX_PREPARATION_ERROR_CODES, SANDBOX_UNAVAILABLE, SandboxPreparationError, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' describe('SandboxUnavailableError', () => { it('carries the structured { name, code } identity consumers key on', () => { @@ -33,3 +34,13 @@ describe('SandboxUnavailableError', () => { expect(error.message).toContain('Runner failure: landlock-run: landlock is not enforced by this kernel') }) }) + +describe('SandboxPreparationError', () => { + it('carries a literal name, a closed code, and the platform failure as cause', () => { + const cause = new Error('SetNamedSecurityInfoW failed (Win32 5)') + const error = new SandboxPreparationError('cannot grant the workspace', 'SANDBOX_PREPARATION_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', cause }) + expect(SANDBOX_PREPARATION_ERROR_CODES).toEqual(['SANDBOX_PREPARATION_FAILED', 'SANDBOX_INVALID_TEMP_ROOT']) + }) +}) From 45db11970ccdf4e4ad5643fc11e45b702ac125e7 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:59:51 +0800 Subject: [PATCH 030/149] feat(working-directory): add WorkingDirectoryError and code directory failures The working-directory service throws WorkingDirectoryError: WORKING_DIRECTORY_INVALID for an empty cd, WORKING_DIRECTORY_NOT_FOUND for a missing target, and WORKING_DIRECTORY_UNAVAILABLE when the current and original directories are both gone or the service was disposed. The working_directory tool reports a missing Agent as ToolContextUnavailableError and an empty cd as ToolArgsError. sandbox-policy reports a session cwd it cannot use as a workspace root as SandboxInvalidRootError (SANDBOX_INVALID_ROOT), whose constructor now names the role of the rejected path. Refs #7869 --- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +-- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 29 ++++++++++++---- .../sandbox-policy/tests/policy.spec.ts | 14 ++++++++ packages/sandbox/sandbox/src/roots.ts | 11 ++++-- .../tool-working-directory/README.i18n.yaml | 4 +-- .../session/tool-working-directory/README.md | 2 +- .../tool-working-directory/README.zh.md | 2 +- .../tool-working-directory/src/index.ts | 5 +-- .../working-directory/README.i18n.yaml | 4 +-- packages/session/working-directory/README.md | 2 +- .../session/working-directory/README.zh.md | 2 +- .../session/working-directory/src/error.ts | 34 +++++++++++++++++++ .../session/working-directory/src/index.ts | 33 +++++++++++++----- .../tests/working-directory.spec.ts | 21 +++++++++++- 16 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 packages/session/working-directory/src/error.ts diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index a5cc96a557..9bef469d7d 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: 0616478ef5bb0566 - zh: e3a5802aefcf8424 + en: 0b62eae224181946 + zh: 2e77e7f49bd074d4 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: en: 841ea47ef1ed7ee6 zh: cd573e67a67742e2 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 022ba841e4..0d26190122 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -45,7 +45,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and | Field | Default | Meaning | |---|---|---| | `mode` | `read-only` | The deployment default mode a session starts from, validated at load | -| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd | +| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a cwd containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` | | `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access | For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 49d976c7bf..a436f298d5 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 | -| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd | +| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 | | `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 | 对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 2136e33f45..8f8d5023ac 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -27,6 +27,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { z as zod } from 'zod' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' +import { SandboxInvalidRootError } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' @@ -34,10 +35,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt' export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' -/** Preserve execution-world spelling; enforcing providers resolve filesystem identity on their host. */ -function resolveWorkspaceRoot(path: string): string { - if (!isAbsolute(path) || path.includes('\0')) throw new Error('sandbox-policy: workspace root must be an absolute execution-world path without NUL bytes') - return path +/** Whether a path is usable as an execution-world grant: absolute and free of NUL bytes. */ +function isExecutionWorldPath(path: string): boolean { + return isAbsolute(path) && !path.includes('\0') } /** @@ -160,7 +160,12 @@ export class SandboxPolicyService extends Service { // runtime fact. `workspaceRoot` has NO schema default, so its fallback to // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode - this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + // Execution-world spelling is preserved; enforcing providers resolve filesystem identity on their host. + const workspaceRoot = config.workspaceRoot ?? process.cwd() + if (!isExecutionWorldPath(workspaceRoot)) { + throw new Error('sandbox-policy: workspace root must be an absolute execution-world path without NUL bytes') + } + this.workspaceRoot = workspaceRoot this.additionalWritableRoots = resolveAdditionalWritableRoots(config.additionalWritableRoots) } @@ -197,6 +202,8 @@ export class SandboxPolicyService extends Service { * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request @@ -206,12 +213,22 @@ export class SandboxPolicyService extends Service { : resolveAdditionalWritableRoots([...(this.additionalWritableRoots ?? []), ...registrations.values()]) return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, - workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + workspaceRoot: this.sessionWorkspaceRoot(session), ...additionalWritableRoots === undefined ? {} : { additionalWritableRoots }, ...session === undefined ? {} : { sessionId: session.id }, } } + /** The session's recorded cwd, or the configured root, as the workspace-write boundary. */ + private sessionWorkspaceRoot(session: Session | undefined): string { + const cwd = session?.header.cwd + if (cwd === undefined) return this.workspaceRoot + if (!isExecutionWorldPath(cwd)) { + throw new SandboxInvalidRootError(cwd, 'the Session working directory must be an absolute execution-world path without NUL bytes', 'sandbox workspace root') + } + return cwd + } + /** * Permit writes under one directory in this Session's future workspace-write * operations. The calling context owns the effect; disposal removes only this diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 1d4588f49e..954f789c02 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -67,6 +67,20 @@ describe('SandboxPolicyService', () => { } }) + it('rejects a session cwd containing a NUL byte with SANDBOX_INVALID_ROOT', async () => { + const cwd = '/invalid\0session' + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws' }) + try { + expect(() => ctx.sandboxPolicy.resolve({ session: session('sess-invalid-cwd', cwd) })).toThrow(expect.objectContaining({ + name: 'SandboxInvalidRootError', + code: 'SANDBOX_INVALID_ROOT', + message: `Invalid sandbox workspace root ${JSON.stringify(cwd)}: the Session working directory must be an absolute execution-world path without NUL bytes`, + })) + } finally { + await ctx.fiber.dispose() + } + }) + it('resolves the deployment policy for an agentless call', async () => { const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) expect(ctx.sandboxPolicy.resolve()).toEqual({ diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index 726f3bbfdc..363469ae42 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -19,10 +19,15 @@ import { relative } from 'node:path' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SandboxExecutionPolicy } from './index.ts' -/** An explicit directory grant cannot be enforced without changing the caller's selected path. */ +/** A workspace root or explicit directory grant cannot be enforced without changing the caller's selected path. */ export class SandboxInvalidRootError extends HarnessError { - constructor(path: string, detail: string) { - super(`Invalid additional writable directory ${JSON.stringify(path)}: ${detail}`, 'SANDBOX_INVALID_ROOT') + /** + * @param path - the rejected path, as the caller spelled it. + * @param detail - why the path cannot be enforced. + * @param subject - the role of the path in the policy, as the message names it. + */ + constructor(path: string, detail: string, subject = 'additional writable directory') { + super(`Invalid ${subject} ${JSON.stringify(path)}: ${detail}`, 'SANDBOX_INVALID_ROOT') this.name = 'SandboxInvalidRootError' } } diff --git a/packages/session/tool-working-directory/README.i18n.yaml b/packages/session/tool-working-directory/README.i18n.yaml index 742c9ffb0a..9e45d5365a 100644 --- a/packages/session/tool-working-directory/README.i18n.yaml +++ b/packages/session/tool-working-directory/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 87d7d2dbd7777429 /deepseek-ai-dsh-tool-working-directory/use-this-package: - en: bb750522dbdb3fef - zh: 85586a5c2137afa9 + en: 83ab57140ebea28e + zh: 213c36a664a21f65 /deepseek-ai-dsh-tool-working-directory/understand-the-implementation: en: f361fe510916fce8 zh: a2127f768ef9ac95 diff --git a/packages/session/tool-working-directory/README.md b/packages/session/tool-working-directory/README.md index 8eadd039a3..379743d651 100644 --- a/packages/session/tool-working-directory/README.md +++ b/packages/session/tool-working-directory/README.md @@ -25,7 +25,7 @@ Read the Session’s current directory or enter an existing directory with the s ## Use this package -Mount alongside `tools` and `workingDirectory`. Call `working_directory({})` to read or `working_directory({ cd: "src" })` to change directory. The tool has no configuration fields. +Mount alongside `tools` and `workingDirectory`. Call `working_directory({})` to read or `working_directory({ cd: "src" })` to change directory. The tool has no configuration fields. A call without a calling Agent fails with `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), an empty `cd` with `ToolArgsError` (`INVALID_ARGS`), and a missing directory with the service's `WorkingDirectoryError` codes. ```yaml - name: '@deepseek-ai/dsh-tool-working-directory' diff --git a/packages/session/tool-working-directory/README.zh.md b/packages/session/tool-working-directory/README.zh.md index 50c4b638fb..5c8cc05bd6 100644 --- a/packages/session/tool-working-directory/README.zh.md +++ b/packages/session/tool-working-directory/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -与 `tools` 和 `workingDirectory` 一起挂载。调用 `working_directory({})` 读取目录,或调用 `working_directory({ cd: "src" })` 改变目录。此工具没有配置字段。 +与 `tools` 和 `workingDirectory` 一起挂载。调用 `working_directory({})` 读取目录,或调用 `working_directory({ cd: "src" })` 改变目录。此工具没有配置字段。没有调用方 Agent 的调用以 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)失败,`cd` 为空以 `ToolArgsError`(`INVALID_ARGS`)失败,目录不存在时使用服务的 `WorkingDirectoryError` code。 ```yaml - name: '@deepseek-ai/dsh-tool-working-directory' diff --git a/packages/session/tool-working-directory/src/index.ts b/packages/session/tool-working-directory/src/index.ts index f0aa8bc4f0..89022d3e57 100644 --- a/packages/session/tool-working-directory/src/index.ts +++ b/packages/session/tool-working-directory/src/index.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-working-directory' /** Cordis plugin name. */ @@ -32,7 +32,8 @@ export function apply(ctx: Context): void { render: (_args, value) => [{ type: 'text', text: value.cwd }], }, async execute(args, exec) { - if (exec.agent === undefined) throw new Error('working_directory requires an agent Session') + if (exec.agent === undefined) throw new ToolContextUnavailableError('working_directory requires an agent Session') + if (args.cd === '') throw new ToolArgsError(['cd must not be empty; omit it to read the current directory']) const cwd = args.cd === undefined ? await ctx.workingDirectory.ensure(exec.agent, exec.signal) : await ctx.workingDirectory.set(exec.agent, args.cd, exec.signal) diff --git a/packages/session/working-directory/README.i18n.yaml b/packages/session/working-directory/README.i18n.yaml index fd50066fe2..3fd77f1c5c 100644 --- a/packages/session/working-directory/README.i18n.yaml +++ b/packages/session/working-directory/README.i18n.yaml @@ -18,8 +18,8 @@ en: e65405026debde7c zh: e12a648eb10ed16b /deepseek-ai-dsh-working-directory/understand-the-implementation: - en: f0458147d29631b1 - zh: 6a288670dd084ffb + en: 82d7372a58bb75c5 + zh: e18ee833daa99581 /deepseek-ai-dsh-working-directory/further-exploration: en: eabff683146786c8 zh: 7997dec17802ef64 diff --git a/packages/session/working-directory/README.md b/packages/session/working-directory/README.md index 44ed9ba9dc..1a70cfbe8f 100644 --- a/packages/session/working-directory/README.md +++ b/packages/session/working-directory/README.md @@ -41,7 +41,7 @@ Directory validation and publication delegate through `working-directory/access`
Implementation internals — click to expand -The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. +The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. Failures are `WorkingDirectoryError` instances: `WORKING_DIRECTORY_INVALID` for an empty or malformed path, `WORKING_DIRECTORY_NOT_FOUND` when a requested change names no existing directory, and `WORKING_DIRECTORY_UNAVAILABLE` when neither the current directory nor the original project exists or the service has been disposed. No runtime invariant companion is published because the service reads its only directory state from the Session projection and checks filesystem validity at use. diff --git a/packages/session/working-directory/README.zh.md b/packages/session/working-directory/README.zh.md index 9561d73972..b5f07d5577 100644 --- a/packages/session/working-directory/README.zh.md +++ b/packages/session/working-directory/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference"
实现细节 — 点击展开 -Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。 +Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。失败均为 `WorkingDirectoryError`:路径为空或格式错误时为 `WORKING_DIRECTORY_INVALID`,请求切换到不存在的目录时为 `WORKING_DIRECTORY_NOT_FOUND`,当前目录与原始项目都不存在或服务已释放时为 `WORKING_DIRECTORY_UNAVAILABLE`。 不发布 `./invariant` 伴生模块,因为服务从 Session 投影读取唯一的目录状态,并在使用时检查文件系统有效性。 diff --git a/packages/session/working-directory/src/error.ts b/packages/session/working-directory/src/error.ts new file mode 100644 index 0000000000..ec2a2ef7a2 --- /dev/null +++ b/packages/session/working-directory/src/error.ts @@ -0,0 +1,34 @@ +/** Machine-routable Session working-directory failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every {@link WorkingDirectoryError} code: + * - `WORKING_DIRECTORY_INVALID` — a requested directory is empty or otherwise malformed. + * - `WORKING_DIRECTORY_NOT_FOUND` — the requested directory does not exist or is not a directory; the caller picks another path. + * - `WORKING_DIRECTORY_UNAVAILABLE` — neither the current directory nor the original project exists, or the owning service was disposed. + */ +export const WORKING_DIRECTORY_ERROR_CODES = [ + 'WORKING_DIRECTORY_INVALID', + 'WORKING_DIRECTORY_NOT_FOUND', + 'WORKING_DIRECTORY_UNAVAILABLE', +] as const + +/** One code from {@link WORKING_DIRECTORY_ERROR_CODES}. */ +export type WorkingDirectoryErrorCode = typeof WORKING_DIRECTORY_ERROR_CODES[number] + +/** Working-directory failure carrying a stable {@link WorkingDirectoryErrorCode}. */ +export class WorkingDirectoryError extends HarnessError { + override readonly code: WorkingDirectoryErrorCode + + /** + * @param message - names the directory and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: WorkingDirectoryErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'WorkingDirectoryError' + this.code = code + } +} diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 62c3750c2c..7a0f66ede6 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -13,6 +13,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' +import { WorkingDirectoryError } from './error.ts' + +export { WORKING_DIRECTORY_ERROR_CODES, WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts' declare module '@deepseek-ai/cordis' { interface Events { @@ -125,7 +128,7 @@ export class WorkingDirectoryService extends Service { return assembly }, { prepend: true }) ctx.effect(() => async () => { - this.lifecycle.abort(new Error('working-directory service disposed')) + this.lifecycle.abort(new WorkingDirectoryError('working-directory service disposed', 'WORKING_DIRECTORY_UNAVAILABLE')) await Promise.allSettled(this.operations) }) } @@ -145,7 +148,8 @@ export class WorkingDirectoryService extends Service { * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise { return this.serialize(agent, signal, control => this.ctx.waterfall('working-directory/access', { agent, path: null, signal: control }, async () => { @@ -161,7 +165,10 @@ export class WorkingDirectoryService extends Service { return current } const original = agent.session.header.cwd ?? this.defaultDirectory - const restored = await this.directory(original, original, control) + const restored = await this.directory(original, original, control, () => new WorkingDirectoryError( + `working-directory: directory does not exist: ${JSON.stringify(original)}; the current working directory ${JSON.stringify(current)} is also unavailable`, + 'WORKING_DIRECTORY_UNAVAILABLE', + )) this.commit(agent, restored, true, control) return restored })) @@ -174,11 +181,13 @@ export class WorkingDirectoryService extends Service { * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise { return this.serialize(agent, signal, control => this.ctx.waterfall('working-directory/access', { agent, path, signal: control }, async () => { - if (path.length === 0) throw new Error('working-directory: cd must not be empty') + if (path.length === 0) throw new WorkingDirectoryError('working-directory: cd must not be empty', 'WORKING_DIRECTORY_INVALID') const directory = await this.directory(path, this.get(agent.session), control) this.commit(agent, directory, false, control) return directory @@ -189,13 +198,19 @@ export class WorkingDirectoryService extends Service { return `Current working directory: ${JSON.stringify(this.get(session))}.` } - private async directory(path: string, cwd: string, signal: AbortSignal): Promise { + private async directory( + path: string, + cwd: string, + signal: AbortSignal, + missing = (): WorkingDirectoryError => new WorkingDirectoryError( + `working-directory: directory does not exist: ${JSON.stringify(path)}`, + 'WORKING_DIRECTORY_NOT_FOUND', + ), + ): Promise { const target = await this.ctx.fs.resolve(path, { cwd, signal }) const info = await this.ctx.fs.stat(target, signal) signal.throwIfAborted() - if (info?.type !== 'directory') { - throw new Error(`working-directory: directory does not exist: ${JSON.stringify(path)}`) - } + if (info?.type !== 'directory') throw missing() return this.ctx.fs.processPath(target) } diff --git a/packages/session/working-directory/tests/working-directory.spec.ts b/packages/session/working-directory/tests/working-directory.spec.ts index 22fb99af47..d4769979cf 100644 --- a/packages/session/working-directory/tests/working-directory.spec.ts +++ b/packages/session/working-directory/tests/working-directory.spec.ts @@ -115,6 +115,11 @@ describe('Session working directories', () => { const agent = await f.harness.create(SessionId('removed-origin'), {}, { cwd: origin }) await rm(origin, { recursive: true }) await expect(f.ctx.workingDirectory.ensure(agent)).rejects.toThrow('directory does not exist') + await expect(f.ctx.workingDirectory.ensure(agent)).rejects.toMatchObject({ + name: 'WorkingDirectoryError', + code: 'WORKING_DIRECTORY_UNAVAILABLE', + message: expect.stringContaining('is also unavailable') as string, + }) await expect(f.ctx.workingDirectory.set(agent, f.root)).resolves.toBe(f.root) }) @@ -122,7 +127,9 @@ describe('Session working directories', () => { await using f = await fixture() await writeFile(join(f.root, 'file'), '') await expect(f.ctx.workingDirectory.set(f.agent, 'file')).rejects.toThrow('directory does not exist') + await expect(f.ctx.workingDirectory.set(f.agent, 'file')).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_NOT_FOUND' }) await expect(f.ctx.workingDirectory.set(f.agent, '')).rejects.toThrow('cd must not be empty') + await expect(f.ctx.workingDirectory.set(f.agent, '')).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_INVALID' }) const controller = new AbortController() controller.abort(new Error('cancelled directory change')) await expect(f.ctx.workingDirectory.set(f.agent, f.root, controller.signal)).rejects.toThrow('cancelled directory change') @@ -277,6 +284,7 @@ describe('Session working directories', () => { expect(f.agent.session.snapshotEvents().filter(event => event.type === 'working-directory/change')).toEqual([]) expect(f.harness.claim(f.agent, 'next-step', 1)).toEqual([]) await expect(directories.ensure(f.agent)).rejects.toThrow('working-directory service disposed') + await expect(directories.ensure(f.agent)).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_UNAVAILABLE' }) expect(probe).toHaveBeenCalledOnce() } finally { release.resolve(undefined) @@ -352,7 +360,18 @@ describe('Session working directories', () => { }) expect(await execute({})).toMatchObject({ value: { cwd: f.root }, isError: false }) expect(await execute({ cd: 'child' })).toMatchObject({ value: { cwd: join(f.root, 'child') }, isError: false }) - expect(await execute({}, false)).toMatchObject({ isError: true }) + expect(await execute({}, false)).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + expect(await execute({ cd: '' })).toMatchObject({ + isError: true, + error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } }, + }) + expect(await execute({ cd: 'missing' })).toMatchObject({ + isError: true, + error: { info: { name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_NOT_FOUND' } }, + }) await fiber.dispose() expect(f.ctx.tools.schemas().some(tool => tool.name === 'working_directory')).toBe(false) }) From c14b6860b4ad7fa67a6e15c17a477f11b74d5e7f Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:09:13 +0800 Subject: [PATCH 031/149] feat(jobs): code registry refusals and job tool argument failures jobs-local throws JobError for a missing controller (JOB_CONTROLLER_UNAVAILABLE), the owner limit (JOB_LIMIT_REACHED), a dead owner (JOB_OWNER_NOT_LIVE), an unknown id (JOB_NOT_FOUND), and a foreign caller (JOB_FOREIGN_SESSION), and ToolContextUnavailableError for an owned registration without the agent registry. An aborted wait rejects with an error named AbortError, so the tool registry records it as ABORTED. job_output and job_kill report an empty job_id, and job_output a non-positive timeout_ms, as ToolArgsError. Refs #7869 --- packages/jobs/jobs-local/README.i18n.yaml | 4 +-- packages/jobs/jobs-local/README.md | 2 +- packages/jobs/jobs-local/README.zh.md | 2 +- packages/jobs/jobs-local/package.json | 6 ++-- packages/jobs/jobs-local/src/index.ts | 30 +++++++++++++------ packages/jobs/jobs-local/tests/jobs.spec.ts | 14 ++++++++- packages/jobs/jobs-local/tsconfig.json | 3 ++ packages/jobs/tool-jobs/src/index.ts | 13 ++++++-- .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 13 ++++++-- pnpm-lock.yaml | 3 ++ 10 files changed, 68 insertions(+), 22 deletions(-) diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index dc37d78e1e..93045928da 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -33,8 +33,8 @@ en: ae926795d8aaa88f zh: 0e6ec0265eefccf7 /deepseek-ai-dsh-jobs-local/use-this-package/what-can-go-wrong: - en: e1f4511c458dff3b - zh: bea247c2a365a646 + en: 3737bc2cfa7f7012 + zh: 07bc090cbfa44584 /deepseek-ai-dsh-jobs-local/understand-the-implementation: en: f782557ca8da3138 zh: e8f0082b9692cacc diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index 3f0f80d296..1f753e7f93 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -64,7 +64,7 @@ Lifecycle facts are appended synchronously at the registration or settlement com ### What can go wrong -Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. +Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. Refusals are `JobError` instances: `JOB_CONTROLLER_UNAVAILABLE`, `JOB_LIMIT_REACHED`, `JOB_OWNER_NOT_LIVE`, `JOB_NOT_FOUND`, and `JOB_FOREIGN_SESSION`; an owned registration without the agent registry throws `ToolContextUnavailableError`. A wait whose signal aborts rejects with an error named `AbortError`. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. ----- diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index c00a6527d2..30dc26d3b0 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 可能出什么问题 -没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 +没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。拒绝均为 `JobError`:`JOB_CONTROLLER_UNAVAILABLE`、`JOB_LIMIT_REACHED`、`JOB_OWNER_NOT_LIVE`、`JOB_NOT_FOUND` 与 `JOB_FOREIGN_SESSION`;没有 agent 注册表时登记带所有者的 job 会抛出 `ToolContextUnavailableError`。signal 中止的等待以名为 `AbortError` 的错误拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 ----- diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index b635f6289d..b7e73d4753 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tools": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -51,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index daa4ce0db3..4e1a0959f6 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -17,7 +17,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' +import { JobError, JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' +import { ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKind, JobOutcome, JobOutputRead, JobOutputSource, JobRead, JobRegisteredData, JobSettleCause, JobSettledData, JobSpec, JobStatus, JobToolCall, JobView, @@ -117,6 +118,13 @@ interface TrackedJob { spillPaths: (string | undefined)[] } +/** The cancellation a wait reports when its caller's signal aborts; the name lets the tool registry classify it. */ +function waitAborted(): Error { + const error = new Error('wait aborted') + error.name = 'AbortError' + return error +} + /** True for the three terminal {@link JobStatus} values. */ function isTerminal(status: JobStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' @@ -207,7 +215,10 @@ export class LocalJobRegistry extends JobRegistry { start(spec: JobSpec): JobId { const owner = this.resolveOwner(spec.owner) if (!this.servesOwner(owner)) { - throw new Error('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)') + throw new JobError( + 'background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)', + 'JOB_CONTROLLER_UNAVAILABLE', + ) } if (spec.kind.length === 0) throw new Error('invalid job kind: expected a non-empty string') if (spec.label.length === 0) throw new Error('invalid job label: expected a non-empty string') @@ -219,8 +230,9 @@ export class LocalJobRegistry extends JobRegistry { const active = this.activeJobCount(owner) if (active >= this.maxConcurrentJobsPerOwner) { - throw new Error( + throw new JobError( `background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, wait for it to finish, then retry`, + 'JOB_LIMIT_REACHED', ) } @@ -362,11 +374,11 @@ export class LocalJobRegistry extends JobRegistry { if (session === undefined) return undefined const agents = this.selfCtx.get('agents') if (agents === undefined) { - throw new Error('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + throw new ToolContextUnavailableError('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') } const owner = agents.get(session) if (owner === undefined) { - throw new Error(`session "${session}" has no live agent (background job owner must be live)`) + throw new JobError(`session "${session}" has no live agent (background job owner must be live)`, 'JOB_OWNER_NOT_LIVE') } return owner } @@ -397,7 +409,7 @@ export class LocalJobRegistry extends JobRegistry { /** Look up a job and enforce caller access. */ private expect(id: JobId, caller?: SessionId): TrackedJob { const job = this.store.get(id) - if (job === undefined) throw new Error(`unknown job ${id}`) + if (job === undefined) throw new JobError(`unknown job ${id}`, 'JOB_NOT_FOUND') this.assertAccess(job, caller) return job } @@ -409,7 +421,7 @@ export class LocalJobRegistry extends JobRegistry { */ private assertAccess(job: TrackedJob, caller: SessionId | undefined): void { if (job.owner !== undefined && job.owner.id !== caller) { - throw new Error(`job ${job.id} belongs to another session`) + throw new JobError(`job ${job.id} belongs to another session`, 'JOB_FOREIGN_SESSION') } } @@ -498,7 +510,7 @@ export class LocalJobRegistry extends JobRegistry { throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) } if (!isTerminal(job.status)) { - if (signal?.aborted) throw new Error('wait aborted') + if (signal?.aborted) throw waitAborted() // The scoped deadline distinguishes a successful wait timeout from // caller cancellation and clears its timer on every exit. using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) @@ -517,7 +529,7 @@ export class LocalJobRegistry extends JobRegistry { if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { resolve() } else { - reject(new Error('wait aborted')) + reject(waitAborted()) } } job.waitResolvers.add(onSettled) diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 0027549fb1..521c7647d6 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -11,7 +11,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' import type { - JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, + JobErrorCode, JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -172,6 +172,9 @@ function scriptedSource(reads: { text: string; lossy?: boolean }[], channel?: 's } } +/** The structured identity a registry refusal carries. */ +const jobError = (code: JobErrorCode): { name: 'JobError'; code: JobErrorCode } => ({ name: 'JobError', code }) + describe('LocalJobRegistry.start', () => { it('preserves the SessionId brand on public owner projections', () => { expectTypeOf().toEqualTypeOf() @@ -182,6 +185,7 @@ describe('LocalJobRegistry.start', () => { await ctx.plugin(LocalJobRegistry) expect(() => ctx.jobs.start(producer().spec)) .toThrow('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)') + expect(() => ctx.jobs.start(producer().spec)).toThrow(expect.objectContaining(jobError('JOB_CONTROLLER_UNAVAILABLE'))) }) it('refuses an owner whose own composition attaches no controller', async () => { @@ -255,6 +259,7 @@ describe('LocalJobRegistry.start', () => { const run = vi.fn((job: JobHandle) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') + expect(() => ctx.jobs.start({ ...blocked.spec, run })).toThrow(expect.objectContaining(jobError('JOB_LIMIT_REACHED'))) expect(run).not.toHaveBeenCalled() for (const job of live) job.settle({ status: 'completed' }) }) @@ -414,6 +419,7 @@ describe('LocalJobRegistry reads and settlement', () => { const ctx = await harness() expect(() => ctx.jobs.read(JobId('bash-99'))).toThrow('unknown job bash-99') expect(() => ctx.jobs.get(JobId('bash-99'))).toThrow('unknown job bash-99') + expect(() => ctx.jobs.get(JobId('bash-99'))).toThrow(expect.objectContaining(jobError('JOB_NOT_FOUND'))) }) it('announces settlement once with containment across listeners', async () => { @@ -601,11 +607,13 @@ describe('LocalJobRegistry.wait', () => { const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) controller.abort() await expect(wait).rejects.toThrow('wait aborted') + await expect(wait).rejects.toMatchObject({ name: 'AbortError' }) expect(ctx.jobs.list()[0]).toMatchObject({ status: 'running' }) const preAborted = new AbortController() preAborted.abort() await expect(ctx.jobs.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted') + await expect(ctx.jobs.wait(id, 5_000, undefined, preAborted.signal)).rejects.toMatchObject({ name: 'AbortError' }) }) it('an abort racing settlement in the same tick rejects the wait and leaves the settlement intact', async () => { @@ -736,6 +744,7 @@ describe('LocalJobRegistry owner isolation', () => { expect(() => foreign.kill(owned, other.id)).toThrow('belongs to another session') await expect(foreign.wait(owned, 10, other.id)).rejects.toThrow('belongs to another session') expect(() => ctx.jobs.read(owned)).toThrow('belongs to another session') + expect(() => ctx.jobs.read(owned)).toThrow(expect.objectContaining(jobError('JOB_FOREIGN_SESSION'))) }) it('list() shows only caller-owned plus unowned jobs', async () => { @@ -758,6 +767,8 @@ describe('LocalJobRegistry owner isolation', () => { ctx.jobs.attachController('test-controller') expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) .toThrow('background job ownership requires the agent registry') + expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) + .toThrow(expect.objectContaining({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' })) // The failed registration mutated nothing: no stored job. expect(ctx.jobs.list()).toEqual([]) expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f]{16}$/) @@ -770,6 +781,7 @@ describe('LocalJobRegistry owner isolation', () => { // Owner resolution precedes registry mutation and cleanup attachment. expect(() => ctx.jobs.start(producer({ owner: ghost }).spec)) .toThrow('has no live agent') + expect(() => ctx.jobs.start(producer({ owner: ghost }).spec)).toThrow(expect.objectContaining(jobError('JOB_OWNER_NOT_LIVE'))) expect(ctx.jobs.list(ghost.id)).toEqual([]) // A later valid registration must still attach cleanup for the live instance. diff --git a/packages/jobs/jobs-local/tsconfig.json b/packages/jobs/jobs-local/tsconfig.json index f94627dd8f..892c4da43e 100644 --- a/packages/jobs/jobs-local/tsconfig.json +++ b/packages/jobs/jobs-local/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../jobs" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index dff4681cf1..8da4efea17 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -15,7 +15,7 @@ import z from '@deepseek-ai/schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContextFormed, MessageId } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-output-retention' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { JobId } from '@deepseek-ai/dsh-jobs' import type { JobEvent, JobView, JobRead } from '@deepseek-ai/dsh-jobs' @@ -208,7 +208,7 @@ function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefin /** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */ function validateJobId(value: string): JobId { if (value.length === 0) { - throw new Error(`invalid job_id: expected a non-empty string, got ${JSON.stringify(value)}`) + throw new ToolArgsError([`invalid job_id: expected a non-empty string, got ${JSON.stringify(value)}`]) } return JobId(value) } @@ -457,6 +457,9 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { const id = validateJobId(args.job_id) + if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) { + throw new ToolArgsError([`invalid timeout_ms: expected a positive number of milliseconds, got ${JSON.stringify(args.timeout_ms)}`]) + } const jobs = ctx.jobs const agent = exec.agent // A wait this execution ends on next-step input claims the read that @@ -509,7 +512,11 @@ export function apply(ctx: Context, config: Config): void { // An input released this wait, so the read below carries the // current state; a cancellation leaves no consumer for that read, // and the claim's release delivers the settlement it withheld. - if (exec.signal.aborted) throw new Error('wait aborted') + if (exec.signal.aborted) { + const aborted = new Error('wait aborted') + aborted.name = 'AbortError' + throw aborted + } } finally { unsubscribe() } diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 4d2592a880..e782de33e2 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -569,10 +569,17 @@ describe('job_output', () => { it('rejects an empty or unknown job id as an errored result', async () => { const { ctx } = await setup() - expect((await call(ctx, 'job_output', { job_id: '' })).isError).toBe(true) + expect(await call(ctx, 'job_output', { job_id: '' })).toMatchObject({ + isError: true, + error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } }, + }) const unknown = await call(ctx, 'job_output', { job_id: 'bash-99' }) - expect(unknown.isError).toBe(true) + expect(unknown).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_NOT_FOUND' } } }) expect(text(unknown)).toContain('unknown job bash-99') + expect(await call(ctx, 'job_output', { job_id: 'bash-99', wait: true, timeout_ms: 0 })).toMatchObject({ + isError: true, + error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } }, + }) }) }) @@ -1153,7 +1160,7 @@ describe('completion notices', () => { await tick() aborter.abort() const result = await pending - expect(result.isError).toBe(true) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } }) expect(text(result)).toContain('wait aborted') p.settle({ status: 'completed' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1884575900..90d655e2af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13140,6 +13140,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools packages/jobs/tool-jobs: dependencies: From dbcab765f95ada1fb0eb09ba304bfefcd86d4513 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:29:33 +0800 Subject: [PATCH 032/149] fix(shell): keep coded failures on the bash and pwsh job path A foreground bash or pwsh call that waits on a job rethrew a failed preparation as new Error(view.detail), dropping the original class and code, so even SandboxUnavailableError reached tool/result without error.name or error.code. processJob now keeps the original error for a failed job and the tool rethrows it; launch and spawn rejections on every path keep a HarnessError's identity, and a platform spawn error becomes SubprocessError (SUBPROCESS_SPAWN_NOT_FOUND or _SPAWN_FAILED). Argument violations are ToolArgsError, a missing sandboxing executor for sandbox_permissions is SandboxEscalationError SANDBOX_ESCALATION_UNAVAILABLE, a disabled background call is JobError JOB_BACKGROUND_DISABLED, and a missing job runtime is ToolContextUnavailableError. Refs #7869 --- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/package.json | 6 +- packages/shell/tool-bash/src/background.ts | 21 ++++++- packages/shell/tool-bash/src/index.ts | 59 ++++++++++++++----- .../shell/tool-bash/tests/background.spec.ts | 44 +++++++++++++- packages/shell/tool-bash/tests/tools.spec.ts | 44 ++++++++++++-- packages/shell/tool-bash/tsconfig.json | 3 + packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/package.json | 6 +- packages/shell/tool-pwsh/src/background.ts | 21 ++++++- packages/shell/tool-pwsh/src/index.ts | 59 ++++++++++++++----- .../shell/tool-pwsh/tests/background.spec.ts | 51 +++++++++++++++- packages/shell/tool-pwsh/tests/tools.spec.ts | 44 +++++++++++++- packages/shell/tool-pwsh/tsconfig.json | 3 + pnpm-lock.yaml | 6 ++ 19 files changed, 322 insertions(+), 61 deletions(-) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index c1244306b1..625910149b 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -111,8 +111,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/what-the-model-sees: - en: d6d7068e23fc1b93 - zh: 515e8aa586415179 + en: f4cebcfd09cd3b13 + zh: 4e3954f417b4a9ed /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/token-effect: en: a3a78ef476ec5e56 zh: dd2c8ad0f69e8ff1 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 045344c881..411d5da79f 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -196,7 +196,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). #### Token effect diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 53d2b1984e..7f222c91aa 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -196,7 +196,7 @@ renderer 输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` #### 模型看到什么 -验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。 +验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 #### Token 影响 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 3d7102e3a9..b2314e05f9 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -73,6 +74,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" } } diff --git a/packages/shell/tool-bash/src/background.ts b/packages/shell/tool-bash/src/background.ts index 49da48b00b..8835c6603c 100644 --- a/packages/shell/tool-bash/src/background.ts +++ b/packages/shell/tool-bash/src/background.ts @@ -11,6 +11,16 @@ import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sand import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' import type { JobChunk, JobHooks, JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' +/** Job hooks plus the original error of a job that failed before its process produced an outcome. */ +export interface ProcessJobHooks extends JobHooks { + /** + * The error that ended a `failed` job, kept with its own class and code; + * undefined while the job runs and for a completed or killed job. + * @returns the original failure, boxed so any thrown value is preserved. + */ + failure(): { error: unknown } | undefined +} + /** * Sandbox facts worth the terminal detail: a runner that never ran the * command, or a denial (with the escalation hint this composition offers). @@ -94,14 +104,16 @@ export function ringDelta(chunks: readonly JobChunk[]): string { * Adapt asynchronous shell preparation after job admission without exposing a partial process. * @param start - starts the process with job-owned cancellation. * @param outcome - projects the settled process into the job outcome. - * @returns synchronous job hooks whose completion includes preparation and process settlement. + * @returns synchronous job hooks whose completion includes preparation and process settlement, + * and the original error of a failed preparation for a caller that reports it. */ export function processJob( start: (signal: AbortSignal) => Promise, outcome: (process: ShellProcess) => JobOutcome, -): JobHooks { +): ProcessJobHooks { const controller = new AbortController() let process: ShellProcess | undefined + let failure: { error: unknown } | undefined const done: Promise = (async () => { try { process = await start(controller.signal) @@ -112,8 +124,10 @@ export function processJob( } return outcome(process) } catch (error: unknown) { + const killed = controller.signal.aborted && process === undefined + if (!killed) failure = { error } return { - status: controller.signal.aborted && process === undefined ? 'killed' : 'failed', + status: killed ? 'killed' : 'failed', detail: error instanceof Error ? error.message : String(error), } } @@ -125,5 +139,6 @@ export function processJob( process?.kill() }, done, + failure: () => failure, } } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 39a2a20369..46554fc192 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -16,14 +16,16 @@ import { FiberState } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { isAbsolute, sep } from 'node:path' -import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import { defineTool, TOOL_ABORTED, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { JobError } from '@deepseek-ai/dsh-jobs' import type { JobId, JobRegistry, JobView } from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-shell-env' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { ESCALATION_TARGETS, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, SandboxEscalationError, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { subprocessSpawnFailure } from '@deepseek-ai/dsh-subprocess' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' @@ -72,13 +74,13 @@ interface BashToolArgs { function validateBashArgs(args: BashToolArgs, effectiveMode: SandboxMode | undefined): void { if (args.command.trim().length === 0) { - throw new Error('invalid command: expected a non-empty string') + throw new ToolArgsError(['invalid command: expected a non-empty string']) } if (args.description.trim().length === 0) { - throw new Error('invalid description: expected a non-empty string') + throw new ToolArgsError(['invalid description: expected a non-empty string']) } if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { - throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + throw new ToolArgsError([`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`]) } if (args.sandbox_permissions !== undefined && args.sandbox_permissions === effectiveMode) return const justification = args.sandbox_permissions === undefined && args.justification?.trim() === '' @@ -185,11 +187,23 @@ function toolAborted(): HarnessError { return error } +/** + * Report a launch or preparation rejection as this tool's failure. A coded + * error keeps its class and code; this call's cancellation stays as thrown so + * the registry records it as aborted; any other value is classified as a + * subprocess spawn failure with the original as `cause`. + */ +function launchFailure(error: unknown, signal: AbortSignal): unknown { + return signal.aborted ? error : subprocessSpawnFailure(error, 'bash') +} + /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ interface StartedJob { readonly id: JobId readonly process: () => ShellExecution | undefined readonly stopped: () => string | undefined + /** The original error of a registration whose preparation failed before the process published. */ + readonly failure: () => { error: unknown } | undefined } /** Canonical background-handle properties shared by the bash output union. */ @@ -232,7 +246,10 @@ export function apply(ctx: Context, config: Config = {}): void { standingPolicy: SandboxExecutionPolicy | undefined, ): Promise => { if (escalationModes.length === 0) { - throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + throw new SandboxEscalationError( + 'sandbox_permissions is not available in this composition (no sandboxing executor to escalate)', + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode return approveEscalation( @@ -266,6 +283,8 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: BashToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined + // The registry runs the starter inside start(), so this is assigned before start() returns. + let failure!: () => { error: unknown } | undefined const id = registry.start({ kind: 'bash', label: args.command, @@ -280,6 +299,7 @@ export function apply(ctx: Context, config: Config = {}): void { }, started => processOutcome(started, escalationModes), ) + failure = () => hooks.failure() return { done: hooks.done, // A kill from outside this call (the human's, a parallel job_kill) @@ -291,7 +311,7 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return { id, process: () => proc, stopped: () => stopped } + return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { @@ -357,11 +377,14 @@ export function apply(ctx: Context, config: Config = {}): void { // the split streams and classification the foreground result needs. registry.remove(attached.id, owner) const process = attached.process() - // No process was published: the starter failed before the spawn, and the - // job's terminal detail is the failure the foreground projection would - // have rejected with. - if (process === undefined) throw new Error(view.detail) - const result = await process.result() + // No process was published: the starter failed before the spawn. The + // original error keeps its class and code, exactly as the foreground + // projection would have rejected; a kill before the spawn has only its detail. + if (process === undefined) { + const failure = attached.failure() + throw failure === undefined ? new Error(view.detail) : launchFailure(failure.error, exec.signal) + } + const result = await process.result().catch((error: unknown) => { throw launchFailure(error, exec.signal) }) const stopped = attached.stopped() return { kind: 'foreground' as const, ...canonicalBashResult(result, spec.workdir), ...stopped !== undefined ? { stopped } : {} } } @@ -498,10 +521,10 @@ export function apply(ctx: Context, config: Config = {}): void { if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. if (!backgroundEnabled) { - throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + throw new JobError('run_in_background is disabled for this deployment (enableRunInBackground: false)', 'JOB_BACKGROUND_DISABLED') } if (jobs === undefined) { - throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') + throw new ToolContextUnavailableError('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') } // The caller owns cancellation until ctx.jobs commits detached ownership. if (exec.signal.aborted) throw toolAborted() @@ -524,8 +547,12 @@ export function apply(ctx: Context, config: Config = {}): void { if (attached !== undefined) return waitOnJob(jobs, attached, exec, spec) } const spec = ctx.shell.resolve({ ...request, signal: exec.signal }) - const foreground = await ctx.shell.execute(spec) - const result = await foreground.result() + let result: ShellRunResult + try { + result = await (await ctx.shell.execute(spec)).result() + } catch (error) { + throw launchFailure(error, exec.signal) + } if (result.aborted) throw toolAborted() return { kind: 'foreground' as const, ...canonicalBashResult(result, spec.workdir) } }, diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index add9714ac4..58129b3a0b 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -19,6 +19,7 @@ import type { JobId } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import type { ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processSources, ringDelta } from '../src/background.ts' @@ -279,10 +280,49 @@ describe('foreground commands as jobs', () => { it('a command whose start fails reports the failure as an error result and leaves no job behind', async () => { const ctx = await setup() - vi.spyOn(ctx.shell, 'execute').mockRejectedValue(new Error('spawn bash ENOENT')) + vi.spyOn(ctx.shell, 'execute').mockRejectedValue(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' })) const result = await call(ctx, { command: 'true', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + expect(text(result)).toBe('Error: cannot start "bash": the program or the working directory does not exist (spawn bash ENOENT)') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a spawn that fails after the process published reports a coded failure and leaves no job behind', async () => { + const ctx = await setup() + const result = await call(ctx, { command: 'true', description: 'test command', workdir: join(spillDir, 'missing-workdir') }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a job stopped from outside before its process published reports the stop', async () => { + const ctx = await setup() + vi.spyOn(ctx.shell, 'execute').mockImplementation(spec => new Promise((_resolve, reject) => { + spec.signal?.addEventListener('abort', () => { reject(new Error('preparation stopped')) }, { once: true }) + })) + const pending = call(ctx, { command: 'ls', description: 'test command' }) + await vi.waitFor(() => { expect(ctx.jobs.list()).toHaveLength(1) }) + ctx.jobs.kill(ctx.jobs.list()[0]!.id, undefined, 'cancelled by the user') + const result = await pending expect(result.isError).toBe(true) - expect(text(result)).toBe('Error: spawn bash ENOENT') + expect((result.content[0] as { text: string }).text).toBe('Error: preparation stopped; cancelled by the user') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a coded preparation failure keeps its name and code through the job path', async () => { + const ctx = await setup() + vi.spyOn(ctx.shell, 'execute').mockRejectedValue(new SandboxUnavailableError('workspace-write', 'bwrap: setting up uid map: Permission denied')) + const result = await call(ctx, { command: 'true', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' } }, + }) + expect(text(result)).toContain('Runner failure: bwrap: setting up uid map: Permission denied') expect(ctx.jobs.list()).toEqual([]) }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index d3f20572f8..554b7f4e35 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -394,7 +394,7 @@ describe('bash tool', () => { ])('rejects value-invalid args %j', async (args, pattern) => { const ctx = await setup() const result = await call(ctx, 'bash', args) - expect(result.isError).toBe(true) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) expect(text(result)).toMatch(pattern) }) @@ -640,7 +640,10 @@ describe('background execution through the job runtime', () => { it('fails loud when the job runtime is not loaded', async () => { const ctx = await setup() // no LocalJobRegistry / ToolJobs const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) - expect(result.isError).toBe(true) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') }) @@ -717,7 +720,7 @@ describe('background execution through the job runtime', () => { // Schema omission is advertising; execution must also enforce the opt-out. const forced = await call(ctx, 'bash', { command: 'echo hi', description: 'test command', run_in_background: true }) - expect(forced.isError).toBe(true) + expect(forced).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_BACKGROUND_DISABLED' } } }) expect(text(forced)).toContain('run_in_background is disabled for this deployment') const foreground = await call(ctx, 'bash', { command: 'echo hi', description: 'test command' }) expect(foreground.isError).toBe(false) @@ -755,7 +758,10 @@ describe('sandbox escalation through the generic task producer', () => { { command: 'true', description: 'd', justification: 'why' }, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' }, ]) { - expect((await call(ctx, 'bash', args)).isError).toBe(true) + expect(await call(ctx, 'bash', args)).toMatchObject({ + isError: true, + error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_INVALID_ARGUMENT' } }, + }) } }) @@ -793,7 +799,9 @@ describe('sandbox escalation through the generic task producer', () => { it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => { const plain = await setup() - expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition') + const unavailable = await call(plain, 'bash', escalate) + expect(text(unavailable)).toContain('not available in this composition') + expect(unavailable).toMatchObject({ error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_UNAVAILABLE' } } }) const { ctx } = await setupSandboxed(true) const prompted = vi.fn() @@ -1543,3 +1551,29 @@ describe('resolved launch directory metadata', () => { } }) }) + +describe('launch failures without a job registry', () => { + it('classifies a platform spawn error and keeps this call\'s cancellation', async () => { + const ctx = await setup() + const missing = vi.spyOn(ctx.shell, 'execute').mockRejectedValueOnce(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' })) + expect(await call(ctx, 'bash', { command: 'true', description: 'd' })).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + missing.mockRestore() + + const controller = new AbortController() + const reason = new Error('caller stopped') + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(reason) + return Promise.reject(reason) + }) + const aborted = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted'), + name: 'bash', + arguments: { command: 'true', description: 'd' }, + }) + expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) + }) +}) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 5efa5088e1..42510cdbdd 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../subprocess/subprocess" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index ed32de4eef..8032f1b349 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -108,8 +108,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/what-the-model-sees: - en: f0802a10dbe0f7d2 - zh: 80f8b312dcfc0065 + en: d45b05d98cf6e7e3 + zh: b3c59fb3603f511e /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/token-effect: en: 243bad012dd99c7f zh: 5ee74905020bd49a diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 051f3cd4a2..34bc941529 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -188,7 +188,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). #### Token effect diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 90e2b726c1..f81c92a602 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -188,7 +188,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### 模型看到什么 -验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。 +验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 #### Token 影响 diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 360190080e..284751c48c 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -72,6 +73,7 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", - "@deepseek-ai/dsh-fs-local": "workspace:*" + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" } } diff --git a/packages/shell/tool-pwsh/src/background.ts b/packages/shell/tool-pwsh/src/background.ts index 67d937df79..120e595b04 100644 --- a/packages/shell/tool-pwsh/src/background.ts +++ b/packages/shell/tool-pwsh/src/background.ts @@ -11,6 +11,16 @@ import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sand import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' import type { JobChunk, JobHooks, JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' +/** Job hooks plus the original error of a job that failed before its process produced an outcome. */ +export interface ProcessJobHooks extends JobHooks { + /** + * The error that ended a `failed` job, kept with its own class and code; + * undefined while the job runs and for a completed or killed job. + * @returns the original failure, boxed so any thrown value is preserved. + */ + failure(): { error: unknown } | undefined +} + /* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ /** @@ -96,14 +106,16 @@ export function ringDelta(chunks: readonly JobChunk[]): string { * Adapt asynchronous shell preparation after job admission without exposing a partial process. * @param start - starts the process with job-owned cancellation. * @param outcome - projects the settled process into the job outcome. - * @returns synchronous job hooks whose completion includes preparation and process settlement. + * @returns synchronous job hooks whose completion includes preparation and process settlement, + * and the original error of a failed preparation for a caller that reports it. */ export function processJob( start: (signal: AbortSignal) => Promise, outcome: (process: ShellProcess) => JobOutcome, -): JobHooks { +): ProcessJobHooks { const controller = new AbortController() let process: ShellProcess | undefined + let failure: { error: unknown } | undefined const done: Promise = (async () => { try { process = await start(controller.signal) @@ -114,8 +126,10 @@ export function processJob( } return outcome(process) } catch (error: unknown) { + const killed = controller.signal.aborted && process === undefined + if (!killed) failure = { error } return { - status: controller.signal.aborted && process === undefined ? 'killed' : 'failed', + status: killed ? 'killed' : 'failed', detail: error instanceof Error ? error.message : String(error), } } @@ -127,6 +141,7 @@ export function processJob( process?.kill() }, done, + failure: () => failure, } } /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index a3472b4357..0ef0fa260b 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -25,14 +25,16 @@ import type {} from '@deepseek-ai/dsh-working-directory' import { FiberState } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import { defineTool, TOOL_ABORTED, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { JobError } from '@deepseek-ai/dsh-jobs' import type { JobId, JobRegistry, JobView } from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-shell-env' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { ESCALATION_TARGETS, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, SandboxEscalationError, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { subprocessSpawnFailure } from '@deepseek-ai/dsh-subprocess' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' @@ -106,13 +108,13 @@ interface PwshForegroundResult { /* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ function validatePwshArgs(args: PwshToolArgs): void { if (args.command.trim().length === 0) { - throw new Error('invalid command: expected a non-empty string') + throw new ToolArgsError(['invalid command: expected a non-empty string']) } if (args.description.trim().length === 0) { - throw new Error('invalid description: expected a non-empty string') + throw new ToolArgsError(['invalid description: expected a non-empty string']) } if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { - throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + throw new ToolArgsError([`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`]) } validateEscalationArgs(args.sandbox_permissions, args.justification) } @@ -193,11 +195,23 @@ function toolAborted(): HarnessError { return error } +/** + * Report a launch or preparation rejection as this tool's failure. A coded + * error keeps its class and code; this call's cancellation stays as thrown so + * the registry records it as aborted; any other value is classified as a + * subprocess spawn failure with the original as `cause`. + */ +function launchFailure(error: unknown, signal: AbortSignal): unknown { + return signal.aborted ? error : subprocessSpawnFailure(error, 'pwsh') +} + /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ interface StartedJob { readonly id: JobId readonly process: () => ShellExecution | undefined readonly stopped: () => string | undefined + /** The original error of a registration whose preparation failed before the process published. */ + readonly failure: () => { error: unknown } | undefined } /** Canonical background-handle properties shared by the pwsh output union. */ @@ -245,7 +259,10 @@ export function apply(ctx: Context, config: Config = {}): void { standingPolicy: SandboxExecutionPolicy | undefined, ): Promise => { if (escalationModes.length === 0) { - throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + throw new SandboxEscalationError( + 'sandbox_permissions is not available in this composition (no sandboxing executor to escalate)', + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode return approveEscalation( @@ -281,6 +298,8 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: PwshToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined + // The registry runs the starter inside start(), so this is assigned before start() returns. + let failure!: () => { error: unknown } | undefined const id = registry.start({ kind: 'pwsh', label: args.command, @@ -295,6 +314,7 @@ export function apply(ctx: Context, config: Config = {}): void { }, started => processOutcome(started, escalationModes), ) + failure = () => hooks.failure() return { done: hooks.done, // A kill from outside this call (the human's, a parallel job_kill) @@ -306,7 +326,7 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return { id, process: () => proc, stopped: () => stopped } + return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { @@ -372,11 +392,14 @@ export function apply(ctx: Context, config: Config = {}): void { // the split streams and classification the foreground result needs. registry.remove(attached.id, owner) const process = attached.process() - // No process was published: the starter failed before the spawn, and the - // job's terminal detail is the failure the foreground projection would - // have rejected with. - if (process === undefined) throw new Error(view.detail) - const result = await process.result() + // No process was published: the starter failed before the spawn. The + // original error keeps its class and code, exactly as the foreground + // projection would have rejected; a kill before the spawn has only its detail. + if (process === undefined) { + const failure = attached.failure() + throw failure === undefined ? new Error(view.detail) : launchFailure(failure.error, exec.signal) + } + const result = await process.result().catch((error: unknown) => { throw launchFailure(error, exec.signal) }) const stopped = attached.stopped() return { ...canonicalPwshResult(result, spec.workdir), ...stopped !== undefined ? { stopped } : {} } } @@ -521,10 +544,10 @@ export function apply(ctx: Context, config: Config = {}): void { if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. if (!backgroundEnabled) { - throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + throw new JobError('run_in_background is disabled for this deployment (enableRunInBackground: false)', 'JOB_BACKGROUND_DISABLED') } if (jobs === undefined) { - throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') + throw new ToolContextUnavailableError('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') } // The caller owns cancellation until ctx.jobs commits detached ownership. if (exec.signal.aborted) throw toolAborted() @@ -547,8 +570,12 @@ export function apply(ctx: Context, config: Config = {}): void { if (attached !== undefined) return waitOnJob(jobs, attached, exec, spec) } const spec = ctx.shell.resolve({ ...request, signal: exec.signal }) - const foreground = await ctx.shell.execute(spec) - const result = await foreground.result() + let result: ShellRunResult + try { + result = await (await ctx.shell.execute(spec)).result() + } catch (error) { + throw launchFailure(error, exec.signal) + } if (result.aborted) throw toolAborted() return canonicalPwshResult(result, spec.workdir) }, diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 2794df944a..48b01dde16 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -13,6 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { unsupportedModel, provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import { SandboxPreparationError } from '@deepseek-ai/dsh-sandbox' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell' import { renderPwshPromoted } from '../src/render.ts' @@ -346,8 +347,56 @@ describe('foreground commands as jobs (pwsh)', () => { const { ctx, pwsh } = await setup() pwsh.backgroundHandler = () => { throw new Error('spawn pwsh ENOENT') } const result = await call(ctx, { command: 'Get-Missing', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_FAILED' } }, + }) + expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh ENOENT') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a spawn that fails after the process published reports a coded failure', async () => { + const { ctx, pwsh } = await setup() + pwsh.backgroundHandler = () => { + const started = observableProcess() + started.finish() + return Object.assign(started.proc, { + result: () => Promise.reject(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })), + }) + } + const result = await call(ctx, { command: 'Get-ChildItem', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a job stopped from outside before its process published reports the stop', async () => { + const { ctx, pwsh } = await setup() + vi.spyOn(pwsh, 'execute').mockImplementation(spec => new Promise((_resolve, reject) => { + spec.signal?.addEventListener('abort', () => { reject(new Error('preparation stopped')) }, { once: true }) + })) + const pending = call(ctx, { command: 'Get-ChildItem', description: 'test command' }) + await vi.waitFor(() => { expect(ctx.jobs.list()).toHaveLength(1) }) + ctx.jobs.kill(ctx.jobs.list()[0]!.id, undefined, 'cancelled by the user') + const result = await pending expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toBe('Error: spawn pwsh ENOENT') + expect((result.content[0] as { text: string }).text).toBe('Error: preparation stopped; cancelled by the user') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a Windows ACL preparation failure keeps its name and code through the job path', async () => { + const { ctx, pwsh } = await setup() + const cause = new Error('SetNamedSecurityInfoW failed (Win32 5): C:\\ws') + pwsh.backgroundHandler = () => { + throw new SandboxPreparationError('sandbox-local: windows-acl workspace grant failed', 'SANDBOX_PREPARATION_FAILED', { cause }) + } + const result = await call(ctx, { command: 'Get-ChildItem', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED' } }, + }) expect(ctx.jobs.list()).toEqual([]) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 731da47a4c..00fb01724f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -434,6 +434,13 @@ describe('argument validation', () => { expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string') expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }))) .toContain('invalid timeoutMs: expected a positive number') + for (const args of [ + { command: ' ', description: 'd' }, + { command: 'Write-Output hi', description: ' ' }, + { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }, + ]) { + expect(await call(ctx, 'pwsh', args)).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) + } }) }) @@ -676,7 +683,9 @@ describe('sandbox escalation through ctx.approval', () => { it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => { const plain = await setup() - expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition') + const unavailable = await call(plain.ctx, 'pwsh', escalate) + expect(text(unavailable)).toContain('not available in this composition') + expect(unavailable).toMatchObject({ error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_UNAVAILABLE' } } }) const { ctx } = await setupSandboxed(true) const prompted = vi.fn() @@ -861,7 +870,10 @@ describe('background execution through the job runtime', () => { it('fails loud when the job runtime is not loaded', async () => { const { ctx } = await setup() // no LocalJobRegistry / ToolJobs const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) - expect(result.isError).toBe(true) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') }) @@ -912,7 +924,7 @@ describe('background execution through the job runtime', () => { // Schema omission is advertising; execution must also enforce the opt-out. const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) - expect(forced.isError).toBe(true) + expect(forced).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_BACKGROUND_DISABLED' } } }) expect(text(forced)).toContain('run_in_background is disabled for this deployment') const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) expect(foreground.isError).toBe(false) @@ -1280,3 +1292,29 @@ describe('resolved launch directory metadata', () => { } }) }) + +describe('launch failures without a job registry', () => { + it('classifies a platform spawn error and keeps this call\'s cancellation', async () => { + const { ctx } = await setup() + const missing = vi.spyOn(ctx.shell, 'execute').mockRejectedValueOnce(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })) + expect(await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'd' })).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + missing.mockRestore() + + const controller = new AbortController() + const reason = new Error('caller stopped') + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(reason) + return Promise.reject(reason) + }) + const aborted = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'd' }, + }) + expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) + }) +}) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 80a56fcda6..4015c265db 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../subprocess/subprocess" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90d655e2af..4b81d0ed5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15586,6 +15586,9 @@ importers: '@deepseek-ai/dsh-shell-env': specifier: workspace:* version: link:../shell-env + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local @@ -15735,6 +15738,9 @@ importers: '@deepseek-ai/dsh-shell-env': specifier: workspace:* version: link:../shell-env + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local From 3538757cbea0decda47b20d34f7c28bdf17fa6ca Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:35:58 +0800 Subject: [PATCH 033/149] feat(terminal): code PTY session and persistent shell failures TerminalError gains SESSION_CLOSING, SESSION_EXITED, STARTUP_EXITED, STARTUP_TIMEOUT, NO_SANDBOX, and INIT_REJECTED, following its existing unprefixed code style. The terminal service and terminal-bash use them for a send to a closing or exited session, a shell that exits or misses readiness during startup, and a confined mode without a sandbox provider; an empty confined argv is SubprocessError SUBPROCESS_INVALID_ARGV, and a send on an aborted signal throws an error named AbortError. The persistent bash and pwsh tools report a missing owner as ToolContextUnavailableError, a blank command as ToolArgsError, and a shell that rejects its setup as TerminalError INIT_REJECTED. Refs #7869 --- .../tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-bash-persistent/README.md | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- .../shell/tool-bash-persistent/src/index.ts | 9 +++++---- .../tool-bash-persistent/tests/tools.spec.ts | 14 +++++++++++--- .../tool-pwsh-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh-persistent/README.md | 2 +- .../shell/tool-pwsh-persistent/README.zh.md | 2 +- .../shell/tool-pwsh-persistent/src/index.ts | 6 +++--- .../tool-pwsh-persistent/tests/tools.spec.ts | 9 +++++++-- packages/terminal/terminal-bash/src/index.ts | 18 ++++++++++++------ packages/terminal/terminal-bash/src/session.ts | 17 +++++++++++------ .../terminal/terminal-bash/tests/index.spec.ts | 4 ++++ .../terminal-bash/tests/session.spec.ts | 11 ++++++++--- packages/terminal/terminal/README.i18n.yaml | 4 ++-- packages/terminal/terminal/README.md | 2 +- packages/terminal/terminal/README.zh.md | 2 +- packages/terminal/terminal/src/index.ts | 17 +++++++++++++++-- .../terminal/terminal/tests/service.spec.ts | 2 ++ 19 files changed, 90 insertions(+), 41 deletions(-) diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index cc6225da8c..a7d7a29c7c 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: b398f124ea7b77b0 zh: 50855e601e6c1b88 /deepseek-ai-dsh-tool-bash-persistent/use-this-package/what-can-go-wrong: - en: 2713550ebdb02f69 - zh: 52ed398dc541d1fc + en: 52e0e1489c6f8071 + zh: 27251d61351ee72d /deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 4bbea6bf1a..9feed7a656 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -58,7 +58,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout ### What can go wrong -A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index 6b4877e177..c348502a09 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 94de7ccf09..ac8d11ef77 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -9,9 +9,10 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' +import { TerminalError } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' @@ -278,7 +279,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell }) const result = await setup.done if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { - throw new Error('persistent bash shell did not accept initialization') + throw new TerminalError('persistent bash shell did not accept initialization', 'INIT_REJECTED') } return spawned.sessionId } catch (error: unknown) { @@ -428,9 +429,9 @@ function registerPersistentBash(ctx: Context, config: ResolvedConfig): void { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { - if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string']) const owner = exec.agent - if (owner === undefined) throw new Error('bash requires an owning agent session') + if (owner === undefined) throw new ToolContextUnavailableError('bash requires an owning agent session') return serialized(owner, async () => { if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement. return executeCommand(ctx, shells, owner, args.command, config, exec.signal) diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 7a926e670e..6793099643 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -694,7 +694,10 @@ describe('tool-bash-persistent', () => { 'fails initialization and closes the unusable shell for %s', async (mode) => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) - expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(await call(ctx, owner, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'TerminalError', code: 'INIT_REJECTED' } }, + }) expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed') }, ) @@ -752,8 +755,13 @@ describe('tool-bash-persistent', () => { it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() - expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) - expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + expect(await call(ctx, undefined, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + const blank = await call(ctx, owner, ' ') + expect(text(blank)).toContain('command must be a non-empty string') + expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) const controller = new AbortController() controller.abort(new Error('caller stopped')) diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index 60b8ae7682..ab2bb8cd7f 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: 9f4c22b06f940caf zh: 61f755cec7ff5b76 /deepseek-ai-dsh-tool-pwsh-persistent/use-this-package/what-can-go-wrong: - en: 70bf2e1e8d8ccbf6 - zh: 6c78d8c85113adf7 + en: 3abc9cc73411c042 + zh: 31c9e3ddf7160414 /deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 0bd03285be..5437ef8a9a 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -60,7 +60,7 @@ Commands share one shell per agent, so cwd, `$env:` variables, functions, and ba ### What can go wrong -A call without an owning agent session fails with `pwsh requires an owning agent session`, and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `pwsh requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index 7f05af221f..f249622ce2 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session` 失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 132ca51d2c..69c2ef9990 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.' @@ -439,9 +439,9 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { - if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string']) const owner = exec.agent - if (owner === undefined) throw new Error('pwsh requires an owning agent session') + if (owner === undefined) throw new ToolContextUnavailableError('pwsh requires an owning agent session') return serialized(owner, async () => { if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement. return executeCommand(ctx, shells, owner, args.command, config, exec.signal) diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index f842613039..c22cad7974 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -829,8 +829,13 @@ describe('tool-pwsh-persistent', () => { it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() - expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) - expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + expect(await call(ctx, undefined, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + const blank = await call(ctx, owner, ' ') + expect(text(blank)).toContain('command must be a non-empty string') + expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) const controller = new AbortController() controller.abort(new Error('caller stopped')) diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 5359948eb6..bdb60aa177 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -8,8 +8,9 @@ import type {} from '@deepseek-ai/dsh-working-directory' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' +import { TerminalBackendCleanupError, TerminalError } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal' +import { SubprocessError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' @@ -94,12 +95,17 @@ function childEnvironment(spec: TerminalBackendSpawnSpec, dialect: ShellDialect) export const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" +/** The startup deadline failure shared by both dialects. */ +function startupTimeout(): TerminalError { + return new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT') +} + async function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy, signal?: AbortSignal): Promise { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv const sandbox = ctx.get('sandbox') if (sandbox === undefined) { - throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`) + throw new TerminalError(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`, 'NO_SANDBOX') } // Re-state the discriminant because object spread does not preserve its narrowed type. return (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv @@ -134,8 +140,8 @@ async function startupSession( ...signal !== undefined ? { signal } : {}, }) const result = await startupOperation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED') + if (result.waitReason === 'timeout') throw startupTimeout() viewport = result.viewport if (result.waitReason === 'stdin_read') break } @@ -154,7 +160,7 @@ async function startupSession( const deadline = Promise.withResolvers() deadlineTimer = setTimeout(() => { startupOperation?.cancel() - deadline.reject(new Error('PTY shell did not reach readiness before startup timeout')) + deadline.reject(startupTimeout()) }, timeoutMs) races.push(deadline.promise) } @@ -201,7 +207,7 @@ export class BashTerminalBackend implements TerminalBackend { const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session }) const argv = await spawnArgv(this.ctx, this.config, policy, spec.signal) spec.signal?.throwIfAborted() - if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv') + if (argv[0] === undefined) throw new SubprocessError('terminal-bash: sandbox returned empty argv', 'SUBPROCESS_INVALID_ARGV') const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? await this.ctx.workingDirectory.ensure(spec.owner, spec.signal), diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index 993b15c4ee..a78c6e4f7c 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -314,8 +314,8 @@ export class LocalPtySession implements TerminalBackendSession { try { const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} }) const result = await operation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED') + if (result.waitReason === 'timeout') throw new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT') this.motd = result.viewport } catch (error: unknown) { signal?.throwIfAborted() @@ -326,8 +326,8 @@ export class LocalPtySession implements TerminalBackendSession { } startSend(request: TerminalSendRequest): TerminalSendOperation { - if (this.closing) throw new Error('PTY session is closing') - if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited') + if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING') + if (this.statusValue.kind === 'exited') throw new TerminalError('PTY session has exited', 'SESSION_EXITED') if (this.active !== undefined) { const draining = this.activeWrite !== undefined ? ' or draining provider write' @@ -336,7 +336,12 @@ export class LocalPtySession implements TerminalBackendSession { : '' throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE') } - if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') + if (request.signal?.aborted === true) { + // Named as a cancellation so the tool registry classifies it as the caller's abort. + const aborted = new Error('PTY send aborted before write') + aborted.name = 'AbortError' + throw aborted + } const operation = new LocalSendOperation( this.config.maxReadBytes, @@ -452,7 +457,7 @@ export class LocalPtySession implements TerminalBackendSession { } async signal(signal: TerminalSignal): Promise { - if (this.closing) throw new Error('PTY session is closing') + if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING') const targetPgid = await this.terminal.signalForeground(signal) return { delivered: true, targetPgid } } diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 71793a5f6e..d992f8ee7f 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -133,6 +133,7 @@ describe('BashTerminalBackend startup rollback', () => { controller.abort(abortReason) await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason) await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv') + await expect(backend.spawn(spec(agent(ctx)))).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' }) }) it('closes failed startup and aggregates cleanup failure', async () => { @@ -347,6 +348,7 @@ describe('BashTerminalBackend startup rollback', () => { await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow( 'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world', ) + await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'NO_SANDBOX' })) }) it('forwards terminal allocation cancellation directly', async () => { @@ -523,8 +525,10 @@ describe('BashTerminalBackend startup rollback', () => { }) as unknown as LocalPtySession const exited = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + await expect(exited.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' })) const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_TIMEOUT' })) }) it('bounds all pwsh startup retries with one deadline', async () => { diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index bff1317c26..4dab23a32e 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -752,6 +752,7 @@ describe('LocalPtySession readiness and output', () => { terminal.emitExit(7, 9) expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } }) expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited') + expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_EXITED' })) }) it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => { @@ -776,6 +777,7 @@ describe('LocalPtySession readiness and output', () => { const aborted = new AbortController() aborted.abort() expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write') + expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow(expect.objectContaining({ name: 'AbortError' })) terminal.throwWrite = true const failed = session.startSend({ text: 'x', submit: false }) @@ -1166,6 +1168,7 @@ describe('LocalPtySession readiness and output', () => { const initializing = startup.initialize(new AbortController().signal) startupTerminal.emitExit(1) await expect(initializing).rejects.toThrow('exited during startup') + await expect(initializing).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' })) expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null }) const terminal = new FakeTerminal() @@ -1238,7 +1241,9 @@ describe('LocalPtySession readiness and output', () => { const timeoutTerminal = new FakeTerminal() const timeout = new LocalPtySession(timeoutTerminal, config()) - const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout') + const timedOut = expect(timeout.initialize()).rejects.toEqual(expect.objectContaining({ + name: 'TerminalError', code: 'STARTUP_TIMEOUT', message: expect.stringContaining('startup timeout') as string, + })) await vi.advanceTimersByTimeAsync(100) await timedOut }) @@ -1640,8 +1645,8 @@ describe('LocalPtySession bounds, signals, and teardown', () => { const closing = session.close('test') expect(session.close('other')).toBe(closing) await expect(closing).rejects.toThrow('PTY cleanup failed (test)') - expect(() => session.startSend({ text: '', submit: false })).toThrow('closing') - await expect(session.signal('SIGTERM')).rejects.toThrow('closing') + expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) + await expect(session.signal('SIGTERM')).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) }) it('reports cleanup failure without waiting for top-level exit and permits retry', async () => { diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index 50ce7993cf..6b91667f72 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -48,8 +48,8 @@ en: 7af36e58919e41d5 zh: 4fe7b4db1099bcbf /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: - en: 5815723cd0c6089c - zh: 0c612ffa4b8c466c + en: 0b8dc3a382aa7bf2 + zh: e6cc6906e01827d2 /deepseek-ai-dsh-terminal/further-exploration: en: 8459a3b70a9f8bad zh: 21372aa8939d2182 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index 26e8506bd8..40cb6866f8 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -88,7 +88,7 @@ Each published session is a record of its id, owner, optional name, backend type ### Send reservation -The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. +The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, `NO_SANDBOX`, and `INIT_REJECTED` with the same class.
diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index a185554a9e..bb21c9e502 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -88,7 +88,7 @@ kind: "package-reference" ### 发送预留 -服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。 +服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT`、`NO_SANDBOX` 与 `INIT_REJECTED`。
diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index f97ab51cae..df09ff94aa 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -52,16 +52,29 @@ declare module '@deepseek-ai/cordis' { } } -/** Machine-routable PTY service failures. */ +/** + * Machine-routable PTY service and backend failures. Backends use + * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a + * confined mode has no sandbox provider, `SESSION_CLOSING` and + * `SESSION_EXITED` for an operation on a closing or exited session, and + * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses + * readiness during startup. + */ export type TerminalErrorCode = | 'DUPLICATE_BACKEND' | 'DUPLICATE_NAME' | 'FOREIGN_SESSION' + | 'INIT_REJECTED' | 'NO_BACKEND' + | 'NO_SANDBOX' | 'NO_SESSION' | 'OWNER_NOT_LIVE' | 'SEND_ACTIVE' | 'SERVICE_DISPOSING' + | 'SESSION_CLOSING' + | 'SESSION_EXITED' + | 'STARTUP_EXITED' + | 'STARTUP_TIMEOUT' /** Harness error carrying a stable {@link TerminalErrorCode}. */ export class TerminalError extends HarnessError { @@ -246,7 +259,7 @@ export class TerminalSessionService extends Service { */ startSend(owner: Agent, id: TerminalSessionId, request: TerminalSendRequest): TerminalSendOperation { const record = this.expectOwned(owner, id) - if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`) + if (record.closing !== undefined) throw new TerminalError(`PTY session ${id} is closing`, 'SESSION_CLOSING') if (record.active !== undefined) throw new TerminalError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE') const operation = record.session.startSend(request) record.active = operation diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 3d8304275b..0237f2970a 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -525,6 +525,8 @@ describe('TerminalSessionService ownership and lifecycle', () => { b.sessions[0]!.closeGate = Promise.withResolvers() const first = ctx.terminals.kill(owner, created.sessionId) expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing') + expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })) + .toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) const second = ctx.terminals.kill(owner, created.sessionId) b.sessions[0]!.closeGate?.resolve(undefined) expect(await first).toBe(true) From 818b603ed4ea8128e2c2b3cd346502758780da5d Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:38:23 +0800 Subject: [PATCH 034/149] feat(shell-env): add ShellEnvError for invalid contributions A contributor that returns an undeclared key or a non-string value now fails the shell call with ShellEnvError SHELL_ENV_CONTRIBUTION_INVALID instead of a plain Error, with the same message. Refs #7869 --- packages/shell/shell-env/README.i18n.yaml | 4 +-- packages/shell/shell-env/README.md | 2 +- packages/shell/shell-env/README.zh.md | 2 +- packages/shell/shell-env/package.json | 3 +- packages/shell/shell-env/src/index.ts | 33 +++++++++++++++++-- .../shell/shell-env/tests/shell-env.spec.ts | 2 ++ packages/shell/shell-env/tsconfig.json | 3 ++ 7 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index 63f09470f2..eac66fcb65 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -21,8 +21,8 @@ en: bab0b75fb967d41a zh: 8f636d9965d01ad1 /deepseek-ai-dsh-shell-env/use-this-package/adding-your-own-environment-facts: - en: 21ef223626e35905 - zh: 5c468055099b0135 + en: 14da34f853d089be + zh: 5ad3942aa5321893 /deepseek-ai-dsh-shell-env/use-this-package/choosing-the-harness-home: en: 0041438f2b7f7e4f zh: 93fa165b07adcdf4 diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index c2507b3c7c..86037caffe 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -50,7 +50,7 @@ export function apply(ctx: Context): void { } ``` -Contributors must declare every key they return; returning an undeclared or non-string value fails the call. Registration is disposed with the registering plugin, so hot-reloading a plugin removes its facts. +Contributors must declare every key they return; returning an undeclared or non-string value fails the call with `ShellEnvError` (`SHELL_ENV_CONTRIBUTION_INVALID`). Registration is disposed with the registering plugin, so hot-reloading a plugin removes its facts. ### Choosing the Harness home diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index 8860c11b54..a3d4a98516 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -50,7 +50,7 @@ export function apply(ctx: Context): void { } ``` -contributor 必须声明它返回的每个键;返回未声明或非字符串的值会让该次调用失败。注册随注册插件的释放而释放,因此热重载插件会移除它的事实。 +contributor 必须声明它返回的每个键;返回未声明或非字符串的值会让该次调用以 `ShellEnvError`(`SHELL_ENV_CONTRIBUTION_INVALID`)失败。注册随注册插件的释放而释放,因此热重载插件会移除它的事实。 ### 选择 Harness 主目录 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4fb761f034..4fb7d7de4f 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index d53a393a8d..aa3012e2b4 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -14,6 +14,7 @@ import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' // Declares `Context.profileContext`, the launcher-provided profile the built-ins read. import type {} from '@deepseek-ai/dsh-app-boot' @@ -24,6 +25,31 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Every {@link ShellEnvError} code: + * - `SHELL_ENV_CONTRIBUTION_INVALID` — a registered contributor returned an undeclared key or a non-string value; the + * shell command does not run, and the contributing plugin must be fixed. + */ +export const SHELL_ENV_ERROR_CODES = ['SHELL_ENV_CONTRIBUTION_INVALID'] as const + +/** One code from {@link SHELL_ENV_ERROR_CODES}. */ +export type ShellEnvErrorCode = typeof SHELL_ENV_ERROR_CODES[number] + +/** Shell environment failure carrying a stable {@link ShellEnvErrorCode}. */ +export class ShellEnvError extends HarnessError { + override readonly code: ShellEnvErrorCode + + /** + * @param message - names the contributor and the offending key. + * @param code - the failure class consumers route on. + */ + constructor(message: string, code: ShellEnvErrorCode) { + super(message, code) + this.name = 'ShellEnvError' + this.code = code + } +} + export const name = 'shell-env' export const inject: string[] = [] @@ -182,10 +208,13 @@ export class ShellEnvRegistry extends Service { for (const [rawKey, value] of Object.entries(resolved)) { const key = rawKey as DshEnvironmentKey if (!Object.hasOwn(contributor.variables, key)) { - throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + throw new ShellEnvError(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`, 'SHELL_ENV_CONTRIBUTION_INVALID') } if (typeof value !== 'string') { - throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + throw new ShellEnvError( + `bash env contributor "${contributor.name}" returned a non-string value for "${key}"`, + 'SHELL_ENV_CONTRIBUTION_INVALID', + ) } values[key] = value } diff --git a/packages/shell/shell-env/tests/shell-env.spec.ts b/packages/shell/shell-env/tests/shell-env.spec.ts index e14d41b06c..74590aa42e 100644 --- a/packages/shell/shell-env/tests/shell-env.spec.ts +++ b/packages/shell/shell-env/tests/shell-env.spec.ts @@ -192,6 +192,7 @@ describe('ShellEnvRegistry', () => { }) expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/) + expect(() => registry.collect(execution())).toThrow(expect.objectContaining({ name: 'ShellEnvError', code: 'SHELL_ENV_CONTRIBUTION_INVALID' })) }) it('rejects non-string values returned by a contributor', () => { @@ -203,6 +204,7 @@ describe('ShellEnvRegistry', () => { }) expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/) + expect(() => registry.collect(execution())).toThrow(expect.objectContaining({ name: 'ShellEnvError', code: 'SHELL_ENV_CONTRIBUTION_INVALID' })) }) it('removes an effect-scoped contributor when its plugin is disposed', async () => { diff --git a/packages/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index fc6a34b571..ae66c37da9 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../boot/app-boot" + }, + { + "path": "../../llm/llm" } ] } From 529077d109a444b14158f9f98d1917e1fa70bd92 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:48:50 +0800 Subject: [PATCH 035/149] test(snapshots): record structured errors for shell, job, and escalation failures Refreshed replays now persist error.name and error.code on three tool results that previously carried only text: the rejected escalation (SandboxEscalationError SANDBOX_ESCALATION_REJECTED), the background job limit (JobError JOB_LIMIT_REACHED), and the unknown job id after a missing sandbox runner (JobError JOB_NOT_FOUND). Model-visible content is unchanged. Refs #7869 --- snapshots/acp/escalation-rejected/session.v4.jsonl | 2 +- snapshots/session/background-job-admission/session.v4.jsonl | 2 +- snapshots/session/missing-sandbox-runner/session.v4.jsonl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snapshots/acp/escalation-rejected/session.v4.jsonl b/snapshots/acp/escalation-rejected/session.v4.jsonl index e0eabb8fe3..434c7bcf86 100644 --- a/snapshots/acp/escalation-rejected/session.v4.jsonl +++ b/snapshots/acp/escalation-rejected/session.v4.jsonl @@ -16,7 +16,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} {"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} {"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"rejected"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\"; it stays denied, so stop and explain instead of working around it"}],"isError":true,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\"; it stays denied, so stop and explain instead of working around it"}],"isError":true,"id":"{{message:5}}"},"error":{"name":"SandboxEscalationError","code":"SANDBOX_ESCALATION_REJECTED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30},"stream":[{"type":"chunk","time":1788699392206,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699392206,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," rejected"," the"," escalation","."," As"," instructed",","," I"," will"," not"," ret","ry"," and"," will"," not"," work"," around"," it"," —"," just"," explain"," in"," one"," short"," sentence"," and"," stop","."]},{"type":"chunk","time":1788699392207,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699392207,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," rejected"," the"," permission"," escalation",","," so"," this"," command"," cannot"," be"," run","."]},{"type":"chunk","time":1788699392207,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}},{"type":"chunk","time":1788699392207,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}},{"type":"chunk","time":1788699392207,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}},{"type":"chunk","time":1788699392207,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/background-job-admission/session.v4.jsonl b/snapshots/session/background-job-admission/session.v4.jsonl index 161b12144f..4c37185c96 100644 --- a/snapshots/session/background-job-admission/session.v4.jsonl +++ b/snapshots/session/background-job-admission/session.v4.jsonl @@ -20,7 +20,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791427794157,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791427794157,"index":0,"dt":[],"id":"bounded-task-second","name":"bash","args":["{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"]},{"type":"chunk","time":1791427794157,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"}}},{"type":"chunk","time":1791427794157,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791427794157,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"bounded-task-second"},"toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background job limit reached for this owner (limit: 1); use job_kill to stop an unneeded job, wait for it to finish, then retry"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"bounded-task-second"},"toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background job limit reached for this owner (limit: 1); use job_kill to stop an unneeded job, wait for it to finish, then retry"}],"isError":true,"id":"{{message:7}}"},"error":{"name":"JobError","code":"JOB_LIMIT_REACHED"}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-kill","name":"job_kill","arguments":"{\"job_id\":\"{{id:1}}\",\"reason\":\"free the bounded task slot\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791427794174,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791427794174,"index":0,"dt":[],"id":"bounded-task-kill","name":"job_kill","args":["{\"job_id\":\"{{id:1}}\",\"reason\":\"free the bounded task slot\"}"]},{"type":"chunk","time":1791427794174,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-kill","name":"job_kill","arguments":"{\"job_id\":\"{{id:1}}\",\"reason\":\"free the bounded task slot\"}"}}},{"type":"chunk","time":1791427794174,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791427794174,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/missing-sandbox-runner/session.v4.jsonl b/snapshots/session/missing-sandbox-runner/session.v4.jsonl index a6bc8b460e..7922555084 100644 --- a/snapshots/session/missing-sandbox-runner/session.v4.jsonl +++ b/snapshots/session/missing-sandbox-runner/session.v4.jsonl @@ -21,7 +21,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791427848744,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791427848744,"index":0,"dt":[],"id":"missing-runner-output","name":"job_output","args":["{\"job_id\":\"bash-1\",\"wait\":true}"]},{"type":"chunk","time":1791427848744,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}}},{"type":"chunk","time":1791427848744,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791427848744,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"missing-runner-output"},"toolCallId":"missing-runner-output","content":[{"type":"text","text":"Error: unknown job bash-1"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"missing-runner-output"},"toolCallId":"missing-runner-output","content":[{"type":"text","text":"Error: unknown job bash-1"}],"isError":true,"id":"{{message:7}}"},"error":{"name":"JobError","code":"JOB_NOT_FOUND"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791427848761,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791427848761,"index":0,"dt":[],"texts":["RUNNER_FAILURES_SURFACED"]},{"type":"chunk","time":1791427848762,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}},{"type":"chunk","time":1791427848762,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791427848762,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From c92eded7e4eca82ce31f4b41c0a2d35bce9599da Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:59:04 +0800 Subject: [PATCH 036/149] docs: regenerate catalogs for the shell, sandbox, and working-directory errors Regenerated Cordis, config, and persistence catalogs pick up the new WorkingDirectoryError and SandboxPreparationError exports and the shifted source lines; persisted type digests are unchanged. Refs #7869 --- docs/config-catalog.md | 14 +++++++------- docs/config-catalog.zh.md | 14 +++++++------- docs/persistence-catalog.i18n.yaml | 16 ++++++++-------- docs/persistence-catalog.md | 8 ++++---- docs/persistence-catalog.zh.md | 8 ++++---- docs/persistence-schema.json | 6 +++--- docs/subsystems/sandbox.md | 2 ++ docs/subsystems/sandbox.zh.md | 2 ++ docs/subsystems/working-directory.md | 7 +++++-- docs/subsystems/working-directory.zh.md | 7 +++++-- .../extensions/tool-cordis/src/api-catalog.ts | 5 +++-- 11 files changed, 50 insertions(+), 39 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7562c8bf96..90892cc3dd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1891,7 +1891,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -2938,7 +2938,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:63`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -3362,7 +3362,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -4083,7 +4083,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4113,7 +4113,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:448`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4348,7 +4348,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -4942,7 +4942,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:67`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:70`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d75b6e523..663fb50b89 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1893,7 +1893,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -2940,7 +2940,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:63`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -3364,7 +3364,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -4085,7 +4085,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4115,7 +4115,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:448`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4350,7 +4350,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -4944,7 +4944,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:67`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:70`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index a5fb682b48..4214f43702 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -300,8 +300,8 @@ en: e8dd0cebbb930eb7 zh: e8dd0cebbb930eb7 /session-persistence-event-catalog/events/working-directory/working-directory-change-log-only: - en: fc893ed0d93765df - zh: 2df365d9d0ac6f6e + en: 27f0761cbdd6fe2f + zh: 33528f333498132d /session-persistence-event-catalog/events/workspace: en: 63ee1a85e4b59ae3 zh: 63ee1a85e4b59ae3 @@ -1203,8 +1203,8 @@ en: 1f691c223f54ab2c zh: f88fef3ee9745c91 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: - en: c9fa5343ea99733b - zh: d6de41351c57a73f + en: 5adc2bf6745ccc67 + zh: 566cb9aaa4a177c9 /session-persistence-event-catalog/resolved-persistence-types/schedulechange: en: b20a355fde48f699 zh: 5d0fd858e2dbff06 @@ -1494,8 +1494,8 @@ en: bef51e0442ebb903 zh: 240ebeb160533d8c /session-persistence-event-catalog/resolved-persistence-types/cwd: - en: 35feea0430c0f14b - zh: d5aaf1d2ca80114a + en: 2809c921c6996791 + zh: 80b15177561766d7 /session-persistence-event-catalog/resolved-persistence-types/decision-durationms-exitcode-handlerid: en: a99ac57bbec0370f zh: 9073329e324a66e0 @@ -1929,8 +1929,8 @@ en: 0b74295f7c5c8f11 zh: 5f1213cec87f6be0 /session-persistence-event-catalog/resolved-persistence-types/kind-working-directory: - en: 626ea3180b86e5b6 - zh: 4b6e0dc1312b3c5a + en: b28ca6b37f5a338d + zh: 4c4f2f05721b8b62 /session-persistence-event-catalog/resolved-persistence-types/max-tokens-messages-model-thinking: en: 3216b23c131d0070 zh: 73ca171c6014f16e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6b80fbbc99..cd09a9358c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1329,7 +1329,7 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:89`](../packages/web/ } ``` -Source: [`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +Source: [`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) ### `workspace/*` @@ -4619,7 +4619,7 @@ One of: SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) +Sources: [`packages/sandbox/sandbox/src/index.ts:38`](../packages/sandbox/sandbox/src/index.ts) One of: @@ -6209,7 +6209,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:24`](../packages/compacti SHA-256: `da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582` -Sources: [`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +Sources: [`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -7949,7 +7949,7 @@ Sources: [`packages/experimental/webhook/src/types.ts:74`](../packages/experimen SHA-256: `2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5` -Sources: [`packages/session/working-directory/src/index.ts:45`](../packages/session/working-directory/src/index.ts) +Sources: [`packages/session/working-directory/src/index.ts:48`](../packages/session/working-directory/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c73b594c28..84c826fe67 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1331,7 +1331,7 @@ export type SessionEvent = { } ``` -来源:[`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) ### `workspace/*` @@ -4621,7 +4621,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) +来源:[`packages/sandbox/sandbox/src/index.ts:38`](../packages/sandbox/sandbox/src/index.ts) 以下类型之一: @@ -6211,7 +6211,7 @@ SHA-256: `3e4e7683b0192c9f0e6a75c59076108f017892cdb302a98032c2632fd79c356f` SHA-256: `da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582` -来源:[`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7951,7 +7951,7 @@ SHA-256: `9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94` SHA-256: `2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5` -来源:[`packages/session/working-directory/src/index.ts:45`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:48`](../packages/session/working-directory/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3a1b058f36..a2832eb98c 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -159,7 +159,7 @@ {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:121"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, - {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:45"]}, + {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:48"]}, {"digest":"224aa11d4d5f88c450822d01b99e6ca5e3a9c7f26a5ede10bae33384a09ef4dc","names":[],"sources":[]}, {"digest":"234142ee4f996f38cc9fa3b8b3e3de29b03c9aefe5ebf6c224eddd564505aa85","names":[],"sources":[]}, {"digest":"237c004c73b6b288246816e0d7fde16e09c63a47aa3b1227383d7afe58666163","names":[],"sources":[]}, @@ -607,7 +607,7 @@ {"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]}, {"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]}, {"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]}, - {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:52"]}, + {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:55"]}, {"digest":"dacf3d5f3e4ff619c1320e64a48dec5e7ecd7870bece65fbd449a0e63adfa521","names":[],"sources":[]}, {"digest":"db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c","names":[],"sources":[]}, {"digest":"db6c5246911d47a7969f6b4cb47c7d0dbbde75c402710b22ace3672fa97bebe0","names":[],"sources":[]}, @@ -692,7 +692,7 @@ {"digest":"f97f47b0eb87905ed77cc27c5e4ed75ef805cb372c98905253709de1aab9b63c","names":[],"sources":[]}, {"digest":"f9cfc89e8fe085b4fcc9cd32b74b6f103608c54d7d8cd97b47c01de34f252ba3","names":[],"sources":[]}, {"digest":"fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be","names":["packages/llm/llm/src/message.ts#AssistantMessage"],"sources":["packages/llm/llm/src/message.ts:167"]}, - {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:30"]}, + {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:38"]}, {"digest":"fab35c4520883c0d69e42d079c9f543fc5ac7a2d15b537b5572fcd99059c7018","names":[],"sources":[]}, {"digest":"fb5e0517050d2c6d40e5b8145db1f7c5ca7044d5d50fada6ff80e0b22c6ca973","names":[],"sources":[]}, {"digest":"fb8b6a2e53cb9cf44c551176de0fb77d1ddc60037b99a891ff8a860b6d5c29a0","names":[],"sources":[]}, diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index e5c3021503..71f4eedea6 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -213,6 +213,8 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 10093b09e2..2b79f4c17c 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -213,6 +213,8 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy diff --git a/docs/subsystems/working-directory.md b/docs/subsystems/working-directory.md index c6f4545328..cac8b9afa4 100644 --- a/docs/subsystems/working-directory.md +++ b/docs/subsystems/working-directory.md @@ -42,7 +42,8 @@ get(session: Session): string * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise @@ -53,7 +54,9 @@ ensure(agent: Agent, signal?: AbortSignal): Promise * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise ``` diff --git a/docs/subsystems/working-directory.zh.md b/docs/subsystems/working-directory.zh.md index 98020a3dfc..aa6270328a 100644 --- a/docs/subsystems/working-directory.zh.md +++ b/docs/subsystems/working-directory.zh.md @@ -42,7 +42,8 @@ get(session: Session): string * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise @@ -53,7 +54,9 @@ ensure(agent: Agent, signal?: AbortSignal): Promise * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise ``` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 74a5703129..7f3a279bf7 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2620,6 +2620,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve the complete policy for one capability call. An approved explicit mode outranks the session\'s last `sandbox/mode` event, which outranks the deployment default. A session cwd is its workspace-write boundary; the configured root is the fallback for agentless calls and sessions without a cwd. Configured extra roots and this Session\'s active registrations are snapshotted together; existing operations and processes keep their policy.', parameters: [{ name: 'request', description: 'optional session and approved mode override.' }], returns: 'the fully resolved per-call mode and absolute workspace root.', + throws: ['`SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session\'s recorded cwd is relative or contains a NUL byte.'], }, { signature: 'registerWritableRoot(session: Session, directory: string): () => void', @@ -4550,14 +4551,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Validate the current directory and restore the original project when it disappeared.', parameters: [{ name: 'agent', description: 'live or unpublished Agent owning the Session.' }, { name: 'signal', description: 'cancellation for filesystem inspection.' }], returns: 'the existing directory; recovery is committed before fulfillment. A notice failure is warned without reverting the committed state.', - throws: ['when the original project is also unavailable, or when a `working-directory/access` listener rejects.'], + throws: ['{@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also unavailable or the service is disposed, or the rejection of a `working-directory/access` listener.'], }, { signature: 'set(agent: Agent, path: string, signal?: AbortSignal): Promise', description: 'Change one Session\'s directory without changing existing processes or permissions.', parameters: [{ name: 'agent', description: 'live or unpublished Agent owning the Session.' }, { name: 'path', description: 'absolute path or a path relative to its current directory.' }, { name: 'signal', description: 'cancellation before the durable change.' }], returns: 'the canonical absolute directory, committed before fulfillment. A notice failure is warned; the next request still receives the committed directory.', - throws: ['when the requested path is not an existing directory.'], + throws: ['{@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a `working-directory/access` listener.'], }, ], }, From fe2effea1f77b37e89f1d7a99286fdedbab75e38 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 20:51:38 +0800 Subject: [PATCH 037/149] feat(terminal): give TerminalBackendCleanupError the CLEANUP_FAILED code TerminalBackendCleanupError extended AggregateError, so a backend that failed startup and then failed to clean its partial resources reached tool/result with a name but no error.name/error.code. It now extends TerminalError with code CLEANUP_FAILED, keeps spawnError, cleanupError, and the errors list, and sets the setup failure as cause. TerminalError and the cleanup error move to src/error.ts so src/types.ts stays type-only; TerminalError's constructor accepts ErrorOptions. Refs #7869 --- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../terminal-bash/tests/index.spec.ts | 3 + packages/terminal/terminal/README.i18n.yaml | 8 +-- packages/terminal/terminal/README.md | 5 +- packages/terminal/terminal/README.zh.md | 5 +- packages/terminal/terminal/src/error.ts | 70 +++++++++++++++++++ packages/terminal/terminal/src/index.ts | 40 +---------- packages/terminal/terminal/src/types.ts | 17 +---- .../terminal/terminal/tests/service.spec.ts | 16 +++++ 10 files changed, 104 insertions(+), 64 deletions(-) create mode 100644 packages/terminal/terminal/src/error.ts diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index c1c78963e4..0d917219d5 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -31,7 +31,7 @@ A backend owns how one registered type starts and detects readiness. `TerminalSe interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index e97e5771f5..a9d2b36d27 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -31,7 +31,7 @@ type TerminalSessionStatus = interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index d992f8ee7f..2859644622 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -158,8 +158,11 @@ describe('BashTerminalBackend startup rollback', () => { const aggregate = new BashTerminalBackend(ctx, config(), spawnTerminal, () => doublyFailed) await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalBackendCleanupError', + code: 'CLEANUP_FAILED', spawnError: startupFailure, cleanupError: cleanupFailure, + errors: [startupFailure, cleanupFailure], + cause: startupFailure, } satisfies Partial)) }) diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index 6b91667f72..e734b14bf2 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -39,14 +39,14 @@ en: 6bae42fb7275816f zh: 5f75e1b26074108c /deepseek-ai-dsh-terminal/understand-the-implementation/source-map: - en: 48dab6fe2f770ced - zh: 9bbfd23bcc5ac2b3 + en: 3ab5d082aa13d500 + zh: e97b962d02e474b7 /deepseek-ai-dsh-terminal/understand-the-implementation/data-model-and-lifecycle: en: a2adfa2708882e19 zh: 002f257fd8c4addb /deepseek-ai-dsh-terminal/understand-the-implementation/ownership-and-cleanup-rules: - en: 7af36e58919e41d5 - zh: 4fe7b4db1099bcbf + en: b669a68c739b062a + zh: cc641f850905095f /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: en: 0b8dc3a382aa7bf2 zh: e6cc6906e01827d2 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index 40cb6866f8..ff34d9a3cc 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -74,7 +74,8 @@ The service owns everything except terminal mechanics: session identity, publica | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`: backend registry, spawn/send/read/signal/kill/list, owner cleanups, disposal | -| [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set, error codes | +| [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set | +| [`src/error.ts`](src/error.ts) | `TerminalError`, its codes, and `TerminalBackendCleanupError` | ### Data model and lifecycle @@ -83,7 +84,7 @@ Each published session is a record of its id, owner, optional name, backend type ### Ownership and cleanup rules - Fencing uses the exact `Agent` object: `hasOwnerActivity(owner)` spans unpublished setup through final close with no publication gap, so lifecycle policy can fence the owner precisely. -- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. +- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. - Caller cancellation keeps its exact `AbortSignal.reason`; `kill()` and disposal resolve only after the backend's captured process tree is quiescent. ### Send reservation diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index bb21c9e502..cef5a62110 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -74,7 +74,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`:后端注册表、spawn/send/read/signal/kill/list、所有者清理与 dispose | -| [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合、错误码 | +| [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合 | +| [`src/error.ts`](src/error.ts) | `TerminalError`、其 code 与 `TerminalBackendCleanupError` | ### 数据模型与生命周期 @@ -83,7 +84,7 @@ kind: "package-reference" ### 所有权与清理规则 - 限制基于确切的 `Agent` 对象:`hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,没有发布竞态,因此生命周期策略可以精确限制所有者。 -- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 +- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,并在 `errors` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 - 调用方取消保留其确切的 `AbortSignal.reason`;`kill()` 与 dispose 只在后端捕获的进程树完全停稳后完成。 ### 发送预留 diff --git a/packages/terminal/terminal/src/error.ts b/packages/terminal/terminal/src/error.ts new file mode 100644 index 0000000000..b487ac9bc4 --- /dev/null +++ b/packages/terminal/terminal/src/error.ts @@ -0,0 +1,70 @@ +/** Machine-routable PTY service and backend failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-error' + +/** + * Machine-routable PTY service and backend failures. Backends use + * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a + * confined mode has no sandbox provider, `SESSION_CLOSING` and + * `SESSION_EXITED` for an operation on a closing or exited session, + * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses + * readiness during startup, and `CLEANUP_FAILED` (always as + * {@link TerminalBackendCleanupError}) when startup failed and the partial + * resources it left could not be cleaned. + */ +export type TerminalErrorCode = + | 'CLEANUP_FAILED' + | 'DUPLICATE_BACKEND' + | 'DUPLICATE_NAME' + | 'FOREIGN_SESSION' + | 'INIT_REJECTED' + | 'NO_BACKEND' + | 'NO_SANDBOX' + | 'NO_SESSION' + | 'OWNER_NOT_LIVE' + | 'SEND_ACTIVE' + | 'SERVICE_DISPOSING' + | 'SESSION_CLOSING' + | 'SESSION_EXITED' + | 'STARTUP_EXITED' + | 'STARTUP_TIMEOUT' + +/** Harness error carrying a stable {@link TerminalErrorCode}. */ +export class TerminalError extends HarnessError { + override readonly code: TerminalErrorCode + + /** + * @param message - names the PTY operation and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: TerminalErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'TerminalError' + this.code = code + } +} + +/** + * Backend-reported failure to clean partial resources after unpublished setup + * failed; code `CLEANUP_FAILED`. Backend-owned resources may still be alive. + * `errors` lists both failures in the order `AggregateError` would, and + * `cause` is the setup failure. + */ +export class TerminalBackendCleanupError extends TerminalError { + /** The setup failure followed by the cleanup failure. */ + readonly errors: readonly unknown[] + + /** + * @param spawnError - original setup or cancellation failure. + * @param cleanupError - failure that may leave backend-owned resources alive. + */ + constructor( + readonly spawnError: unknown, + readonly cleanupError: unknown, + ) { + super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { cause: spawnError }) + this.name = 'TerminalBackendCleanupError' + this.errors = [spawnError, cleanupError] + } +} diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index df09ff94aa..2e6e78b6ea 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -6,8 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-error' -import { TerminalBackendCleanupError } from './types.ts' +import { TerminalBackendCleanupError, TerminalError } from './error.ts' import type { TerminalBackend, TerminalBackendSession, @@ -41,7 +40,7 @@ export type { TerminalSpawnResult, TerminalWaitReason, } from './types.ts' -export { TerminalBackendCleanupError } from './types.ts' +export { TerminalBackendCleanupError, TerminalError, type TerminalErrorCode } from './error.ts' /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue @@ -52,41 +51,6 @@ declare module '@deepseek-ai/cordis' { } } -/** - * Machine-routable PTY service and backend failures. Backends use - * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a - * confined mode has no sandbox provider, `SESSION_CLOSING` and - * `SESSION_EXITED` for an operation on a closing or exited session, and - * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses - * readiness during startup. - */ -export type TerminalErrorCode = - | 'DUPLICATE_BACKEND' - | 'DUPLICATE_NAME' - | 'FOREIGN_SESSION' - | 'INIT_REJECTED' - | 'NO_BACKEND' - | 'NO_SANDBOX' - | 'NO_SESSION' - | 'OWNER_NOT_LIVE' - | 'SEND_ACTIVE' - | 'SERVICE_DISPOSING' - | 'SESSION_CLOSING' - | 'SESSION_EXITED' - | 'STARTUP_EXITED' - | 'STARTUP_TIMEOUT' - -/** Harness error carrying a stable {@link TerminalErrorCode}. */ -export class TerminalError extends HarnessError { - override readonly code: TerminalErrorCode - - constructor(message: string, code: TerminalErrorCode) { - super(message, code) - this.name = 'TerminalError' - this.code = code - } -} - /** * Brand one registry-minted string as a {@link TerminalSessionId}. * @param value - raw registry-issued id. diff --git a/packages/terminal/terminal/src/types.ts b/packages/terminal/terminal/src/types.ts index cbdd08d8a8..c3a191ba51 100644 --- a/packages/terminal/terminal/src/types.ts +++ b/packages/terminal/terminal/src/types.ts @@ -10,21 +10,6 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** Internal exported basis for the public `TerminalSessionId` type/value pair. */ export type TerminalSessionIdValue = Branded<'TerminalSessionId'> -/** - * Backend-reported failure to clean partial resources after unpublished setup failed. - * @param spawnError - original setup or cancellation failure. - * @param cleanupError - failure that may leave backend-owned resources alive. - */ -export class TerminalBackendCleanupError extends AggregateError { - constructor( - readonly spawnError: unknown, - readonly cleanupError: unknown, - ) { - super([spawnError, cleanupError], 'PTY backend startup and cleanup both failed') - this.name = 'TerminalBackendCleanupError' - } -} - /** Why one interactive send returned control to its caller. */ export type TerminalWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' @@ -166,7 +151,7 @@ export interface TerminalBackendSession { export interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 0237f2970a..3199deb1a0 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -203,6 +203,9 @@ describe('TerminalSessionService ownership and lifecycle', () => { expect(concurrent).toBeInstanceOf(TerminalError) expect(concurrent).toBeInstanceOf(HarnessError) expect(concurrent).toMatchObject({ name: 'TerminalError', code: 'SEND_ACTIVE' }) + const cleanup = new TerminalBackendCleanupError(new Error('setup failed'), new Error('cleanup failed')) + expect(cleanup).toBeInstanceOf(TerminalError) + expect(cleanup).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false }) expect(operation.cancel()).toBe(true) await operation.done @@ -297,6 +300,19 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(pending).rejects.toBe(reason) }) + it('rejects an uncancelled spawn with the backend cleanup failure and its code', async () => { + const ctx = await harness() + const failure = new TerminalBackendCleanupError(new Error('startup failed'), new Error('backend cleanup failed')) + ctx.terminals.registerBackend({ type: 'cleanup-failing', spawn: () => Promise.reject(failure) }) + const owner = stubAgent(ctx, 'owner') + await ctx.agents.register(owner) + + await expect(ctx.terminals.spawn(owner, { type: 'cleanup-failing' })).rejects.toBe(failure) + expect(failure).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) + const internal = ctx.terminals as unknown as { disposeOwned(owner: Agent): Promise } + await expect(internal.disposeOwned(owner)).rejects.toThrow('failed to clean up PTY lifecycle') + }) + it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => { const ctx = await harness() const started = Promise.withResolvers() From 690634f5e9ac7e28d643f69fcc94f0a81d20574e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 22:05:35 +0800 Subject: [PATCH 038/149] refactor(errors): base the new shell, sandbox, and subprocess errors on dsh-error The error classes this branch adds import HarnessError from the dependency-free @deepseek-ai/dsh-error package: Win32Error, SubprocessError, and ShellEnvError packages depend on dsh-error instead of the LLM runtime, and dsh-sandbox and dsh-working-directory declare it beside their existing dsh-llm dependency. The SHELL_ENV and WORKING_DIRECTORY code lists stay in internal modules so the plugin entries export only the error classes and code types. Generated module, config, and event graphs follow. Refs #7869 --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.md | 32 ++++++++++++------- docs/module-graph.zh.md | 32 ++++++++++++------- packages/sandbox/sandbox/package.json | 6 ++-- packages/sandbox/sandbox/src/escalation.ts | 2 +- .../sandbox/sandbox/tests/vocabulary.spec.ts | 2 +- packages/sandbox/sandbox/tsconfig.json | 3 ++ .../session/working-directory/package.json | 6 ++-- .../session/working-directory/src/error.ts | 2 +- .../session/working-directory/src/index.ts | 2 +- .../session/working-directory/tsconfig.json | 3 ++ packages/shell/shell-env/README.i18n.yaml | 4 +-- packages/shell/shell-env/README.md | 1 + packages/shell/shell-env/README.zh.md | 1 + packages/shell/shell-env/package.json | 4 +-- packages/shell/shell-env/src/error.ts | 28 ++++++++++++++++ packages/shell/shell-env/src/index.ts | 29 ++--------------- packages/shell/shell-env/tsconfig.json | 2 +- packages/subprocess/subprocess/package.json | 4 +-- packages/subprocess/subprocess/src/error.ts | 2 +- .../subprocess/subprocess/tests/error.spec.ts | 2 +- packages/subprocess/subprocess/tsconfig.json | 2 +- .../subprocess/win32-process/package.json | 4 +-- .../subprocess/win32-process/src/errors.ts | 2 +- .../win32-process/tests/ffi.spec.ts | 2 +- .../subprocess/win32-process/tsconfig.json | 2 +- pnpm-lock.yaml | 22 ++++++++----- 30 files changed, 124 insertions(+), 85 deletions(-) create mode 100644 packages/shell/shell-env/src/error.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90892cc3dd..f0e447ae59 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3362,7 +3362,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:34`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 663fb50b89..dad8488a45 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3364,7 +3364,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:34`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7e1a837be5..c0a83f9f7f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -98,7 +98,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:29`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | +| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:32`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | | `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | | `workspace/session-archived` | `parallel` | [`packages/workspace/workspace/src/index.ts:156`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | `worktree` | | `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 85f0623eaf..20e1101166 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -100,7 +100,7 @@ | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:29`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | +| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:32`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | | `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | | `workspace/session-archived` | `parallel` | [`packages/workspace/workspace/src/index.ts:156`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | `worktree` | | `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 1f8dfaee91..dbd423430a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -505,7 +505,9 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage + pkg_subprocess --> pkg_error pkg_subprocess --> pkg_http_proxy + pkg_win32_process --> pkg_error pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_system_prompt --> pkg_llm @@ -539,6 +541,7 @@ flowchart TD pkg_host_frontend_static --> pkg_host_webserver pkg_lsp --> pkg_brand pkg_lsp --> pkg_llm + pkg_sandbox --> pkg_error pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_format_catalog --> pkg_session @@ -735,6 +738,7 @@ flowchart TD pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection pkg_working_directory --> pkg_agent + pkg_working_directory --> pkg_error pkg_working_directory --> pkg_fs pkg_working_directory --> pkg_llm pkg_working_directory --> pkg_session @@ -804,10 +808,6 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1023,6 +1023,11 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout + pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1059,6 +1064,7 @@ flowchart TD pkg_tool_working_directory --> pkg_tools pkg_tool_working_directory --> pkg_working_directory pkg_shell_env --> pkg_app_boot + pkg_shell_env --> pkg_error pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools @@ -1176,6 +1182,7 @@ flowchart TD pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_shell pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_subprocess pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval @@ -1187,6 +1194,7 @@ flowchart TD pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_shell pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_subprocess pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval @@ -1683,7 +1691,6 @@ flowchart TD | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | | [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | @@ -1702,7 +1709,8 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`error`](../packages/util/error), [`http-proxy`](../packages/util/http-proxy) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`error`](../packages/util/error) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1719,7 +1727,7 @@ flowchart TD | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`error`](../packages/util/error), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1780,7 +1788,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1795,7 +1803,6 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1838,6 +1845,7 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1845,7 +1853,7 @@ flowchart TD | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-working-directory`](../packages/session/tool-working-directory) | `session` | [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`error`](../packages/util/error), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | @@ -1865,8 +1873,8 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) | | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a6070b4f5e..caf5f595f8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -507,7 +507,9 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage + pkg_subprocess --> pkg_error pkg_subprocess --> pkg_http_proxy + pkg_win32_process --> pkg_error pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_system_prompt --> pkg_llm @@ -541,6 +543,7 @@ flowchart TD pkg_host_frontend_static --> pkg_host_webserver pkg_lsp --> pkg_brand pkg_lsp --> pkg_llm + pkg_sandbox --> pkg_error pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_format_catalog --> pkg_session @@ -737,6 +740,7 @@ flowchart TD pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection pkg_working_directory --> pkg_agent + pkg_working_directory --> pkg_error pkg_working_directory --> pkg_fs pkg_working_directory --> pkg_llm pkg_working_directory --> pkg_session @@ -806,10 +810,6 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1025,6 +1025,11 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout + pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1061,6 +1066,7 @@ flowchart TD pkg_tool_working_directory --> pkg_tools pkg_tool_working_directory --> pkg_working_directory pkg_shell_env --> pkg_app_boot + pkg_shell_env --> pkg_error pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools @@ -1178,6 +1184,7 @@ flowchart TD pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_shell pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_subprocess pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval @@ -1189,6 +1196,7 @@ flowchart TD pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_shell pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_subprocess pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval @@ -1685,7 +1693,6 @@ flowchart TD | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | | [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | @@ -1704,7 +1711,8 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`error`](../packages/util/error), [`http-proxy`](../packages/util/http-proxy) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`error`](../packages/util/error) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1721,7 +1729,7 @@ flowchart TD | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`error`](../packages/util/error), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1782,7 +1790,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1797,7 +1805,6 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1840,6 +1847,7 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1847,7 +1855,7 @@ flowchart TD | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-working-directory`](../packages/session/tool-working-directory) | `session` | [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`error`](../packages/util/error), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | @@ -1867,8 +1875,8 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) | | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 6b6f314b78..4445da55fa 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -32,12 +32,14 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-session": "workspace:*" + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-session": "workspace:*" + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*" diff --git a/packages/sandbox/sandbox/src/escalation.ts b/packages/sandbox/sandbox/src/escalation.ts index 84d5f49561..7e0193b6c8 100644 --- a/packages/sandbox/sandbox/src/escalation.ts +++ b/packages/sandbox/sandbox/src/escalation.ts @@ -16,7 +16,7 @@ * @module dsh-sandbox/escalation */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { assertNever } from '@deepseek-ai/dsh-util-values' import { escalationDisplayReason } from './locales.ts' import type { SandboxMode } from './index.ts' diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts index 26db8fe796..8c3002a4ea 100644 --- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { SANDBOX_PREPARATION_ERROR_CODES, SANDBOX_UNAVAILABLE, SandboxPreparationError, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' describe('SandboxUnavailableError', () => { diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index 95245937ec..48fc673d7e 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../util/error" } ] } diff --git a/packages/session/working-directory/package.json b/packages/session/working-directory/package.json index e7433cb7ca..9682f5da41 100644 --- a/packages/session/working-directory/package.json +++ b/packages/session/working-directory/package.json @@ -38,7 +38,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*" + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-sandbox-policy": "workspace:*", @@ -54,7 +55,8 @@ "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tool-working-directory": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", diff --git a/packages/session/working-directory/src/error.ts b/packages/session/working-directory/src/error.ts index ec2a2ef7a2..acae1f0307 100644 --- a/packages/session/working-directory/src/error.ts +++ b/packages/session/working-directory/src/error.ts @@ -1,6 +1,6 @@ /** Machine-routable Session working-directory failures. @module */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every {@link WorkingDirectoryError} code: diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 7a0f66ede6..6ef5ce0279 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -15,7 +15,7 @@ import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' import { WorkingDirectoryError } from './error.ts' -export { WORKING_DIRECTORY_ERROR_CODES, WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts' +export { WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts' declare module '@deepseek-ai/cordis' { interface Events { diff --git a/packages/session/working-directory/tsconfig.json b/packages/session/working-directory/tsconfig.json index 6d24c1126a..7d67ef3a9f 100644 --- a/packages/session/working-directory/tsconfig.json +++ b/packages/session/working-directory/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/error" } ] } diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index eac66fcb65..871ab18c3a 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -36,8 +36,8 @@ en: 5438a951817526d9 zh: 66459cdea830a8c8 /deepseek-ai-dsh-shell-env/understand-the-implementation/source-map: - en: 72eba3bfadcfa581 - zh: dcd83e02a1910f27 + en: da18240c8c1dc687 + zh: a854ddaca8953832 /deepseek-ai-dsh-shell-env/understand-the-implementation/collection: en: 0c2d7d5b2131630f zh: b44d4f0ddf89421b diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index 86037caffe..f6b005367e 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -87,6 +87,7 @@ This section explains the design decisions behind the registry and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry, `ShellEnvRegistry` service, and the built-in facts | +| [`src/error.ts`](src/error.ts) | `ShellEnvError` and its codes | ### Collection diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index a3d4a98516..99abd27556 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -87,6 +87,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口、`ShellEnvRegistry` 服务与内置事实 | +| [`src/error.ts`](src/error.ts) | `ShellEnvError` 及其 code | ### 收集 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4fb7d7de4f..3393956475 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } diff --git a/packages/shell/shell-env/src/error.ts b/packages/shell/shell-env/src/error.ts new file mode 100644 index 0000000000..cd320b4971 --- /dev/null +++ b/packages/shell/shell-env/src/error.ts @@ -0,0 +1,28 @@ +/** Machine-routable shell environment failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-error' + +/** + * Every {@link ShellEnvError} code: + * - `SHELL_ENV_CONTRIBUTION_INVALID` — a registered contributor returned an undeclared key or a non-string value; the + * shell command does not run, and the contributing plugin must be fixed. + */ +export const SHELL_ENV_ERROR_CODES = ['SHELL_ENV_CONTRIBUTION_INVALID'] as const + +/** One code from {@link SHELL_ENV_ERROR_CODES}. */ +export type ShellEnvErrorCode = typeof SHELL_ENV_ERROR_CODES[number] + +/** Shell environment failure carrying a stable {@link ShellEnvErrorCode}. */ +export class ShellEnvError extends HarnessError { + override readonly code: ShellEnvErrorCode + + /** + * @param message - names the contributor and the offending key. + * @param code - the failure class consumers route on. + */ + constructor(message: string, code: ShellEnvErrorCode) { + super(message, code) + this.name = 'ShellEnvError' + this.code = code + } +} diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index aa3012e2b4..ee84bf764b 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -14,42 +14,19 @@ import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { ShellEnvError } from './error.ts' import type { ToolExecution } from '@deepseek-ai/dsh-tools' // Declares `Context.profileContext`, the launcher-provided profile the built-ins read. import type {} from '@deepseek-ai/dsh-app-boot' +export { ShellEnvError, type ShellEnvErrorCode } from './error.ts' + declare module '@deepseek-ai/cordis' { interface Context { shellEnv: ShellEnvRegistry } } -/** - * Every {@link ShellEnvError} code: - * - `SHELL_ENV_CONTRIBUTION_INVALID` — a registered contributor returned an undeclared key or a non-string value; the - * shell command does not run, and the contributing plugin must be fixed. - */ -export const SHELL_ENV_ERROR_CODES = ['SHELL_ENV_CONTRIBUTION_INVALID'] as const - -/** One code from {@link SHELL_ENV_ERROR_CODES}. */ -export type ShellEnvErrorCode = typeof SHELL_ENV_ERROR_CODES[number] - -/** Shell environment failure carrying a stable {@link ShellEnvErrorCode}. */ -export class ShellEnvError extends HarnessError { - override readonly code: ShellEnvErrorCode - - /** - * @param message - names the contributor and the offending key. - * @param code - the failure class consumers route on. - */ - constructor(message: string, code: ShellEnvErrorCode) { - super(message, code) - this.name = 'ShellEnvError' - this.code = code - } -} - export const name = 'shell-env' export const inject: string[] = [] diff --git a/packages/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index ae66c37da9..050a016f80 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -30,7 +30,7 @@ "path": "../../boot/app-boot" }, { - "path": "../../llm/llm" + "path": "../../util/error" } ] } diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 4d4968fbfb..1ff80d99f7 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -37,12 +37,12 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" } } diff --git a/packages/subprocess/subprocess/src/error.ts b/packages/subprocess/subprocess/src/error.ts index 064dc494ae..febc495b63 100644 --- a/packages/subprocess/subprocess/src/error.ts +++ b/packages/subprocess/subprocess/src/error.ts @@ -1,6 +1,6 @@ /** Machine-routable subprocess failures shared by every `ctx.subprocess` provider and its consumers. @module */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every {@link SubprocessError} code: diff --git a/packages/subprocess/subprocess/tests/error.spec.ts b/packages/subprocess/subprocess/tests/error.spec.ts index ecdcb89a98..dbb0554043 100644 --- a/packages/subprocess/subprocess/tests/error.spec.ts +++ b/packages/subprocess/subprocess/tests/error.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { SUBPROCESS_ERROR_CODES, SubprocessError, diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index a39b295358..c961cc9e54 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../util/launch-environment" }, { - "path": "../../llm/llm" + "path": "../../util/error" } ] } diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7eae3438fc..205b2113f4 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -31,7 +31,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", @@ -39,6 +39,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" } } diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts index d8cd4caa86..be3e531bc5 100644 --- a/packages/subprocess/win32-process/src/errors.ts +++ b/packages/subprocess/win32-process/src/errors.ts @@ -1,4 +1,4 @@ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** Code of every {@link Win32Error}; the failing API and its numeric code stay in `api`, `win32Code`, and `message`. */ export const WIN32_ERROR = 'WIN32_ERROR' diff --git a/packages/subprocess/win32-process/tests/ffi.spec.ts b/packages/subprocess/win32-process/tests/ffi.spec.ts index a202503369..c3f4a9bfb8 100644 --- a/packages/subprocess/win32-process/tests/ffi.spec.ts +++ b/packages/subprocess/win32-process/tests/ffi.spec.ts @@ -1,6 +1,6 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { WIN32_ERROR, Win32Error, diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index ac86e810e8..7430b866fa 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -13,7 +13,7 @@ "path": "../../util/lazy-require" }, { - "path": "../../llm/llm" + "path": "../../util/error" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b81d0ed5a..341f928b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14162,6 +14162,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -15314,6 +15317,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:* version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-fs': specifier: workspace:* version: link:../../fs/fs @@ -15519,12 +15525,12 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../../boot/app-boot + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../util/home-paths - '@deepseek-ai/dsh-llm': - specifier: workspace:* - version: link:../../llm/llm '@deepseek-ai/dsh-shell': specifier: workspace:* version: link:../shell @@ -16987,15 +16993,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-http-proxy': specifier: workspace:* version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:* version: link:../../util/launch-environment - '@deepseek-ai/dsh-llm': - specifier: workspace:* - version: link:../../llm/llm packages/subprocess/subprocess-local: dependencies: @@ -17037,9 +17043,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-llm': + '@deepseek-ai/dsh-error': specifier: workspace:* - version: link:../../llm/llm + version: link:../../util/error packages/team/agent-team: dependencies: From 11a5bf8a55bb11a9ce7ae7e54775776c0dfca395 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:06:13 +0800 Subject: [PATCH 039/149] refactor(tool-fs): keep attachment store codes in read_image failures The attachment store already classifies its rejections, so read_image no longer translates them into ImageInputError codes. An AttachmentError keeps its name and code; for codes with a known remedy the tool rethrows it with a message naming the file and the next step, and the store error as cause. Other store failures pass through unchanged. ImageInputError keeps only the refusals the tool makes before storing the image: IMAGE_UNSUPPORTED_MODEL and IMAGE_UNSUPPORTED_FORMAT. The removed codes (IMAGE_TYPE_MISMATCH, IMAGE_INVALID, IMAGE_TOO_LARGE, IMAGE_CONVERSION_FAILED, IMAGE_STORE_FAILED) were never released. --- .../attachment-error-codes/guide.i18n.yaml | 4 +- .../attachment-error-codes/guide.md | 2 +- .../attachment-error-codes/guide.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 8 ++-- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/README.zh.md | 4 +- packages/fs/tool-fs/src/image-error.ts | 22 ++------- packages/fs/tool-fs/src/read-image.ts | 46 ++++++++++-------- packages/fs/tool-fs/tests/read-image.spec.ts | 48 ++++++++++--------- 9 files changed, 67 insertions(+), 73 deletions(-) diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml index 091cae83fa..66e712178e 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 4f2b2f0db0646d3b zh: 9467f41336a65c48 /attachment-failures-keep-their-codes/change: - en: daa5a93cf418a352 - zh: 3b391fbb4a87f2b1 + en: 0f8c05c401f15a7d + zh: adf5976a05802bf1 /attachment-failures-keep-their-codes/migration: en: 8599ead7a17bc7eb zh: 1120fd02065567f5 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md index 263ddf5df8..70acc07793 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -12,7 +12,7 @@ English | [中文](guide.zh.md) `AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: - When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the `AttachmentError` code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, `ATTACHMENT_READ_FAILED`, or an image admission code such as `INVALID_IMAGE` or `IMAGE_TOO_LARGE`. -- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` converts attachment failures to `ImageInputError` codes instead. +- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` also records the `AttachmentError` code and names the file and the next step in the message. `instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md index 674f00805b..7c00e63528 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -12,7 +12,7 @@ description: "AttachmentError 现在继承 HarnessError,工具失败与模型 `@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: - 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录 `AttachmentError` 的错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT`、`ATTACHMENT_READ_FAILED`,或 `INVALID_IMAGE`、`IMAGE_TOO_LARGE` 等图像准入错误码。 -- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 则把附件失败转换为 `ImageInputError` 错误码。 +- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 同样记录 `AttachmentError` 的错误码,并在消息中写明文件与下一步。 `instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index e8c96ec826..f9d9c8ac82 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -30,8 +30,8 @@ en: 9bddebaf4aff0a8d zh: fea0196162e3fcd1 /deepseek-ai-dsh-tool-fs/use-this-package/failures-and-recovery: - en: 06fe9bd1d49c1d53 - zh: a8a6963daae11267 + en: 4e33f0736e64c72b + zh: b7a0f2386760e08e /deepseek-ai-dsh-tool-fs/understand-the-implementation: en: e30f7038c28e93f8 zh: a3868dbd90367b13 @@ -39,8 +39,8 @@ en: 1f4d7213aa3d0d60 zh: aa6573f96c8b5220 /deepseek-ai-dsh-tool-fs/understand-the-implementation/source-map: - en: 791043de5ce46710 - zh: c5315d0195908f83 + en: 8be089bfe40bb7ab + zh: 7753de2feca62205 /deepseek-ai-dsh-tool-fs/understand-the-implementation/per-tool-flow: en: 35fbee1ef9811806 zh: 17c725e2b742c073 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index cf3ef031df..93985eeb21 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -83,7 +83,7 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` For `apply_patch`, syntax and preflight refusals throw with `isError: true` before any disk change. `FsError` preserves filesystem codes; `PatchToolError extends HarnessError` carries `PATCH_SYNTAX` and `PATCH_SYMLINK_SOURCE`. Once commit starts, the tool returns a value with `isError: false` even on failure or uncertainty, so known partial effects retain their diff metadata; a call cancelled during commit receives the tool registry's abort result like every other tool, and its completed changes remain visible through Review. `phase`, `cancelled`, ordered `operations` and `affectedPaths` describe the commit; each operation retains its index, kind, display paths, patch line, status, diagnostic and known changes. Full `before`/`after` text serves rendering and PTC; the Session stores the rendered result plus `meta.diffs` and `meta.stopped`. Creations store no diff, as for `write`; deletions and move sources store a removal diff only when the provider returned a bounded basis, using `newText: ''` because `FileDiff.newText` cannot be null. -`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; any store failure other than an `AttachmentError`, such as an `AbortError`, another `HarnessError`, or a defect, passes through unchanged. +`read_image` refusals it makes before storing the image throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model) or `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file). Refusals from the attachment store keep their `AttachmentError` `name` and `code`, such as `INVALID_IMAGE`, `IMAGE_TYPE_MISMATCH`, or `IMAGE_DIMENSION_TOO_LARGE`; for codes with a known remedy, the message names the file and the next step, and the store error is the `cause`. An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; any other store failure, such as an `AbortError`, another `HarnessError`, or a defect, passes through unchanged. Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include the `ToolArgsError` (`INVALID_ARGS`) messages `invalid arguments: file_path must be a non-empty string` and `invalid arguments: limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. @@ -108,7 +108,7 @@ The tools are the executor; policy is an event gate. The tools inject no policy | [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, tool composition, `read_image` attachments gate | | [`src/read.ts`](src/read.ts) | `read` executor: one stat, streaming decision, window build, observation | | [`src/read-image.ts`](src/read-image.ts) | `read_image` executor: route and media-type gates, bounded bytes, attachment save | -| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` and its closed `read_image` failure codes | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` and the closed codes for refusals `read_image` makes itself | | [`src/write.ts`](src/write.ts) | `write` executor: intent waterfall, atomic write, observation | | [`src/apply-patch.ts`](src/apply-patch.ts), [`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` registration, strict commit values and compact diagnostics | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | Pure call previews and known result diffs | diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 09af000f07..ec28bf145c 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" `apply_patch` 的语法与预检拒绝在磁盘变更前抛错,结果为 `isError: true`。`FsError` 保留文件系统错误码;`PatchToolError extends HarnessError` 携带 `PATCH_SYNTAX` 和 `PATCH_SYMLINK_SOURCE`。进入提交后,即使失败或结果不确定也返回 `isError: false` 的值,确保已知部分效果的 diff 元数据得以保留;提交期间被取消的调用与其他工具一样收到工具注册表的中止结果,已完成的变更仍可通过 Review 查看。`phase`、`cancelled`、有序 `operations` 与 `affectedPaths` 描述提交;每条操作保留索引、种类、展示路径、补丁行号、状态、诊断与已知变更。完整 `before`/`after` 文本供渲染和 PTC 使用;Session 保存渲染文本以及 `meta.diffs` 与 `meta.stopped`。新建与 `write` 一样不保存 diff;删除与移动源仅在提供方返回有界基线时保存删除 diff,并使用 `newText: ''`,因为 `FileDiff.newText` 不可为 null。 -`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AttachmentError` 以外的失败(例如 `AbortError`、其他 `HarnessError` 或缺陷)原样传递。 +`read_image` 在存储图像前自行作出的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)或 `IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)。附件存储作出的拒绝保留其 `AttachmentError` 的 `name` 与 `code`,例如 `INVALID_IMAGE`、`IMAGE_TYPE_MISMATCH` 或 `IMAGE_DIMENSION_TOO_LARGE`;对有明确补救办法的错误码,消息会写明文件与下一步,存储错误作为 `cause`。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的其他失败(例如 `AbortError`、其他 `HarnessError` 或缺陷)原样传递。 失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `ToolArgsError`(`INVALID_ARGS`)消息 `invalid arguments: file_path must be a non-empty string` 与 `invalid arguments: limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 @@ -108,7 +108,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`Config`、工具组合、`read_image` 附件门禁 | | [`src/read.ts`](src/read.ts) | `read` 执行器:一次 stat、流式决策、窗口构建、观察 | | [`src/read-image.ts`](src/read-image.ts) | `read_image` 执行器:路由与媒体类型门禁、有界字节、附件保存 | -| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` 及其封闭的 `read_image` 失败码 | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` 及 `read_image` 自行拒绝时使用的封闭错误码 | | [`src/write.ts`](src/write.ts) | `write` 执行器:意图 waterfall、原子写入、观察 | | [`src/apply-patch.ts`](src/apply-patch.ts)、[`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` 注册、严格提交值与精简诊断 | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | 纯调用预览与已知结果 diff | diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts index a19a0b0f8d..ccacb48744 100644 --- a/packages/fs/tool-fs/src/image-error.ts +++ b/packages/fs/tool-fs/src/image-error.ts @@ -3,37 +3,23 @@ import { HarnessError } from '@deepseek-ai/dsh-error' /** - * Every machine-routable `read_image` failure code: + * Every `read_image` failure the tool classifies itself, before the attachment store sees the bytes: * * - `IMAGE_UNSUPPORTED_MODEL`: the calling route does not declare image input; switch to an image-capable model. * - `IMAGE_UNSUPPORTED_FORMAT`: the extension, the file signature, or the deployment's accepted media types * exclude the format; convert the file to an accepted format or read another file. - * - `IMAGE_TYPE_MISMATCH`: the extension or file signature declares one format but the bytes decode as another; rename or convert the file. - * - `IMAGE_INVALID`: bytes in a supported format do not decode; the file is truncated or corrupt. - * - `IMAGE_TOO_LARGE`: a side, the decoded pixel count, or the stored byte budget exceeds the deployment limit; - * downscale the image and read the smaller copy. - * - `IMAGE_CONVERSION_FAILED`: a 16-bit PNG could not be normalized to 8-bit sRGB; convert it to an 8-bit format. - * - `IMAGE_STORE_FAILED`: the attachment store could not commit the image for another reason; report the cause - * instead of retrying unchanged. + * + * Failures the attachment store classifies keep their `AttachmentError` codes. */ export const IMAGE_INPUT_ERROR_CODES = [ 'IMAGE_UNSUPPORTED_MODEL', 'IMAGE_UNSUPPORTED_FORMAT', - 'IMAGE_TYPE_MISMATCH', - 'IMAGE_INVALID', - 'IMAGE_TOO_LARGE', - 'IMAGE_CONVERSION_FAILED', - 'IMAGE_STORE_FAILED', ] as const /** One `read_image` failure code. */ export type ImageInputErrorCode = typeof IMAGE_INPUT_ERROR_CODES[number] -/** - * Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. `IMAGE_TOO_LARGE` and - * `IMAGE_TYPE_MISMATCH` share their spelling with `AttachmentError` codes, so consumers route on `name` - * and `code` together, and code-only predicates such as `isAttachmentError` do not identify this class. - */ +/** Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. */ export class ImageInputError extends HarnessError { override readonly code: ImageInputErrorCode diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 9f7d714ed6..be422b38aa 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -215,45 +215,49 @@ interface SaveFailureFacts { } /** - * Classify one `saveImage` rejection as the error `read_image` throws. - * Attachment admission codes become {@link ImageInputError} codes and other - * `AttachmentError` codes become `IMAGE_STORE_FAILED`, keeping the original as - * `cause`. The attachment store reports its own failures as `AttachmentError`, - * so any other value, including cancellation, passes through unchanged. + * Add the read path and the next step to one `saveImage` rejection. The attachment store + * classifies its own failures, so an `AttachmentError` keeps its `name` and `code` and only + * gains a message naming the file and remedy, with the original as `cause`; a code without + * a remedy, and any value that is not an `AttachmentError`, passes through unchanged. * @param error - the value `saveImage` rejected with. - * @param attachments - the store whose limits the refusal names. - * @param facts - the path and media-type facts the refusal names. + * @param attachments - the store whose limits the message names. + * @param facts - the path and media-type facts the message names. * @returns the error to throw from the tool body. */ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFailureFacts): unknown { - const { displayPath, extension, mediaType, sniffed } = facts - const refusal = (detail: string, code: ImageInputError['code']) => - new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) - const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') if (!(error instanceof AttachmentError)) return error + const detail = saveFailureDetail(error, attachments, facts) + return detail === undefined + ? error + : new AttachmentError(`cannot read "${facts.displayPath}": ${detail}`, error.code, { cause: error }) +} + +/** The model-facing remedy for one attachment rejection, or undefined when the store message stands. */ +function saveFailureDetail(error: AttachmentError, attachments: AttachmentStore, facts: SaveFailureFacts): string | undefined { + const { extension, mediaType, sniffed } = facts // Size refusals stay recoverable tool errors: an oversized image must never // enter durable history, where it would ride every later model request past // provider-side dimension rejections. switch (error.code) { case 'IMAGE_DIMENSION_TOO_LARGE': - return refusal(`at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + return `at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy` case 'IMAGE_TOO_MANY_PIXELS': - return refusal(`the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + return `the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy` case 'IMAGE_TOO_LARGE': case 'IMAGES_TOO_LARGE': - return refusal('the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy', 'IMAGE_TOO_LARGE') + return 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy' case 'UNSUPPORTED_IMAGE_TYPE': - return refusal(`${mediaType} images are not accepted by this deployment`, 'IMAGE_UNSUPPORTED_FORMAT') + return `${mediaType} images are not accepted by this deployment` case 'INVALID_IMAGE': - return refusal('the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt', 'IMAGE_INVALID') + return 'the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt' case 'IMAGE_TYPE_MISMATCH': return sniffed - ? refusal(`the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, 'IMAGE_TYPE_MISMATCH') - : refusal(`the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, 'IMAGE_TYPE_MISMATCH') + ? `the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt` + : `the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` case 'ATTACHMENT_WRITE_FAILED': return /16-bit PNG/iu.test(error.message) - ? refusal('the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry', 'IMAGE_CONVERSION_FAILED') - : storeFailed(error.message) + ? 'the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry' + : undefined case 'TOO_MANY_IMAGES': case 'INVALID_IMAGE_BASE64': case 'INVALID_FILE_BASE64': @@ -263,7 +267,7 @@ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFa case 'ATTACHMENT_READ_FAILED': case 'ATTACHMENT_PROJECTION_UNSUPPORTED': case 'ATTACHMENT_FILES_UNSUPPORTED': - return storeFailed(error.message) + return undefined /* v8 ignore next -- Closed AttachmentErrorCode union; assertNever checks exhaustiveness at compile time. */ default: return assertNever(error.code) diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index bced7b466e..a55bb1c717 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -397,7 +397,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'broken' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_INVALID' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'INVALID_IMAGE' }) }) it('applies the deployment media-type policy to the sniffed format', async () => { @@ -463,7 +463,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'sniffed' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('the file signature claims image/png, but the bytes decode as a different image format') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_TYPE_MISMATCH' }) }) }) @@ -593,7 +593,7 @@ describe('image admission failures', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('the .jpg extension declares image/jpeg') expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_TYPE_MISMATCH' }) }) it('caps an extension-less read at maxImageBytes before format detection', async () => { @@ -620,7 +620,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_TOO_MANY_PIXELS' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) @@ -630,7 +630,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('at least one image side exceeds the 2px limit') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_DIMENSION_TOO_LARGE' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) @@ -674,31 +674,35 @@ describe('image admission failures', () => { } it.each([ - ['IMAGE_TOO_LARGE', 'Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE', + ['IMAGE_TOO_LARGE', 'Image cannot be encoded within the configured normalized-image byte cap.', 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], - ['IMAGES_TOO_LARGE', 'Image batch exceeds the configured aggregate image-byte limit.', 'IMAGE_TOO_LARGE', + ['IMAGES_TOO_LARGE', 'Image batch exceeds the configured aggregate image-byte limit.', 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], - ['UNSUPPORTED_IMAGE_TYPE', 'Image type image/png is not accepted by this deployment.', 'IMAGE_UNSUPPORTED_FORMAT', + ['UNSUPPORTED_IMAGE_TYPE', 'Image type image/png is not accepted by this deployment.', 'image/png images are not accepted by this deployment'], - ['INVALID_IMAGE', 'Unsupported or malformed image data.', 'IMAGE_INVALID', + ['INVALID_IMAGE', 'Unsupported or malformed image data.', 'the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt'], - ['ATTACHMENT_WRITE_FAILED', 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'IMAGE_CONVERSION_FAILED', + ['ATTACHMENT_WRITE_FAILED', 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry'], - ['ATTACHMENT_WRITE_FAILED', 'Unable to persist attachment.', 'IMAGE_STORE_FAILED', - 'the attachment store could not save the image: Unable to persist attachment.'], - ...([ - 'TOO_MANY_IMAGES', 'INVALID_IMAGE_BASE64', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF', 'ATTACHMENT_CORRUPT', - 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', 'ATTACHMENT_FILES_UNSUPPORTED', - ] as const).map(code => [code, 'Stored attachment failed.', 'IMAGE_STORE_FAILED', - 'the attachment store could not save the image: Stored attachment failed.'] as const), - ] as const)('classifies AttachmentError %s (%s) as %s', async (attachmentCode, message, code, detail) => { - const cause = new AttachmentError(message, attachmentCode) + ] as const)('keeps AttachmentError %s and adds the read path and remedy', async (code, message, detail) => { + const cause = new AttachmentError(message, code) const result = await failingSave(() => cause) expect(result.isError).toBe(true) - expect(result.error?.info).toEqual({ name: 'ImageInputError', code }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code }) expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": ${detail}`) }) + it.each([ + 'ATTACHMENT_WRITE_FAILED', 'TOO_MANY_IMAGES', 'INVALID_IMAGE_BASE64', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF', + 'ATTACHMENT_CORRUPT', 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', + 'ATTACHMENT_FILES_UNSUPPORTED', + ] as const)('passes AttachmentError %s without a read remedy through unchanged', async (code) => { + const result = await failingSave(() => new AttachmentError('Stored attachment failed.', code)) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code }) + expect(text(result)).toBe('Error: Stored attachment failed.') + }) + it('passes a store defect, a classified HarnessError, and an AbortError through unchanged', async () => { const defect = await failingSave(() => new Error('unrelated infrastructure failure')) expect(defect.isError).toBe(true) @@ -726,13 +730,13 @@ describe('image admission failures', () => { expect(text(result)).not.toContain('cannot read') }) - it('classifies a save failure that is not cancellation even after the caller aborts', async () => { + it('keeps a save failure that is not cancellation even after the caller aborts', async () => { const controller = new AbortController() const result = await failingSave(() => { controller.abort() return new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED') }, 'red.png', controller.signal) - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'ATTACHMENT_WRITE_FAILED' }) }) it('reports a missing image file and a directory target through the fs vocabulary', async () => { From 05270c6fcbe119da39dca2e351684b603445b23b Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:22:51 +0800 Subject: [PATCH 040/149] test(sandbox,terminal): drop the new assertions to unknown The ACL preparation tests match the error cause with a partial object instead of a matcher cast to unknown, and the uncancelled cleanup failure test checks the retained owner activity through the public service rather than casting the service to reach its disposal method. Refs #7869 --- packages/sandbox/sandbox-local/tests/acl-grants.spec.ts | 6 +++--- packages/terminal/terminal/tests/service.spec.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index c98fbf0eb9..4f4c99c0e4 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -267,7 +267,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', message: expect.stringMatching(/workspace grant failed and its cleanup also failed/u) as string, - cause: expect.objectContaining({ errors: [mockState.addFailure, mockState.disposeFailure] }) as unknown, + cause: { errors: [mockState.addFailure, mockState.disposeFailure] }, }) expect(mockState.grants).toHaveLength(2) } finally { @@ -290,7 +290,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', message: expect.stringContaining('workspace and temp directory resolution failed') as string, - cause: expect.objectContaining({ code: 'ENOENT' }) as unknown, + cause: { code: 'ENOENT' }, }) expect(mockState.grants).toHaveLength(0) } finally { @@ -313,7 +313,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', message: expect.stringContaining('private temp directory creation failed') as string, - cause: expect.objectContaining({ code: 'EACCES' }) as unknown, + cause: { code: 'EACCES' }, }) } finally { if (previous === undefined) delete process.env.TMPDIR diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 3199deb1a0..a43448b761 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -309,8 +309,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(ctx.terminals.spawn(owner, { type: 'cleanup-failing' })).rejects.toBe(failure) expect(failure).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) - const internal = ctx.terminals as unknown as { disposeOwned(owner: Agent): Promise } - await expect(internal.disposeOwned(owner)).rejects.toThrow('failed to clean up PTY lifecycle') + expect(ctx.terminals.hasOwnerActivity(owner)).toBe(true) }) it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => { From 85c7b7be35444afca594a42c2178be373ede41c6 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:28:16 +0800 Subject: [PATCH 041/149] test(fs-local): cover readFile errno classification on every platform The Windows coverage lane left the errno branch of readFileAbortable uncovered: its only test denies reads with chmod, which is POSIX-only, and the v8 ignore did not reach the throw. readForEdit reads without a preceding stat, so a missing file and a directory exercise the branch on every platform; the ignore is removed. --- packages/fs/fs-local/src/fsio.ts | 1 - packages/fs/fs-local/tests/fsio.spec.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 63da0a95f0..c6959128a4 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -66,7 +66,6 @@ async function readFileAbortable(absolutePath: string, displayPath: string, verb try { return await readFile(absolutePath, signal ? { signal } : {}) } catch (error: unknown) { - /* v8 ignore next 2 -- a non-abort rejection needs a permission fault after a successful stat; POSIX covers it. */ if (isAbortError(error)) throw new FsError(`${verb} aborted`, 'FS_ABORTED') throw fsErrorFromErrno(verb, displayPath, error) } diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 4d4f9a3748..6c7379051d 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -981,6 +981,15 @@ describe('readForEdit + restoreLineEndings', () => { await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) + it('classifies a missing file and a directory read for editing', async () => { + await expect(readForEdit(join(dir, 'missing'), '~/missing')).rejects.toMatchObject({ + name: 'FsError', code: 'FS_NOT_FOUND', message: 'cannot edit "~/missing": not found', + }) + await expect(readForEdit(dir, '~/dir')).rejects.toMatchObject({ + name: 'FsError', code: 'FS_NOT_REGULAR_FILE', message: 'cannot edit "~/dir": not a regular file', + }) + }) + it('passes a live (non-aborted) signal through the read', async () => { const file = join(dir, 'a.txt') await writeFile(file, 'one\ntwo') From 3ce9462e7f8ee65e4b61578ccfc4c72b1c8e59a5 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:31:56 +0800 Subject: [PATCH 042/149] fix(terminal): keep both startup failures on TerminalBackendCleanupError's cause Renderers such as errorChain and the activation report expand AggregateError members but otherwise follow only cause, so a cause of just the setup failure hid the cleanup failure that may leave resources alive. The cause is now an AggregateError of both failures. An upgrade guide records that the class extends TerminalError instead of AggregateError. INIT_REJECTED is documented as the code a consumer that drives a shell uses when the shell rejects its setup input, and the persistent bash tool README records it. Refs #7869 --- .../guide.i18n.yaml | 16 ++++++++++++++ .../terminal-backend-cleanup-error/guide.md | 20 +++++++++++++++++ .../guide.zh.md | 20 +++++++++++++++++ .../tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-bash-persistent/README.md | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- .../terminal-bash/tests/index.spec.ts | 2 +- packages/terminal/terminal/README.i18n.yaml | 8 +++---- packages/terminal/terminal/README.md | 4 ++-- packages/terminal/terminal/README.zh.md | 4 ++-- packages/terminal/terminal/src/error.ts | 22 +++++++++++-------- 11 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml new file mode 100644 index 0000000000..fc72ae7b2e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md +/: + en: 91597636f5a71894 + zh: 02d2517952d86cfd +/terminalbackendcleanuperror-extends-terminalerror: + en: cb11df977825904b + zh: 080f157e78942a9d +/terminalbackendcleanuperror-extends-terminalerror/change: + en: bf3bd46faf910ff0 + zh: fd47d6e58f467a1e +/terminalbackendcleanuperror-extends-terminalerror/migration: + en: 85fe7dd354cf8657 + zh: e39dc4ce137aeb13 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md new file mode 100644 index 0000000000..7ab32fec51 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "TerminalBackendCleanupError extends TerminalError instead of AggregateError and carries the CLEANUP_FAILED code." +--- + +# `TerminalBackendCleanupError` extends `TerminalError` + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-terminal` exports `TerminalBackendCleanupError`, which a PTY backend throws when startup failed and cleaning its partial resources also failed. It previously extended `AggregateError`. It now extends `TerminalError`, so it is a `HarnessError` with code `CLEANUP_FAILED`, and a tool result that carries it records `error.name` `TerminalBackendCleanupError` and `error.code` `CLEANUP_FAILED`. The constructor, the `name`, `spawnError`, `cleanupError`, and the `errors` list in that order are unchanged; `errors` is typed `readonly unknown[]` instead of `any[]`. `cause` is a new `AggregateError` of the same two failures. `error instanceof AggregateError` is now `false`. + +Backends and consumers that only construct the error, read `spawnError` or `cleanupError`, or test `instanceof TerminalBackendCleanupError` are unaffected. + +## Migration + +1. Replace `error instanceof AggregateError` checks meant for this error with `error instanceof TerminalBackendCleanupError`, or with `error.code === 'CLEANUP_FAILED'` on a `TerminalError`. +2. Where TypeScript code reads members of `error.errors` without narrowing, narrow each `unknown` element before use, or read `spawnError` and `cleanupError`. +3. Confirm with `pnpm exec tsc --noEmit` in the consuming project, and check that a startup cleanup failure still reaches the handling path. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md new file mode 100644 index 0000000000..7f69c4558d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "TerminalBackendCleanupError 改为继承 TerminalError 而非 AggregateError,并带 CLEANUP_FAILED 错误码。" +--- + +# `TerminalBackendCleanupError` 改为继承 `TerminalError` + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-terminal` 导出的 `TerminalBackendCleanupError` 由 PTY 后端在启动失败且清理部分资源也失败时抛出。它原先继承 `AggregateError`,现在继承 `TerminalError`,因此是带 code `CLEANUP_FAILED` 的 `HarnessError`,携带它的工具结果会记录 `error.name` `TerminalBackendCleanupError` 和 `error.code` `CLEANUP_FAILED`。构造函数、`name`、`spawnError`、`cleanupError` 以及按原顺序排列的 `errors` 列表不变;`errors` 的类型由 `any[]` 改为 `readonly unknown[]`。`cause` 新增为同样两个失败组成的 `AggregateError`。`error instanceof AggregateError` 现在为 `false`。 + +只构造该错误、读取 `spawnError` 或 `cleanupError`、或检查 `instanceof TerminalBackendCleanupError` 的后端与消费方不受影响。 + +## 迁移 + +1. 把针对该错误的 `error instanceof AggregateError` 检查改为 `error instanceof TerminalBackendCleanupError`,或对 `TerminalError` 检查 `error.code === 'CLEANUP_FAILED'`。 +2. TypeScript 代码未经收窄就读取 `error.errors` 元素成员时,先收窄每个 `unknown` 元素,或改为读取 `spawnError` 与 `cleanupError`。 +3. 在消费项目中运行 `pnpm exec tsc --noEmit` 确认通过,并检查启动清理失败仍会进入原有的处理路径。 diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index a7d7a29c7c..3bf961d1ca 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: b398f124ea7b77b0 zh: 50855e601e6c1b88 /deepseek-ai-dsh-tool-bash-persistent/use-this-package/what-can-go-wrong: - en: 52e0e1489c6f8071 - zh: 27251d61351ee72d + en: 080e71c8720eab46 + zh: 2b0266e78c868d9c /deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 9feed7a656..3a6de11ff4 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -58,7 +58,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout ### What can go wrong -A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), a shell that does not accept the echo and history setup fails with `persistent bash shell did not accept initialization` (`TerminalError`, `INIT_REJECTED`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index c348502a09..f74537f54e 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,不接受回显与历史设置的 shell 以 `persistent bash shell did not accept initialization`(`TerminalError`,`INIT_REJECTED`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 2859644622..47cca862e9 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -162,7 +162,7 @@ describe('BashTerminalBackend startup rollback', () => { spawnError: startupFailure, cleanupError: cleanupFailure, errors: [startupFailure, cleanupFailure], - cause: startupFailure, + cause: expect.objectContaining({ errors: [startupFailure, cleanupFailure] }) as AggregateError, } satisfies Partial)) }) diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index e734b14bf2..9f24300c84 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -45,11 +45,11 @@ en: a2adfa2708882e19 zh: 002f257fd8c4addb /deepseek-ai-dsh-terminal/understand-the-implementation/ownership-and-cleanup-rules: - en: b669a68c739b062a - zh: cc641f850905095f + en: 3c977ec7192623b1 + zh: 78d14a23e087808a /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: - en: 0b8dc3a382aa7bf2 - zh: e6cc6906e01827d2 + en: b80a540215116c43 + zh: 1dc651d09ceb4b15 /deepseek-ai-dsh-terminal/further-exploration: en: 8459a3b70a9f8bad zh: 21372aa8939d2182 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index ff34d9a3cc..ac88814518 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -84,12 +84,12 @@ Each published session is a record of its id, owner, optional name, backend type ### Ownership and cleanup rules - Fencing uses the exact `Agent` object: `hasOwnerActivity(owner)` spans unpublished setup through final close with no publication gap, so lifecycle policy can fence the owner precisely. -- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. +- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors` and in an `AggregateError` `cause`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. - Caller cancellation keeps its exact `AbortSignal.reason`; `kill()` and disposal resolve only after the backend's captured process tree is quiescent. ### Send reservation -The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, `NO_SANDBOX`, and `INIT_REJECTED` with the same class. +The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, and `NO_SANDBOX` with the same class. A consumer that drives a shell uses `INIT_REJECTED` when that shell rejects the consumer's setup input.
diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index cef5a62110..07022207e5 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -84,12 +84,12 @@ kind: "package-reference" ### 所有权与清理规则 - 限制基于确切的 `Agent` 对象:`hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,没有发布竞态,因此生命周期策略可以精确限制所有者。 -- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,并在 `errors` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 +- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,在 `errors` 和 `AggregateError` 类型的 `cause` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 - 调用方取消保留其确切的 `AbortSignal.reason`;`kill()` 与 dispose 只在后端捕获的进程树完全停稳后完成。 ### 发送预留 -服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT`、`NO_SANDBOX` 与 `INIT_REJECTED`。 +服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT` 与 `NO_SANDBOX`。通过服务驱动 shell 的消费方在该 shell 拒绝自身的初始化输入时使用 `INIT_REJECTED`。
diff --git a/packages/terminal/terminal/src/error.ts b/packages/terminal/terminal/src/error.ts index b487ac9bc4..71db13687c 100644 --- a/packages/terminal/terminal/src/error.ts +++ b/packages/terminal/terminal/src/error.ts @@ -4,13 +4,14 @@ import { HarnessError } from '@deepseek-ai/dsh-error' /** * Machine-routable PTY service and backend failures. Backends use - * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a - * confined mode has no sandbox provider, `SESSION_CLOSING` and - * `SESSION_EXITED` for an operation on a closing or exited session, - * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses - * readiness during startup, and `CLEANUP_FAILED` (always as + * `NO_SANDBOX` when a confined mode has no sandbox provider, + * `SESSION_CLOSING` and `SESSION_EXITED` for an operation on a closing or + * exited session, `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits + * or misses readiness during startup, and `CLEANUP_FAILED` (always as * {@link TerminalBackendCleanupError}) when startup failed and the partial - * resources it left could not be cleaned. + * resources it left could not be cleaned. A consumer that drives a shell + * through the service uses `INIT_REJECTED` when that shell rejects the + * consumer's own setup input. */ export type TerminalErrorCode = | 'CLEANUP_FAILED' @@ -48,8 +49,9 @@ export class TerminalError extends HarnessError { /** * Backend-reported failure to clean partial resources after unpublished setup * failed; code `CLEANUP_FAILED`. Backend-owned resources may still be alive. - * `errors` lists both failures in the order `AggregateError` would, and - * `cause` is the setup failure. + * `errors` lists the setup failure, then the cleanup failure; `cause` is an + * `AggregateError` of the same two failures, so renderers that follow `cause` + * report both. */ export class TerminalBackendCleanupError extends TerminalError { /** The setup failure followed by the cleanup failure. */ @@ -63,7 +65,9 @@ export class TerminalBackendCleanupError extends TerminalError { readonly spawnError: unknown, readonly cleanupError: unknown, ) { - super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { cause: spawnError }) + super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { + cause: new AggregateError([spawnError, cleanupError], 'PTY backend startup and cleanup both failed'), + }) this.name = 'TerminalBackendCleanupError' this.errors = [spawnError, cleanupError] } From 088d1f3b0405fe85650eaf298321ab4fa7767eb9 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:38:50 +0800 Subject: [PATCH 043/149] fix(shell): classify only errno spawn failures and real cancellations subprocessSpawnFailure() now converts only errors that carry a string errno code and returns every other value unchanged, so an internal failure such as a dropped collect stream is no longer reported as cannot start / SUBPROCESS_SPAWN_FAILED. The bash and pwsh tools pass a rejection through unchanged only when it is the call's own cancellation reason; a spawn error that settles after the call was cancelled is still classified. The job starter reads the failure through an optional hooks binding instead of a definite-assignment assertion. Refs #7869 --- packages/shell/tool-bash/README.i18n.yaml | 4 +-- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/src/index.ts | 25 ++++++++++--------- packages/shell/tool-bash/tests/tools.spec.ts | 16 ++++++++++++ packages/shell/tool-pwsh/README.i18n.yaml | 4 +-- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/src/index.ts | 25 ++++++++++--------- .../shell/tool-pwsh/tests/background.spec.ts | 4 +-- packages/shell/tool-pwsh/tests/tools.spec.ts | 16 ++++++++++++ .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/error.ts | 22 +++++++--------- .../subprocess/subprocess/tests/error.spec.ts | 11 +++++--- 16 files changed, 89 insertions(+), 54 deletions(-) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 625910149b..95fd6ed161 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -111,8 +111,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/what-the-model-sees: - en: f4cebcfd09cd3b13 - zh: 4e3954f417b4a9ed + en: 1772eedea1c1ee6a + zh: a0d77bdd515cb517 /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/token-effect: en: a3a78ef476ec5e56 zh: dd2c8ad0f69e8ff1 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 411d5da79f..7c1a498b13 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -196,7 +196,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error, recognized by its errno `code`, becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`), even when it settles after the call was cancelled. #### Token effect diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 7f222c91aa..1e0f96c57f 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -196,7 +196,7 @@ renderer 输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` #### 模型看到什么 -验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 +验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;按 errno `code` 识别的平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`),在调用取消后才出现时也是如此。 #### Token 影响 diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 46554fc192..f3e8b14eb2 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -30,6 +30,7 @@ import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import { processJob, processOutcome, processSources, ringDelta } from './background.ts' +import type { ProcessJobHooks } from './background.ts' import { parseExitStatus, renderJobRead, renderPromoted, renderResult } from './render.ts' export const name = 'tool-bash' @@ -188,13 +189,14 @@ function toolAborted(): HarnessError { } /** - * Report a launch or preparation rejection as this tool's failure. A coded - * error keeps its class and code; this call's cancellation stays as thrown so - * the registry records it as aborted; any other value is classified as a - * subprocess spawn failure with the original as `cause`. + * Report a launch or preparation rejection as this tool's failure. This + * call's cancellation reason stays as thrown so the registry records it as + * aborted; a platform spawn error is classified as a subprocess spawn failure + * with the original as `cause`; any other value, including a coded error, + * keeps its own identity. */ function launchFailure(error: unknown, signal: AbortSignal): unknown { - return signal.aborted ? error : subprocessSpawnFailure(error, 'bash') + return signal.aborted && error === signal.reason ? error : subprocessSpawnFailure(error, 'bash') } /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ @@ -283,8 +285,7 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: BashToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined - // The registry runs the starter inside start(), so this is assigned before start() returns. - let failure!: () => { error: unknown } | undefined + let hooks: ProcessJobHooks | undefined const id = registry.start({ kind: 'bash', label: args.command, @@ -292,26 +293,26 @@ export function apply(ctx: Context, config: Config = {}): void { ...exec.agent ? { owner: exec.agent.id } : {}, output: processSources(() => proc), run: () => { - const hooks = processJob( + const job = processJob( async (signal) => { proc = await ctx.shell.execute({ ...spec, signal }) return proc }, started => processOutcome(started, escalationModes), ) - failure = () => hooks.failure() + hooks = job return { - done: hooks.done, + done: job.done, // A kill from outside this call (the human's, a parallel job_kill) // reaches the process here; its reason is what the model reads. cancel: (reason) => { stopped = reason - hooks.cancel(reason) + job.cancel(reason) }, } }, }) - return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } + return { id, process: () => proc, stopped: () => stopped, failure: () => hooks?.failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 554b7f4e35..45e92e05d5 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -1576,4 +1576,20 @@ describe('launch failures without a job registry', () => { }) expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) }) + + it('classifies a spawn error that settles after this call was cancelled', async () => { + const ctx = await setup() + const controller = new AbortController() + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(new Error('caller stopped')) + return Promise.reject(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' })) + }) + const result = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted-spawn-error'), + name: 'bash', + arguments: { command: 'true', description: 'd' }, + }) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } } }) + }) }) diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 8032f1b349..e4c8f16b4b 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -108,8 +108,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/what-the-model-sees: - en: d45b05d98cf6e7e3 - zh: b3c59fb3603f511e + en: c71e7f547de19d3d + zh: a9d9c3a84e1618b9 /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/token-effect: en: 243bad012dd99c7f zh: 5ee74905020bd49a diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 34bc941529..ff35a8068d 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -188,7 +188,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error, recognized by its errno `code`, becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`), even when it settles after the call was cancelled. #### Token effect diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index f81c92a602..6225e8ad33 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -188,7 +188,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### 模型看到什么 -验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 +验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;按 errno `code` 识别的平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`),在调用取消后才出现时也是如此。 #### Token 影响 diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 0ef0fa260b..1a5eac1e7d 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -39,6 +39,7 @@ import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' import { processJob, processOutcome, processSources, ringDelta } from './background.ts' +import type { ProcessJobHooks } from './background.ts' import { renderPwshJobRead, renderPwshPromoted, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -196,13 +197,14 @@ function toolAborted(): HarnessError { } /** - * Report a launch or preparation rejection as this tool's failure. A coded - * error keeps its class and code; this call's cancellation stays as thrown so - * the registry records it as aborted; any other value is classified as a - * subprocess spawn failure with the original as `cause`. + * Report a launch or preparation rejection as this tool's failure. This + * call's cancellation reason stays as thrown so the registry records it as + * aborted; a platform spawn error is classified as a subprocess spawn failure + * with the original as `cause`; any other value, including a coded error, + * keeps its own identity. */ function launchFailure(error: unknown, signal: AbortSignal): unknown { - return signal.aborted ? error : subprocessSpawnFailure(error, 'pwsh') + return signal.aborted && error === signal.reason ? error : subprocessSpawnFailure(error, 'pwsh') } /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ @@ -298,8 +300,7 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: PwshToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined - // The registry runs the starter inside start(), so this is assigned before start() returns. - let failure!: () => { error: unknown } | undefined + let hooks: ProcessJobHooks | undefined const id = registry.start({ kind: 'pwsh', label: args.command, @@ -307,26 +308,26 @@ export function apply(ctx: Context, config: Config = {}): void { ...exec.agent ? { owner: exec.agent.id } : {}, output: processSources(() => proc), run: () => { - const hooks = processJob( + const job = processJob( async (signal) => { proc = await ctx.shell.execute({ ...spec, signal }) return proc }, started => processOutcome(started, escalationModes), ) - failure = () => hooks.failure() + hooks = job return { - done: hooks.done, + done: job.done, // A kill from outside this call (the human's, a parallel job_kill) // reaches the process here; its reason is what the model reads. cancel: (reason) => { stopped = reason - hooks.cancel(reason) + job.cancel(reason) }, } }, }) - return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } + return { id, process: () => proc, stopped: () => stopped, failure: () => hooks?.failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 48b01dde16..870325ab91 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -345,13 +345,13 @@ describe('foreground commands as jobs (pwsh)', () => { it('a command whose start fails reports the failure as an error result and leaves no job behind', async () => { const { ctx, pwsh } = await setup() - pwsh.backgroundHandler = () => { throw new Error('spawn pwsh ENOENT') } + pwsh.backgroundHandler = () => { throw Object.assign(new Error('spawn pwsh EACCES'), { code: 'EACCES' }) } const result = await call(ctx, { command: 'Get-Missing', description: 'test command' }) expect(result).toMatchObject({ isError: true, error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_FAILED' } }, }) - expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh ENOENT') + expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh EACCES') expect(ctx.jobs.list()).toEqual([]) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 00fb01724f..7db5c6fc5d 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -1317,4 +1317,20 @@ describe('launch failures without a job registry', () => { }) expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) }) + + it('classifies a spawn error that settles after this call was cancelled', async () => { + const { ctx } = await setup() + const controller = new AbortController() + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(new Error('caller stopped')) + return Promise.reject(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })) + }) + const result = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted-spawn-error'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'd' }, + }) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } } }) + }) }) diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 762893c5a0..8f829ef784 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -39,8 +39,8 @@ en: 4d4cbb311575d071 zh: 7be2d265adb661bc /deepseek-ai-dsh-subprocess/use-this-package/what-can-go-wrong: - en: 718494d6530da700 - zh: a41b7d48a9d12178 + en: 819a0d9b6ac823ab + zh: 57be33bc8204dd3e /deepseek-ai-dsh-subprocess/understand-the-implementation: en: 25cd344c959cfd9e zh: 045880776ab82d1e diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 337afd9fba..f94817fa4d 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -81,7 +81,7 @@ Children never inherit the harness's ambient secrets: credential-shaped names an ### What can go wrong -Coded failures are `SubprocessError` instances whose `code` is one of `SUBPROCESS_ERROR_CODES`: an empty argv is `SUBPROCESS_INVALID_ARGV`, an executable lookup miss is `SubprocessExecutableNotFoundError` (`SUBPROCESS_EXECUTABLE_NOT_FOUND`), a provider's private runner that fails or breaks its channel is `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`, and an unloadable terminal implementation is `SUBPROCESS_PTY_UNAVAILABLE`. A spawn requested on an already aborted signal throws an error named `AbortError`. A spawn that never starts rejects `done` with the platform's original error, so consumers can inspect its errno fields; a consumer that reports the rejection as its own failure converts it with `subprocessSpawnFailure()`, which yields `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` otherwise. There is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. +Coded failures are `SubprocessError` instances whose `code` is one of `SUBPROCESS_ERROR_CODES`: an empty argv is `SUBPROCESS_INVALID_ARGV`, an executable lookup miss is `SubprocessExecutableNotFoundError` (`SUBPROCESS_EXECUTABLE_NOT_FOUND`), a provider's private runner that fails or breaks its channel is `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`, and an unloadable terminal implementation is `SUBPROCESS_PTY_UNAVAILABLE`. A spawn requested on an already aborted signal throws an error named `AbortError`. A spawn that never starts rejects `done` with the platform's original error, so consumers can inspect its errno fields; a consumer that reports the rejection as its own failure converts it with `subprocessSpawnFailure()`, which turns an error with a string errno `code` into `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` for any other errno, and returns every other value unchanged. There is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. ----- diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index da111f517a..90cabb9c87 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -81,7 +81,7 @@ const output = handle.collected.stdout?.readFrom(0) ### 可能出错的地方 -带 code 的失败都是 `SubprocessError`,其 `code` 属于 `SUBPROCESS_ERROR_CODES`:argv 为空是 `SUBPROCESS_INVALID_ARGV`,可执行文件查找未命中是 `SubprocessExecutableNotFoundError`(`SUBPROCESS_EXECUTABLE_NOT_FOUND`),提供方私有 runner 失败或通道损坏是 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED`,终端实现无法加载是 `SUBPROCESS_PTY_UNAVAILABLE`。在已 abort 的 signal 上请求 spawn 会抛出名为 `AbortError` 的错误。从未启动成功的 spawn 会让 `done` 以平台原始错误 reject,便于消费者检查 errno 字段;把该 reject 报告为自身失败的消费者用 `subprocessSpawnFailure()` 转换,`ENOENT` 得到 `SUBPROCESS_SPAWN_NOT_FOUND`,其余得到 `SUBPROCESS_SPAWN_FAILED`。从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 +带 code 的失败都是 `SubprocessError`,其 `code` 属于 `SUBPROCESS_ERROR_CODES`:argv 为空是 `SUBPROCESS_INVALID_ARGV`,可执行文件查找未命中是 `SubprocessExecutableNotFoundError`(`SUBPROCESS_EXECUTABLE_NOT_FOUND`),提供方私有 runner 失败或通道损坏是 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED`,终端实现无法加载是 `SUBPROCESS_PTY_UNAVAILABLE`。在已 abort 的 signal 上请求 spawn 会抛出名为 `AbortError` 的错误。从未启动成功的 spawn 会让 `done` 以平台原始错误 reject,便于消费者检查 errno 字段;把该 reject 报告为自身失败的消费者用 `subprocessSpawnFailure()` 转换:带字符串 errno `code` 的错误中,`ENOENT` 得到 `SUBPROCESS_SPAWN_NOT_FOUND`,其余 errno 得到 `SUBPROCESS_SPAWN_FAILED`;其他值原样返回。从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 ----- diff --git a/packages/subprocess/subprocess/src/error.ts b/packages/subprocess/subprocess/src/error.ts index febc495b63..df6acbec18 100644 --- a/packages/subprocess/subprocess/src/error.ts +++ b/packages/subprocess/subprocess/src/error.ts @@ -61,21 +61,17 @@ export class SubprocessExecutableNotFoundError extends SubprocessError { * `cause`. * @param error - the provider's rejection value. * @param program - the program the consumer asked to spawn, named in the message. - * @returns `error` unchanged when it is a `HarnessError` or a cancellation - * (`name === 'AbortError'`); otherwise a {@link SubprocessError} with - * `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` for - * any other value. + * @returns a {@link SubprocessError} for a platform spawn error, recognized by + * its string errno `code`: `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and + * `SUBPROCESS_SPAWN_FAILED` for any other errno. Every other value, including + * a `HarnessError`, is returned unchanged, because it is not a spawn failure + * this helper can classify. */ export function subprocessSpawnFailure(error: unknown, program: string): unknown { - if (error instanceof HarnessError) return error - if (error instanceof Error && error.name === 'AbortError') return error - const errno = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined - let detail = 'unprintable failure' - try { - detail = error instanceof Error ? error.message : String(error) - } catch { - // A provider-owned rejection whose conversion throws still yields a coded failure. - } + if (!(error instanceof Error) || error instanceof HarnessError) return error + const errno = (error as { code?: unknown }).code + if (typeof errno !== 'string') return error + const detail = error.message if (errno === 'ENOENT') { return new SubprocessError( `cannot start ${JSON.stringify(program)}: the program or the working directory does not exist (${detail})`, diff --git a/packages/subprocess/subprocess/tests/error.spec.ts b/packages/subprocess/subprocess/tests/error.spec.ts index dbb0554043..4916e05c06 100644 --- a/packages/subprocess/subprocess/tests/error.spec.ts +++ b/packages/subprocess/subprocess/tests/error.spec.ts @@ -34,9 +34,14 @@ describe('subprocessSpawnFailure', () => { }) const denied = Object.assign(new Error('spawn bash EACCES'), { code: 'EACCES' }) expect(subprocessSpawnFailure(denied, 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', cause: denied }) - expect(subprocessSpawnFailure('raw', 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', message: 'cannot start "bash": raw' }) - const unprintable = { [Symbol.toPrimitive]() { throw new Error('no') } } - expect(subprocessSpawnFailure(unprintable, 'bash')).toMatchObject({ message: 'cannot start "bash": unprintable failure' }) + }) + + it('returns values without an errno code unchanged', () => { + const internal = new Error('subprocess implementation dropped a requested collect stream') + expect(subprocessSpawnFailure(internal, 'bash')).toBe(internal) + const numeric = Object.assign(new Error('exit'), { code: 1 }) + expect(subprocessSpawnFailure(numeric, 'bash')).toBe(numeric) + expect(subprocessSpawnFailure('raw', 'bash')).toBe('raw') }) it('returns coded errors and cancellations unchanged', () => { From 449addf2c9cafd7c76656fb03ff9ec698c4d7468 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:42:23 +0800 Subject: [PATCH 044/149] fix(jobs): report a missing owner registry with a JobError code A job registration that names an owner in a composition without an Agent registry now throws JobError JOB_OWNER_REGISTRY_UNAVAILABLE instead of the tool-layer ToolContextUnavailableError: the registry is a service whose start() also has non-tool callers. jobs-local no longer depends on dsh-tools. Refs #7869 --- docs/config-catalog.md | 6 +++--- docs/config-catalog.zh.md | 6 +++--- docs/module-graph.md | 11 +++++------ docs/module-graph.zh.md | 11 +++++------ packages/jobs/jobs-local/README.i18n.yaml | 4 ++-- packages/jobs/jobs-local/README.md | 2 +- packages/jobs/jobs-local/README.zh.md | 2 +- packages/jobs/jobs-local/package.json | 6 ++---- packages/jobs/jobs-local/src/index.ts | 3 +-- packages/jobs/jobs-local/tests/jobs.spec.ts | 2 +- packages/jobs/jobs-local/tsconfig.json | 3 --- packages/jobs/jobs/src/error.ts | 2 ++ pnpm-lock.yaml | 3 --- 13 files changed, 26 insertions(+), 35 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f0e447ae59..7f4507050d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1891,7 +1891,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -4083,7 +4083,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:40`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4348,7 +4348,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:57`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dad8488a45..167e434a7b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1893,7 +1893,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -4085,7 +4085,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:40`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4350,7 +4350,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:57`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ diff --git a/docs/module-graph.md b/docs/module-graph.md index dbd423430a..065d448df7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -808,6 +808,10 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1023,11 +1027,6 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout - pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1803,6 +1802,7 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1845,7 +1845,6 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index caf5f595f8..f60f9a4e3d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -810,6 +810,10 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1025,11 +1029,6 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout - pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1805,6 +1804,7 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1847,7 +1847,6 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 93045928da..746d8cbf72 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -33,8 +33,8 @@ en: ae926795d8aaa88f zh: 0e6ec0265eefccf7 /deepseek-ai-dsh-jobs-local/use-this-package/what-can-go-wrong: - en: 3737bc2cfa7f7012 - zh: 07bc090cbfa44584 + en: 5600eb21a76970ee + zh: 66bf8fab3f380115 /deepseek-ai-dsh-jobs-local/understand-the-implementation: en: f782557ca8da3138 zh: e8f0082b9692cacc diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index 1f753e7f93..7f7b5b1401 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -64,7 +64,7 @@ Lifecycle facts are appended synchronously at the registration or settlement com ### What can go wrong -Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. Refusals are `JobError` instances: `JOB_CONTROLLER_UNAVAILABLE`, `JOB_LIMIT_REACHED`, `JOB_OWNER_NOT_LIVE`, `JOB_NOT_FOUND`, and `JOB_FOREIGN_SESSION`; an owned registration without the agent registry throws `ToolContextUnavailableError`. A wait whose signal aborts rejects with an error named `AbortError`. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. +Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. Refusals are `JobError` instances: `JOB_CONTROLLER_UNAVAILABLE`, `JOB_LIMIT_REACHED`, `JOB_OWNER_NOT_LIVE`, `JOB_NOT_FOUND`, and `JOB_FOREIGN_SESSION`; an owned registration without the agent registry throws `JOB_OWNER_REGISTRY_UNAVAILABLE`. A wait whose signal aborts rejects with an error named `AbortError`. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. ----- diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 30dc26d3b0..ee2ff3c879 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 可能出什么问题 -没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。拒绝均为 `JobError`:`JOB_CONTROLLER_UNAVAILABLE`、`JOB_LIMIT_REACHED`、`JOB_OWNER_NOT_LIVE`、`JOB_NOT_FOUND` 与 `JOB_FOREIGN_SESSION`;没有 agent 注册表时登记带所有者的 job 会抛出 `ToolContextUnavailableError`。signal 中止的等待以名为 `AbortError` 的错误拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 +没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。拒绝均为 `JobError`:`JOB_CONTROLLER_UNAVAILABLE`、`JOB_LIMIT_REACHED`、`JOB_OWNER_NOT_LIVE`、`JOB_NOT_FOUND` 与 `JOB_FOREIGN_SESSION`;没有 agent 注册表时登记带所有者的 job 会以 `JOB_OWNER_REGISTRY_UNAVAILABLE` 失败。signal 中止的等待以名为 `AbortError` 的错误拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 ----- diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index b7e73d4753..b635f6289d 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -36,8 +36,7 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -52,7 +51,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 4e1a0959f6..378aaf0c08 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -18,7 +18,6 @@ import { ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobError, JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' -import { ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKind, JobOutcome, JobOutputRead, JobOutputSource, JobRead, JobRegisteredData, JobSettleCause, JobSettledData, JobSpec, JobStatus, JobToolCall, JobView, @@ -374,7 +373,7 @@ export class LocalJobRegistry extends JobRegistry { if (session === undefined) return undefined const agents = this.selfCtx.get('agents') if (agents === undefined) { - throw new ToolContextUnavailableError('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + throw new JobError('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)', 'JOB_OWNER_REGISTRY_UNAVAILABLE') } const owner = agents.get(session) if (owner === undefined) { diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 521c7647d6..f3d47532f2 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -768,7 +768,7 @@ describe('LocalJobRegistry owner isolation', () => { expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) .toThrow('background job ownership requires the agent registry') expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) - .toThrow(expect.objectContaining({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' })) + .toThrow(expect.objectContaining(jobError('JOB_OWNER_REGISTRY_UNAVAILABLE'))) // The failed registration mutated nothing: no stored job. expect(ctx.jobs.list()).toEqual([]) expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f]{16}$/) diff --git a/packages/jobs/jobs-local/tsconfig.json b/packages/jobs/jobs-local/tsconfig.json index 892c4da43e..f94627dd8f 100644 --- a/packages/jobs/jobs-local/tsconfig.json +++ b/packages/jobs/jobs-local/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../jobs" - }, - { - "path": "../../core/tools" } ] } diff --git a/packages/jobs/jobs/src/error.ts b/packages/jobs/jobs/src/error.ts index e0f0af64d9..28c2984382 100644 --- a/packages/jobs/jobs/src/error.ts +++ b/packages/jobs/jobs/src/error.ts @@ -10,6 +10,7 @@ import { HarnessError } from '@deepseek-ai/dsh-error' * - `JOB_NOT_FOUND` — the job id names no listed job. * - `JOB_FOREIGN_SESSION` — the job belongs to another Session. * - `JOB_OWNER_NOT_LIVE` — the owning Agent is no longer live. + * - `JOB_OWNER_REGISTRY_UNAVAILABLE` — a job names an owner, but the composition has no Agent registry to resolve it. * - `JOB_LIMIT_REACHED` — the registry's configured job limit is reached. */ export const JOB_ERROR_CODES = [ @@ -18,6 +19,7 @@ export const JOB_ERROR_CODES = [ 'JOB_NOT_FOUND', 'JOB_FOREIGN_SESSION', 'JOB_OWNER_NOT_LIVE', + 'JOB_OWNER_REGISTRY_UNAVAILABLE', 'JOB_LIMIT_REACHED', ] as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 341f928b4c..59409205d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13140,9 +13140,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout - '@deepseek-ai/dsh-tools': - specifier: workspace:* - version: link:../../core/tools packages/jobs/tool-jobs: dependencies: From 02c62579875ebc878cec487467074b78126e7949 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:43:12 +0800 Subject: [PATCH 045/149] fix(shell-env): keep the dsh-llm devDependency its tests import Moving ShellEnvError onto dsh-error replaced the existing dsh-llm devDependency, but the shell-env tests still import ToolCallId from dsh-llm. Both are now declared. Refs #7869 --- packages/shell/shell-env/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 3393956475..cb1beb7098 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-error": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59409205d1..26fc83966c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15528,6 +15528,9 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../util/home-paths + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm '@deepseek-ai/dsh-shell': specifier: workspace:* version: link:../shell From 25b2fd24c0d32578a36e3149c7b389d1d2e6a375 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:43:13 +0800 Subject: [PATCH 046/149] docs(sandbox-policy): state that a relative session cwd also fails The workspaceRoot row named only a NUL byte, while the session cwd check rejects relative paths too, as the resolve() JSDoc states. Refs #7869 --- packages/sandbox/sandbox-policy/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 9bef469d7d..1d00a1307f 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: 0b62eae224181946 - zh: 2e77e7f49bd074d4 + en: 4425f5a22850d740 + zh: 7dfd299ade0fc561 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: en: 841ea47ef1ed7ee6 zh: cd573e67a67742e2 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 0d26190122..96db8d41e9 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -45,7 +45,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and | Field | Default | Meaning | |---|---|---| | `mode` | `read-only` | The deployment default mode a session starts from, validated at load | -| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a cwd containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` | +| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a relative cwd or one containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` | | `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access | For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index a436f298d5..372ed91b4f 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 | -| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 | +| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 为相对路径或含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 | | `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 | 对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 From d67e146e73e98a564f5f26e880cad1d140fa4eb3 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Fri, 6 Nov 2026 17:43:31 +0800 Subject: [PATCH 047/149] fix(goal): report saved objectives when attachment admission closes --- docs/event-producer-consumer.md | 22 ++--- docs/event-producer-consumer.zh.md | 22 ++--- docs/subsystems/core.md | 7 ++ docs/subsystems/core.zh.md | 7 ++ packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent/src/runtime-types.ts | 7 ++ packages/goal/command-goal/README.i18n.yaml | 4 +- packages/goal/command-goal/README.md | 2 +- packages/goal/command-goal/README.zh.md | 2 +- packages/goal/command-goal/package.json | 1 + packages/goal/command-goal/src/index.ts | 39 +++++---- .../command-goal/tests/command-goal.spec.ts | 87 +++++++++++++++++-- 12 files changed, 152 insertions(+), 50 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5247fc4477..e60d35766c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,17 +12,17 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:404`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:295`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:434`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:341`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:394`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:314`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:422`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:411`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:302`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:441`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:340`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:348`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:329`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:362`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:401`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:321`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:705`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:712`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f8c8c77e5a..83ff9eb7d4 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -14,17 +14,17 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:404`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:295`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:434`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:341`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:322`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:394`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:314`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:422`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:411`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:302`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-team`](../packages/team/agent-team), `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-office`](../packages/document/tool-office), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:441`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:340`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:348`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:329`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:362`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:401`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:321`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:705`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:712`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 7e86e6e0d1..0e718fe573 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -74,6 +74,13 @@ interface Agent { /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context + /** + * Check the driver's admission before a compound operation writes state. + * Drivers without an admission check leave this method absent. + * @throws AgentClosedError once the driver stops accepting work. + */ + assertOpen?(): void + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index c82be05f03..c624316f57 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -78,6 +78,13 @@ interface Agent { /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context + /** + * Check the driver's admission before a compound operation writes state. + * Drivers without an admission check leave this method absent. + * @throws AgentClosedError once the driver stops accepting work. + */ + assertOpen?(): void + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 95c27e6c92..56cb982c75 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -265,7 +265,7 @@ export class ReactLoopAgent implements Agent { } while (activity !== this.activityDone) } - private assertOpen(): void { + assertOpen(): void { if (this.lifetimeSignal.aborted) { throw new AgentClosedError(this.id, { cause: this.lifetimeSignal.reason }) } diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 39b15064dc..98f0d3ebe7 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -202,6 +202,13 @@ declare module './types.ts' { /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context + /** + * Check the driver's admission before a compound operation writes state. + * Drivers without an admission check leave this method absent. + * @throws AgentClosedError once the driver stops accepting work. + */ + assertOpen?(): void + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 9efbb3b229..a057275fbe 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/README.i18n.yaml @@ -24,8 +24,8 @@ en: 38a273cb14017e13 zh: d4b7b501bcd9589b /deepseek-ai-dsh-command-goal/use-this-package/attachments: - en: e61cd7437ed4aa73 - zh: 552c36c3b8d16007 + en: 60281bad395286ac + zh: c1a6e51161717616 /deepseek-ai-dsh-command-goal/use-this-package/compose-it: en: ba5792bd8714f1c3 zh: c9e5500c0a3b7651 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index a627a15e82..96aa3c7b9a 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -46,7 +46,7 @@ Control words (`clear`, `pause`, `resume`, `edit`) are recognized only when they ### Attachments -`/goal` declares attachment support. Attachments accompany only an objective: after a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Later goal rounds read that ordinary session history; the goal domain stores no attachment state. Every other sub-command, and any refused create or edit, returns a direct error before a domain mutation and leaves the dispatching composer's draft and cards intact. +`/goal` declares attachment support. Attachments accompany only an objective: after a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Later goal rounds read that ordinary session history; the goal domain stores no attachment state. Every other sub-command, and any refused create or edit, returns a direct error before a domain mutation and leaves the dispatching composer's draft and cards intact. If the Agent starts closing before the command writes the objective, the command rejects with `AGENT_CLOSED`. If attachment submission is rejected after the objective is saved, the command reports the saved objective and leaves the attachments in the composer. ### Compose it diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index 9e4bac63d9..e8d0b08b85 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" ### 附件 -`/goal` 声明附件支持。附件只随目标本身:create 或 edit 成功后,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。后续 Goal Round 从普通会话历史读取这些内容,goal 领域不存储附件状态。其他任何子命令以及被拒绝的 create 或 edit,都会在领域变更前返回直接错误,并保留 composer 的草稿和附件卡。 +`/goal` 声明附件支持。附件只随目标本身:create 或 edit 成功后,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。后续 Goal Round 从普通会话历史读取这些内容,goal 领域不存储附件状态。其他任何子命令以及被拒绝的 create 或 edit,都会在领域变更前返回直接错误,并保留 composer 的草稿和附件卡。 如果 Agent 在命令写入目标前开始关闭,命令会以 `AGENT_CLOSED` 拒绝请求。如果目标已保存,但随后提交附件时遭到拒绝,命令会提示目标已保存,并将附件保留在输入区。 ### 组合方式 diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index f51a794228..881b44177e 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index 9238d76632..cd84cbddf5 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { AgentClosedError } from '@deepseek-ai/dsh-agent' import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import { GoalError } from '@deepseek-ai/dsh-goal' @@ -109,17 +110,25 @@ function missingGoal(action: string): CommandResult { } /** - * Submit the invocation's admitted composer attachments as one model-visible user - * message ahead of the goal's next round. The attachments precede a fixed text - * block naming their role, so a later goal round reads them from ordinary - * session history without the goal domain storing attachment state. + * Report the committed objective and submit its attachments ahead of the next goal round. + * A closed Agent leaves the objective saved and the attachments in the composer. */ -function submitObjectiveAttachments(invocation: CommandInvocation): void { - if (invocation.attachments.length === 0) return - invocation.agent.followup(createUserMessage({ - content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }], - source: { kind: 'user' }, - })) +function finishObjective(invocation: CommandInvocation, title: string, goal: GoalView): CommandResult { + if (invocation.attachments.length > 0) { + try { + invocation.agent.followup(createUserMessage({ + content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }], + source: { kind: 'user' }, + })) + } catch (error: unknown) { + if (!(error instanceof AgentClosedError)) throw error + return { + kind: 'error', + text: 'Goal saved. The agent is closing, so the attachments were not submitted.', + } + } + } + return renderGoal(title, goal) } /** Execute one parsed human command through the domain that owns persistence. */ @@ -131,6 +140,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman text: 'Attachments only accompany a goal objective: /goal or /goal edit .', } } + if (command.kind === 'create' || command.kind === 'edit') invocation.agent.assertOpen?.() try { const current = ctx.goals.get(invocation.agent) switch (command.kind) { @@ -148,19 +158,16 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman } } const created = ctx.goals.create(invocation.agent, { objective: command.objective }) - submitObjectiveAttachments(invocation) - return renderGoal('Goal created', created) + return finishObjective(invocation, 'Goal created', created) } case 'edit': { if (current === undefined) return missingGoal('edit') if (current.phase === 'complete') { const replaced = ctx.goals.create(invocation.agent, { objective: command.objective }) - submitObjectiveAttachments(invocation) - return renderGoal('Goal created', replaced) + return finishObjective(invocation, 'Goal created', replaced) } const edited = ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }) - submitObjectiveAttachments(invocation) - return renderGoal('Goal updated', edited) + return finishObjective(invocation, 'Goal updated', edited) } case 'pause': if (current === undefined) return missingGoal('pause') diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 6cb4cf83c9..e69b3fe89d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentClosedError } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -15,14 +15,19 @@ interface Harness { readonly ctx: Context readonly agent: Agent readonly session: Session + readonly closing: AbortController readonly plugin: Awaited> } /** Build a live idle agent accepted by the exact-identity goal service. */ -function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { +function stubAgent(ctx: Context, id: string): Pick { // Store-created: the command executor durably logs lifecycle events on it. const session = ctx.sessions.create(SessionId(id)) const inbox = createInboxStub() + const closing = new AbortController() + const assertOpen = (): void => { + if (closing.signal.aborted) throw new AgentClosedError(session.id) + } let status: AgentStatus = 'idle' const agent: Agent = { id: session.id, @@ -33,14 +38,18 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } ctx: new Context(), get status() { return status }, send: () => {}, - followup: () => {}, + assertOpen, + followup(input) { + assertOpen() + inbox.append('next-turn', input) + }, steer: () => {}, inject(input) { this.inbox.append('next-step', input) }, cancel() { status = 'idle' }, runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - return { agent, session } + return { agent, session, closing } } /** Mount the real command registry, goal domain, and producer. */ @@ -52,9 +61,9 @@ async function harness(): Promise { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const plugin = await ctx.plugin(commandGoal) - const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`) + const { agent, session, closing } = stubAgent(ctx, `command-goal-${Math.random()}`) await ctx.agents.register(agent) - return { ctx, agent, session, plugin } + return { ctx, agent, session, closing, plugin } } /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */ @@ -243,7 +252,7 @@ describe('/goal attachments', () => { const PNG = 'AAAA' /** Wire the fake store the executor admits through (once per harness). */ - function provideStore(test: Harness): void { + function provideStore(test: Harness, beforeSave?: () => Promise): void { let saved = 0 const saveImage = (input: { mediaType: string; name?: string }) => { saved += 1 @@ -260,6 +269,7 @@ describe('/goal attachments', () => { validateImage: () => Promise.resolve(), saveImage, async saveImages(inputs: readonly { mediaType: string; name?: string }[]) { + if (beforeSave !== undefined) await beforeSave() const refs = [] for (const input of inputs) refs.push(await saveImage(input)) return refs @@ -317,6 +327,69 @@ describe('/goal attachments', () => { expect(followup).toHaveBeenCalledTimes(1) }) + it.each(['create', 'edit'] as const)('rejects %s when closure begins during attachment storage', async (operation) => { + const test = await harness() + const saving = Promise.withResolvers() + const release = Promise.withResolvers() + provideStore(test, () => { + saving.resolve(undefined) + return release.promise + }) + const original = operation === 'edit' + ? test.ctx.goals.create(test.agent, { objective: 'original objective' }) + : undefined + const suffix = operation === 'edit' ? ' edit use the reference image' : ' use the reference image' + const result = runWithAttachments(test, suffix) + try { + await saving.promise + test.closing.abort() + release.resolve(undefined) + await expect(result).rejects.toMatchObject({ code: 'AGENT_CLOSED' }) + expect(test.ctx.goals.get(test.agent)).toEqual(original) + expect(test.agent.inbox.nextTurn).toEqual([]) + } finally { + release.resolve(undefined) + await result.catch(() => undefined) + await test.ctx.fiber.dispose() + } + }) + + it('reports the saved objective when closure interrupts attachment delivery after commit', async () => { + const test = await harness() + provideStore(test) + const stop = test.ctx.on('goal/changed', ({ change }) => { + if (change.operation === 'create') test.closing.abort() + }) + try { + const result = await runWithAttachments(test, ' use the reference image') + expect(result).toEqual({ + kind: 'error', + text: 'Goal saved. The agent is closing, so the attachments were not submitted.', + }) + expect(test.ctx.goals.get(test.agent)?.objective).toBe('use the reference image') + expect(test.session.snapshotEvents().at(-1)).toMatchObject({ + type: 'command/done', + data: result, + }) + expect(test.agent.inbox.nextTurn).toEqual([]) + } finally { + stop() + await test.ctx.fiber.dispose() + } + }) + + it('propagates unrelated attachment delivery failures', async () => { + const test = await harness() + provideStore(test) + const failure = new Error('inbox write failed') + vi.spyOn(test.agent, 'followup').mockImplementation(() => { throw failure }) + try { + await expect(runWithAttachments(test, ' use the reference image')).rejects.toBe(failure) + } finally { + await test.ctx.fiber.dispose() + } + }) + it('rejects attachments on sub-commands that cannot use them, leaving the domain untouched', async () => { const test = await harness() provideStore(test) From eed89dea5b8e0e1d2dcec7e8684865d0fe50a71c Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Fri, 6 Nov 2026 17:49:06 +0800 Subject: [PATCH 048/149] docs(goal): record the command agent dependency --- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1f8dfaee91..41eca08cbd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -761,6 +761,7 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_command_goal --> pkg_agent pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm @@ -1786,7 +1787,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a6070b4f5e..edfeb63f2d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -763,6 +763,7 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_command_goal --> pkg_agent pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm @@ -1788,7 +1789,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | From 38b59a84bc3d5c3b38c2d8ebeba039b836755161 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 6 Nov 2026 17:50:54 +0800 Subject: [PATCH 049/149] feat(forge): read pull request commits and commit ranges ForgeProvider gains optional commits and compare methods, which a provider implements together. The forge service returns undefined from commits and an unavailable failure from compare when either is missing. The GitHub provider lists commits through GraphQL pages pinned to one head, bounded by the new maxCommits setting, and reads ranges through the compare API in diff form. --- .../forge-github/README.i18n.yaml | 4 +- packages/experimental/forge-github/README.md | 4 +- .../experimental/forge-github/README.zh.md | 4 +- .../experimental/forge-github/src/commits.ts | 100 ++++++++++++++++++ .../experimental/forge-github/src/index.ts | 9 +- .../experimental/forge-github/src/provider.ts | 45 +++++++- .../forge-github/tests/plugin.spec.ts | 2 + .../forge-github/tests/provider.spec.ts | 76 ++++++++++++- .../forge-github/tests/review.spec.ts | 4 +- packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 2 + packages/experimental/forge/README.zh.md | 2 + packages/experimental/forge/src/index.ts | 32 +++++- packages/experimental/forge/src/types.ts | 34 ++++++ .../forge/tests/lifecycle.spec.ts | 6 +- .../experimental/forge/tests/registry.spec.ts | 21 ++++ 16 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 packages/experimental/forge-github/src/commits.ts diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index b096b71898..fec538644d 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: f2bd06d91b744305 - zh: ad956b539a43b880 + en: 282129be7093ee26 + zh: ff1eeeb907a46245 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: 90f3c10096be642a zh: 4e1b663c52019ac2 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 2d9e2ce57a..f392b899a0 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -34,7 +34,7 @@ Mount the provider after the forge registry and list every GitHub Enterprise hos hosts: [github.com, ghe.example.com] ``` -The provider runs `gh` as a host process, even when Sessions execute commands in another execution world. Sign in once per host with `gh auth login --hostname `. The child receives the host environment without `DSH_*` variables and without variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, so `GH_TOKEN` and `GITHUB_TOKEN` are not forwarded and `gh` uses its own credential store. Prompts, update notices, pagers, and colors are disabled. `maxOutputBytes` must be a positive safe integer; only `timeoutMs` is bounded by the runtime timer limit. `ghCommand`, `hosts`, `timeoutMs`, and `maxOutputBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-github). +The provider runs `gh` as a host process, even when Sessions execute commands in another execution world. Sign in once per host with `gh auth login --hostname `. The child receives the host environment without `DSH_*` variables and without variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, so `GH_TOKEN` and `GITHUB_TOKEN` are not forwarded and `gh` uses its own credential store. Prompts, update notices, pagers, and colors are disabled. `maxOutputBytes` and `maxCommits` must be positive safe integers; only `timeoutMs` is bounded by the runtime timer limit. `ghCommand`, `hosts`, `timeoutMs`, `maxOutputBytes`, and `maxCommits` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-github). The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `git@github.com:owner/name.git`, when its host is configured. HTTP(S) remotes retain custom ports in the host identity; SSH and Git transport ports are excluded. It serves pull request URLs of the form `https://///pull/` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A readiness probe runs `gh auth status --hostname `: exit status 0 is `ready`, another exit status is `unauthenticated` with the first diagnostic line, and a `gh` that cannot start is `unavailable`. A head lookup returns up to 20 open pull requests whose head is the branch, newest first, excluding pull requests from forks; a lookup of the default branch returns none. @@ -44,7 +44,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready --repo //`. Merging calls `PUT repos///pulls//merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`. -`diff` runs `gh pr diff --repo // --color never`. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's `too_large` error, which a diff over its own size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. +`diff` runs `gh pr diff --repo // --color never`. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head of the first one, else the listing is `unavailable`. `compare` reads `repos///compare/...` with the diff media type, accepting only full commits, and fails like `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's `too_large` error, which a diff over its own size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 1ac57ffa7d..da6995e916 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" hosts: [github.com, ghe.example.com] ``` -即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname ` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`maxOutputBytes` 必须是正安全整数;只有 `timeoutMs` 受运行时定时器上限约束。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。 +即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname ` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`maxOutputBytes` 与 `maxCommits` 必须是正安全整数;只有 `timeoutMs` 受运行时定时器上限约束。`ghCommand`、`hosts`、`timeoutMs`、`maxOutputBytes` 与 `maxCommits` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。 当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `git@github.com:owner/name.git` 这类 scp 形式。HTTP(S) 远端在主机标识中保留自定义端口;SSH 和 Git 传输端口不计入主机标识。它服务 `https://///pull/` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。就绪探测运行 `gh auth status --hostname `:退出码为 0 时为 `ready`,其他退出码为 `unauthenticated` 并带第一行诊断,`gh` 无法启动时为 `unavailable`。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。 @@ -44,7 +44,7 @@ kind: "package-reference" pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready --repo //`。合并经 `gh api` 调用 `PUT repos///pulls//merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。 -`diff` 运行 `gh pr diff --repo // --color never`。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 报告 `too_large` 错误时(diff 超过其自身大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 +`diff` 运行 `gh pr diff --repo // --color never`。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 必须与第一页相同,否则返回 `unavailable`。`compare` 以 diff 媒体类型读取 `repos///compare/...`,只接受完整提交,失败时与 `diff` 相同。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 报告 `too_large` 错误时(diff 超过其自身大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 diff --git a/packages/experimental/forge-github/src/commits.ts b/packages/experimental/forge-github/src/commits.ts new file mode 100644 index 0000000000..24a0d830b0 --- /dev/null +++ b/packages/experimental/forge-github/src/commits.ts @@ -0,0 +1,100 @@ +/** GitHub pull request commit listing through GraphQL, pinned to one head commit across pages. */ + +import type { ChangeRequestRef, ForgeCommit, ForgeCommitList, ForgeFailure } from '@deepseek-ai/dsh-experimental-forge' +import { z } from 'zod' +import { checkedRepository } from './urls.ts' + +/** Commits GitHub returns per GraphQL page, its connection maximum. */ +const PAGE = 100 + +/** One page of a pull request's commits with its head; `cursor` continues after the previous page. */ +export const COMMITS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + headRefOid + commits(first: $first, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { commit { oid messageHeadline authoredDate author { name user { login } } parents(first: 1) { nodes { oid } } } } + } + } + } +}` + +const pageSchema = z.object({ + data: z.object({ + repository: z.object({ + pullRequest: z.object({ + headRefOid: z.string(), + commits: z.object({ + totalCount: z.number().int().nonnegative(), + pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }), + nodes: z.array(z.object({ + commit: z.object({ + oid: z.string(), + messageHeadline: z.string(), + authoredDate: z.string(), + author: z.object({ name: z.string().nullable(), user: z.object({ login: z.string() }).nullable() }).nullable(), + parents: z.object({ nodes: z.array(z.object({ oid: z.string() })) }), + }), + })), + }), + }).nullable(), + }).nullable(), + }), +}) + +/** Runs one `gh` GraphQL request; resolves to its parsed JSON body, or why GitHub did not answer it. */ +export type GraphqlReader = (args: readonly string[], signal: AbortSignal) => Promise< + { readonly ok: true; readonly body: unknown } | { readonly ok: false; readonly failure: ForgeFailure }> + +/** + * List a pull request's commits oldest first, at most `limit`, reading pages until the limit or the last commit. + * Every page must report the head of the first page, so the list never mixes two pushes. + * @param read - runs one GraphQL request. + * @param ref - validated pull request address. + * @param limit - most commits listed. + * @param signal - cancellation of every page. + * @returns the commits at one head, or why GitHub did not list them. + */ +export async function listCommits( + read: GraphqlReader, + ref: ChangeRequestRef, + limit: number, + signal: AbortSignal, +): Promise { + const { host, owner, name } = checkedRepository(ref) + const commits: ForgeCommit[] = [] + let head: string | undefined + let cursor: string | null = null + for (;;) { + const body = await read([ + 'api', 'graphql', '--hostname', host, '-f', `query=${COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, + '-F', `number=${ref.number}`, '-F', `first=${Math.min(PAGE, limit - commits.length)}`, + ...cursor === null ? [] : ['-f', `cursor=${cursor}`], + ], signal) + if (!body.ok) return body.failure + const parsed = pageSchema.safeParse(body.body) + if (!parsed.success) return { code: 'unavailable', message: 'unexpected GitHub response to the commit listing' } + const pull = parsed.data.data.repository?.pullRequest + if (pull == null) return { code: 'not-found', message: `pull request ${ref.number} was not found` } + if (head !== undefined && pull.headRefOid !== head) { + return { code: 'unavailable', message: 'The pull request head changed while its commits were read' } + } + head = pull.headRefOid + for (const { commit } of pull.commits.nodes) { + commits.push({ + sha: commit.oid, + parent: commit.parents.nodes[0]?.oid ?? null, + title: commit.messageHeadline, + author: commit.author?.user?.login ?? commit.author?.name ?? null, + authoredAt: commit.authoredDate, + }) + } + const { hasNextPage, endCursor } = pull.commits.pageInfo + if (!hasNextPage || endCursor === null || commits.length >= limit) { + return { headSha: head, commits, truncated: pull.commits.totalCount > commits.length } + } + cursor = endCursor + } +} diff --git a/packages/experimental/forge-github/src/index.ts b/packages/experimental/forge-github/src/index.ts index a0bc66126a..16481d8c81 100644 --- a/packages/experimental/forge-github/src/index.ts +++ b/packages/experimental/forge-github/src/index.ts @@ -20,6 +20,8 @@ export interface Config { timeoutMs?: number /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ maxOutputBytes?: number + /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ + maxCommits?: number } export const Config: z = z.object({ @@ -27,6 +29,7 @@ export const Config: z = z.object({ hosts: z.array(z.string()).default(['github.com']), timeoutMs: z.number().default(30_000), maxOutputBytes: z.number().default(8_388_608), + maxCommits: z.number().default(250), }) const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d+)?$/i @@ -41,8 +44,8 @@ export function apply(ctx: Context, config: Config): void { if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) { throw new Error(`forge-github: timeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) } - if (!Number.isSafeInteger(resolved.maxOutputBytes) || resolved.maxOutputBytes <= 0) { - throw new Error('forge-github: maxOutputBytes must be a positive safe integer') + for (const key of ['maxOutputBytes', 'maxCommits'] as const) { + if (!Number.isSafeInteger(resolved[key]) || resolved[key] <= 0) throw new Error(`forge-github: ${key} must be a positive safe integer`) } - ctx.forge.register(new GitHubProvider(ghRunner(resolved), resolved.hosts, resolved.maxOutputBytes)) + ctx.forge.register(new GitHubProvider(ghRunner(resolved), resolved.hosts, resolved.maxOutputBytes, resolved.maxCommits)) } diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index ca56473956..c73a174f72 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -2,9 +2,11 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { - ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, - ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeProvider, + ForgeReadiness, ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, } from '@deepseek-ai/dsh-experimental-forge' +import { listCommits } from './commits.ts' +import type { GraphqlReader } from './commits.ts' import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from './gh.ts' import type { GhOutput, GhRunner } from './gh.ts' import { CHECKS_QUERY, hasGraphqlFields, HEAD_QUERY, mapCheckPageResponse, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts' @@ -40,8 +42,14 @@ export class GitHubProvider implements ForgeProvider { * @param gh - runner that executes `gh` on the host. * @param hosts - host names this provider serves, such as `github.com` and Enterprise hosts. * @param maxOutputBytes - maximum combined JSON bytes retained by an explicit check read. + * @param maxCommits - most commits one commit listing returns. */ - constructor(private readonly gh: GhRunner, hosts: readonly string[], private readonly maxOutputBytes: number) { + constructor( + private readonly gh: GhRunner, + hosts: readonly string[], + private readonly maxOutputBytes: number, + private readonly maxCommits: number, + ) { this.hosts = new Set(hosts.map(host => host.toLowerCase())) } @@ -160,6 +168,26 @@ export class GitHubProvider implements ForgeProvider { ], signal) } + /** List the pull request's commits through GraphQL, at most `maxCommits`, all read at one head. */ + async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) return failure + return await listCommits(this.graphql, ref, this.maxCommits, signal) + } + + /** Read the changes between two commits through the compare API in its diff form. */ + async compare(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise { + const { host, owner, name } = checkedRepository(ref) + const failure = this.hostFailure(host) + if (failure !== undefined) return failure + for (const commit of [base, head]) { + if (!COMMIT.test(commit)) throw new Error(`forge-github: invalid commit ${JSON.stringify(commit)}`) + } + return await this.read([ + 'api', '--hostname', host, '-H', 'Accept: application/vnd.github.diff', `repos/${owner}/${name}/compare/${base}...${head}`, + ], signal) + } + fileUrl(ref: ChangeRequestRef, commit: string, path: string): string { const { host, owner, name } = ref.repository return `https://${host}/${owner}/${name}/blob/${commit}/${encodedPath(path)}` @@ -199,6 +227,17 @@ export class GitHubProvider implements ForgeProvider { return { code: status === '404' || /could not resolve to a pullrequest/i.test(output.stderr) ? 'not-found' : 'unavailable', message } } + /** Run one GraphQL request; a refusal, a body that is not GraphQL, and GraphQL errors become failures. */ + private readonly graphql: GraphqlReader = async (args, signal) => { + const output = await this.read(args, signal) + if (typeof output !== 'string') return { ok: false, failure: output } + const body = parseJson(output) + if (!hasGraphqlFields(body)) return { ok: false, failure: { code: 'unavailable', message: 'unexpected GitHub response' } } + const errors = typeof body === 'object' && body !== null && 'errors' in body && Array.isArray(body.errors) ? body.errors : [] + if (errors.length > 0) return { ok: false, failure: responseFailure({ exitCode: 0, stdout: output, stderr: '' }, errors[0]) } + return { ok: true, body } + } + /** Run one changing `gh` command; a refusal or a `gh` that cannot start becomes a failure. */ private async act(args: readonly string[], signal: AbortSignal): Promise { const output = await this.attempt(args, signal) diff --git a/packages/experimental/forge-github/tests/plugin.spec.ts b/packages/experimental/forge-github/tests/plugin.spec.ts index a214182694..2701a2720e 100644 --- a/packages/experimental/forge-github/tests/plugin.spec.ts +++ b/packages/experimental/forge-github/tests/plugin.spec.ts @@ -56,6 +56,8 @@ describe('forge-github plugin', () => { [{ maxOutputBytes: 1.5 }, 'forge-github: maxOutputBytes must be a positive safe integer'], [{ maxOutputBytes: 0 }, 'forge-github: maxOutputBytes must be a positive safe integer'], [{ maxOutputBytes: Number.MAX_SAFE_INTEGER + 1 }, 'forge-github: maxOutputBytes must be a positive safe integer'], + [{ maxCommits: 0 }, 'forge-github: maxCommits must be a positive safe integer'], + [{ maxCommits: 2.5 }, 'forge-github: maxCommits must be a positive safe integer'], ])('rejects %j at load', async (config, message) => { const context = await forge() diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index ce513e0ab8..2433e580f7 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -24,7 +24,7 @@ function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput { } function provider(run: GhRunner): GitHubProvider { - return new GitHubProvider(run, ['github.com', 'GHE.corp.example'], 8_388_608) + return new GitHubProvider(run, ['github.com', 'GHE.corp.example'], 8_388_608, 250) } describe('GitHub provider addressing', () => { @@ -46,7 +46,7 @@ describe('GitHub provider addressing', () => { it('uses a configured HTTP port for URL mapping and GitHub queries', async () => { const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } })) - const subject = new GitHubProvider(gh.run, ['ghe.example.com:8443'], 8_388_608) + const subject = new GitHubProvider(gh.run, ['ghe.example.com:8443'], 8_388_608, 250) const repository = subject.repositoryFromRemote('https://ghe.example.com:8443/team/repo.git')! const ref = { repository, number: 1 } expect(subject.changeRequestFromUrl(subject.changeRequestUrl(ref))).toEqual(ref) @@ -92,7 +92,7 @@ describe('GitHub provider fetch', () => { const total = Buffer.byteLength(first.stdout) + Buffer.byteLength(last.stdout) for (const limit of [total, total - 1, Buffer.byteLength(first.stdout) - 1]) { const gh = fakeGh(args => args.includes('id=fixed-rollup') ? last : first) - const result = await new GitHubProvider(gh.run, ['github.com'], limit).fetchChecks(pull(1), signal) + const result = await new GitHubProvider(gh.run, ['github.com'], limit, 250).fetchChecks(pull(1), signal) if (limit === total) expect(result).toMatchObject({ checksTruncated: false }) else expect(result).toMatchObject({ code: 'too-large' }) expect(gh.calls).toHaveLength(limit < Buffer.byteLength(first.stdout) ? 1 : 2) @@ -192,7 +192,7 @@ describe('GitHub provider fetch', () => { it('rejects references retained after their host was removed from configuration', async () => { const gh = fakeGh(() => output('')) const retained = provider(gh.run).changeRequestFromUrl('https://ghe.corp.example/team/svc/pull/2')! - const replacement = new GitHubProvider(gh.run, ['github.com'], 8_388_608) + const replacement = new GitHubProvider(gh.run, ['github.com'], 8_388_608, 250) const results = await replacement.fetch([retained], signal) await expect(replacement.findByHead(retained.repository, 'topic', signal)).rejects.toThrow('is not configured') expect(results.get(changeRequestKey(retained))).toMatchObject({ code: 'unavailable' }) @@ -419,6 +419,72 @@ describe('GitHub provider reads', () => { }) }) +describe('GitHub provider commits', () => { + const sha = (n: number): string => String(n).repeat(40).slice(0, 40) + const node = (n: number, author: unknown = { name: 'Octo Cat', user: { login: 'octo' } }) => ({ commit: { + oid: sha(n), messageHeadline: `commit ${n}`, authoredDate: `2026-10-0${n}T00:00:00Z`, author, + parents: { nodes: n === 1 ? [] : [{ oid: sha(n - 1) }] }, + } }) + const page = (nodes: unknown[], next: string | null, head = sha(3), total = 3) => output({ data: { repository: { pullRequest: { + headRefOid: head, commits: { totalCount: total, pageInfo: { hasNextPage: next !== null, endCursor: next }, nodes }, + } } } }) + + it('lists the commits oldest first across pages pinned to one head, bounded by maxCommits', async () => { + const gh = fakeGh(args => args.includes('cursor=page-1') ? page([node(3, { name: 'Ann', user: null })], null) : page([node(1), node(2, null)], 'page-1')) + + await expect(provider(gh.run).commits(pull(7), signal)).resolves.toEqual({ + headSha: sha(3), + truncated: false, + commits: [ + { sha: sha(1), parent: null, title: 'commit 1', author: 'octo', authoredAt: '2026-10-01T00:00:00Z' }, + { sha: sha(2), parent: sha(1), title: 'commit 2', author: null, authoredAt: '2026-10-02T00:00:00Z' }, + { sha: sha(3), parent: sha(2), title: 'commit 3', author: 'Ann', authoredAt: '2026-10-03T00:00:00Z' }, + ], + }) + expect(gh.calls[0]).toEqual(expect.arrayContaining(['--hostname', 'github.com', 'owner=octo', 'name=demo', 'number=7', 'first=100'])) + expect(gh.calls[0]?.some(arg => arg.startsWith('cursor='))).toBe(false) + expect(gh.calls[1]).toEqual(expect.arrayContaining(['cursor=page-1'])) + + const bounded = fakeGh(() => page([node(1), node(2)], 'page-1')) + await expect(new GitHubProvider(bounded.run, ['github.com'], 8_388_608, 2).commits(pull(7), signal)) + .resolves.toMatchObject({ truncated: true, commits: [{ sha: sha(1) }, { sha: sha(2) }] }) + expect(bounded.calls).toHaveLength(1) + expect(bounded.calls[0]).toEqual(expect.arrayContaining(['first=2'])) + }) + + it('refuses a listing whose head moves between pages, a missing pull request, and an unexpected response', async () => { + const moved = fakeGh(args => args.includes('cursor=page-1') ? page([node(3)], null, sha(4)) : page([node(1)], 'page-1')) + await expect(provider(moved.run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'The pull request head changed while its commits were read' }) + await expect(provider(fakeGh(() => output({ data: { repository: { pullRequest: null } } })).run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'not-found', message: 'pull request 7 was not found' }) + await expect(provider(fakeGh(() => output({ data: { repository: null } })).run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'not-found', message: 'pull request 7 was not found' }) + await expect(provider(fakeGh(() => output({ data: { repository: { pullRequest: { headRefOid: 1 } } } })).run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response to the commit listing' }) + }) + + it('reports GraphQL errors, a body that is not GraphQL, and a refused request as failures', async () => { + await expect(provider(fakeGh(() => output({ errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible' }] })).run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible' }) + await expect(provider(fakeGh(() => output('not json')).run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' }) + await expect(provider(fakeGh(() => output('', 1, 'To get started with GitHub CLI, please run: gh auth login')).run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' }) + }) + + it('reads the changes between two commits through the compare API in its diff form', async () => { + const gh = fakeGh(() => output('diff --git a/x b/x\n')) + + await expect(provider(gh.run).compare(pull(7, enterprise), sha(1), sha(3), signal)).resolves.toBe('diff --git a/x b/x\n') + expect(gh.calls).toEqual([[ + 'api', '--hostname', 'ghe.corp.example', '-H', 'Accept: application/vnd.github.diff', `repos/team/svc/compare/${sha(1)}...${sha(3)}`, + ]]) + await expect(provider(gh.run).compare(pull(7), 'main', sha(3), signal)).rejects.toThrow('forge-github: invalid commit "main"') + await expect(provider(gh.run).compare(pull(7), sha(1), 'HEAD~1', signal)).rejects.toThrow('forge-github: invalid commit "HEAD~1"') + }) +}) + it('rejects readiness, actions, and content reads for an unconfigured host without starting gh', async () => { const gh = fakeGh(() => output('unexpected')) const subject = provider(gh.run) @@ -429,5 +495,7 @@ it('rejects readiness, actions, and content reads for an unconfigured host witho await expect(subject.merge(target, { method: 'squash', headSha: 'a'.repeat(40) }, signal)).resolves.toEqual({ code: 'failed', message }) await expect(subject.diff(target, signal)).resolves.toEqual({ code: 'unavailable', message }) await expect(subject.fileText(target, 'a'.repeat(40), 'file.txt', signal)).resolves.toEqual({ code: 'unavailable', message }) + await expect(subject.commits(target, signal)).resolves.toEqual({ code: 'unavailable', message }) + await expect(subject.compare(target, 'a'.repeat(40), 'b'.repeat(40), signal)).resolves.toEqual({ code: 'unavailable', message }) expect(gh.calls).toEqual([]) }) diff --git a/packages/experimental/forge-github/tests/review.spec.ts b/packages/experimental/forge-github/tests/review.spec.ts index e128508994..7b45b6d9d8 100644 --- a/packages/experimental/forge-github/tests/review.spec.ts +++ b/packages/experimental/forge-github/tests/review.spec.ts @@ -11,7 +11,7 @@ const single: ForgeReviewRequest = { kind: 'comment', headSha: head, comment } const success = { exitCode: 0, stdout: JSON.stringify({ html_url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), stderr: '' } function fixture() { const gh = vi.fn().mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ head: { sha: head }, state: 'open' }), stderr: '' }).mockResolvedValue(success) - return { gh, provider: new GitHubProvider(gh, ['github.com'], 8_388_608), signal: new AbortController().signal } + return { gh, provider: new GitHubProvider(gh, ['github.com'], 8_388_608, 250), signal: new AbortController().signal } } describe('review submission', () => { @@ -91,7 +91,7 @@ describe('review submission', () => { }) it('rejects a host outside the provider configuration without invoking gh', async () => { const { gh, signal } = fixture() - expect(await new GitHubProvider(gh, ['enterprise.test'], 8_388_608).submitReview(ref, single, signal)).toMatchObject({ ok: false, code: 'failed' }) + expect(await new GitHubProvider(gh, ['enterprise.test'], 8_388_608, 250).submitReview(ref, single, signal)).toMatchObject({ ok: false, code: 'failed' }) expect(gh).not.toHaveBeenCalled() }) }) diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index a51d57819e..164392fafd 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 9ca86ee541048ff2 - zh: 7aef4feb76542dd6 + en: e99829d47d5d3863 + zh: 64413f0ab530b37f /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 5d2bb55ec6..f2df88eb54 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -38,6 +38,8 @@ Mount the registry with at least one provider package, such as the GitHub provid `diff(ref, signal)` reads the change request's unified patch as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. `fileUrl(ref, commit, path)` returns the web page of that file at that commit. +Providers may implement `commits` and `compare` together. `commits(ref, signal)` lists a change request's commits oldest first, each with its full object name, first parent, title, author, and author time, at one head commit and with a `truncated` flag; `compare(ref, base, head, signal)` reads the unified patch from commit `base` to commit `head`. The registry returns undefined from `commits` and an `unavailable` failure from `compare` when the provider lacks either method, so a consumer can offer commit views only where both exist. + `fetchChecks(ref, signal)` reads one change request’s complete checks on demand. It has its own `fetchTimeoutMs` deadline and does not occupy the polling batch or publish to its watchers. Failure leaves previously polled snapshots intact. A provider without `fetchChecks` returns `unavailable`. Caller cancellation and provider or service disposal abort the read; disposal waits for it to settle. `ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one polling fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed change requests use `idleIntervalMs` to detect reopening; merged requests stop automatic polling. `refresh(ref)` polls a watched change request at once, including after a terminal state; if a fetch is already in flight for its forge, another fetch starts immediately after it settles. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`, which must be at least `activeIntervalMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index 8961c8d31d..8515de0652 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -38,6 +38,8 @@ kind: "package-reference" `diff(ref, signal)` 读取托管平台计算出的变更请求统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。 +提供方可以同时实现 `commits` 与 `compare`。`commits(ref, signal)` 在一个 head 提交上按从旧到新列出变更请求的提交,每个提交带完整对象名、第一个父提交、标题、作者与作者时间,并带 `truncated` 标志;`compare(ref, base, head, signal)` 读取从提交 `base` 到提交 `head` 的统一补丁。提供方缺少其中任一方法时,注册表的 `commits` 返回 undefined,`compare` 返回 `unavailable` 失败,因此使用方只在两者都存在时提供按提交查看。 + `fetchChecks(ref, signal)` 按需读取单个变更请求的完整检查,使用独立的 `fetchTimeoutMs` 时限,不占用轮询批次,也不向其监听方发布结果。读取失败时保留已有轮询快照。提供方未实现 `fetchChecks` 时返回 `unavailable`。调用方取消、提供方或服务销毁都会中止读取;销毁会等待读取结束。 `ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次轮询读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭的变更请求按 `idleIntervalMs` 轮询以检测重新打开;已合并的停止自动轮询。`refresh(ref)` 立即轮询一个被监听的变更请求,已结束的也包括在内;该 forge 已有读取在进行时,等待其结束后立即再读一次。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`;该值必须大于或等于 `activeIntervalMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index d5204d988a..2ed58446a1 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -6,7 +6,7 @@ import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { changeRequestKey } from './keys.ts' import { nextPollDelay } from './schedule.ts' import type { - ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, + ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, ForgeCommitList, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, } from './types.ts' @@ -247,6 +247,36 @@ export class ForgeService extends Service { (provider, operation) => provider.diff(ref, operation)) } + /** + * List the commits of a change request at its current head. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the read. + * @returns the commits, why the host did not provide them, or undefined when the provider does not read commits. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ + async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise { + return await this.invoke(this.registration(ref.repository.forge), signal, + (provider, operation) => provider.commits === undefined || provider.compare === undefined + ? Promise.resolve(undefined) : provider.commits(ref, operation)) + } + + /** + * Read the changes between two commits of a change request's repository. + * @param ref - change request served by a registered provider. + * @param base - full object name of the commit compared from. + * @param head - full object name of a commit that descends from `base`. + * @param signal - cancellation of the read. + * @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it; a provider that + * does not read commits returns `unavailable`. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ + async compare(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise { + return await this.invoke(this.registration(ref.repository.forge), signal, + (provider, operation) => provider.commits === undefined || provider.compare === undefined + ? Promise.resolve({ code: 'unavailable', message: 'This provider does not read commits' } as const) + : provider.compare(ref, base, head, operation)) + } + /** * Publish one comment or a completed review, then refresh the watched status. * @param ref - change request served by a registered provider. diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 35a977dcfe..1aa84c15aa 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -176,6 +176,30 @@ export interface ForgeActionFailure { readonly message: string } +/** One commit of a change request. */ +export interface ForgeCommit { + /** Full commit object name. */ + readonly sha: string + /** Full object name of the first parent, or null for a root commit. */ + readonly parent: string | null + /** First line of the commit message. */ + readonly title: string + /** Author name or login as the host reports it, or null when it reports none. */ + readonly author: string | null + /** ISO 8601 time the commit was authored. */ + readonly authoredAt: string +} + +/** The commits of a change request at one head commit. */ +export interface ForgeCommitList { + /** Head commit the list was read at. */ + readonly headSha: string + /** Commits oldest first, ending with the head commit unless `truncated`. */ + readonly commits: readonly ForgeCommit[] + /** Whether the change request has more commits than the provider lists; the listed ones are the oldest. */ + readonly truncated: boolean +} + /** Whether a provider can reach one host with the current credentials. */ export interface ForgeReadiness { /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ @@ -220,6 +244,16 @@ export interface ForgeProvider { fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ fileUrl(ref: ChangeRequestRef, commit: string, path: string): string + /** + * List the commits of a change request at its current head. A provider implements `commits` and `compare` + * together; omitting either declares that the change request can be read only as a whole. + */ + commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** + * Read the changes from commit `base` to commit `head` in the change request's repository, as one unified patch + * with `a/` and `b/` path prefixes. Both are full object names; `head` descends from `base`. + */ + compare?(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise } /** diff --git a/packages/experimental/forge/tests/lifecycle.spec.ts b/packages/experimental/forge/tests/lifecycle.spec.ts index 26e779eb87..42f649cefa 100644 --- a/packages/experimental/forge/tests/lifecycle.spec.ts +++ b/packages/experimental/forge/tests/lifecycle.spec.ts @@ -120,7 +120,7 @@ it('does not announce provider withdrawal after its registry has stopped', async expect(observer).toHaveBeenCalledTimes(1) }) -it.each(['readiness', 'markReady', 'merge', 'diff', 'fileText', 'submitReview', 'fetchChecks'] as const)( +it.each(['readiness', 'markReady', 'merge', 'diff', 'fileText', 'submitReview', 'fetchChecks', 'commits', 'compare'] as const)( 'cancels and drains %s when its provider stops', async (method) => { ctx = await forgeContext() const provider: ForgeProvider = new FakeProvider() @@ -141,6 +141,8 @@ it.each(['readiness', 'markReady', 'merge', 'diff', 'fileText', 'submitReview', vi.spyOn(provider, 'fileText').mockImplementation(async (_ref, _commit, _path, signal) => { await hold(signal); return 'file' }) provider.submitReview = async (_ref, _request, signal) => { await hold(signal); return { ok: true, url: 'https://fake.test/review' } } provider.fetchChecks = async (target, signal) => { await hold(signal); return snapshot(target) } + provider.commits = async (_ref, signal) => { await hold(signal); return { headSha: 'a'.repeat(40), commits: [], truncated: false } } + provider.compare = async (_ref, _base, _head, signal) => { await hold(signal); return 'diff' } const fiber = await provide(ctx, provider) const service = ctx.forge const signal = new AbortController().signal @@ -152,6 +154,8 @@ it.each(['readiness', 'markReady', 'merge', 'diff', 'fileText', 'submitReview', merge: () => service.merge(ref(1), { method: 'squash', headSha: 'a'.repeat(40) }, signal), diff: () => service.diff(ref(1), signal), fileText: () => service.fileText(ref(1), 'a'.repeat(40), 'file.txt', signal), + commits: () => service.commits(ref(1), signal), + compare: () => service.compare(ref(1), 'a'.repeat(40), 'b'.repeat(40), signal), } const rejected = expect(calls[method]()).rejects.toThrow('was unregistered') let disposed = false diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index c70a07057b..68bdf8811b 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -104,6 +104,27 @@ describe('forge registry', () => { expect(provider.actions).toEqual(['diff 3', `text 3 ${'b'.repeat(40)} src/a.ts`]) }) + it('reads commits and commit ranges only from a provider that implements both', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + await provide(ctx, provider) + const signal = new AbortController().signal + const list = { headSha: 'b'.repeat(40), truncated: false, commits: [] } + + await expect(ctx.forge.commits(ref(3), signal)).resolves.toBeUndefined() + await expect(ctx.forge.compare(ref(3), 'a'.repeat(40), 'b'.repeat(40), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'This provider does not read commits' }) + const commits = vi.fn(async () => list) + Object.assign(provider, { commits }) + await expect(ctx.forge.commits(ref(3), signal)).resolves.toBeUndefined() + const compare = vi.fn(async () => 'diff --git a/x b/x') + Object.assign(provider, { compare }) + await expect(ctx.forge.commits(ref(3), signal)).resolves.toBe(list) + await expect(ctx.forge.compare(ref(3), 'a'.repeat(40), 'b'.repeat(40), signal)).resolves.toBe('diff --git a/x b/x') + expect(commits).toHaveBeenCalledWith(ref(3), expect.any(AbortSignal)) + expect(compare).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), 'b'.repeat(40), expect.any(AbortSignal)) + }) + it('delegates change request actions to the provider of their forge and returns its outcome', async () => { ctx = await forgeContext() const provider = new FakeProvider() From 929e283c5e414a43585aa729471bce2799f09ff3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 6 Nov 2026 17:51:06 +0800 Subject: [PATCH 050/149] feat(forge-panel): classify changed files into review layers and read chosen commits Each diff file now carries a review layer and a change digest. The layer follows the new testPaths and interfacePaths globs and the linguist-generated lines of the root .gitattributes, which the panel reads at the compared side and caches beside change request patches. localCommits and changeRequestCommits list commits, and a branch scope or change request diff can name consecutive commits to show instead of the whole comparison. --- .../experimental/client-ui-forge/package.json | 6 +- .../experimental/client-ui-forge/src/diff.ts | 85 ++++++--- .../experimental/client-ui-forge/src/index.ts | 165 +++++++++++++++--- .../client-ui-forge/src/layers.ts | 87 +++++++++ .../client-ui-forge/src/local-diff.ts | 108 ++++++++++-- .../client-ui-forge/src/patch-cache.ts | 30 ++-- .../experimental/client-ui-forge/src/types.ts | 53 +++++- .../tests/controller.host.spec.ts | 122 +++++++++++-- .../client-ui-forge/tests/diff.host.spec.ts | 34 +++- .../client-ui-forge/tests/harness.ts | 15 +- .../client-ui-forge/tests/layers.host.spec.ts | 65 +++++++ .../tests/local-diff-safety.host.spec.ts | 29 +-- .../tests/local-diff.host.spec.ts | 151 ++++++++++++++-- .../tests/patch-cache.host.spec.ts | 41 +++-- .../client-ui-forge/tsconfig.host.json | 1 + pnpm-lock.yaml | 14 +- 16 files changed, 863 insertions(+), 143 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/layers.ts create mode 100644 packages/experimental/client-ui-forge/tests/layers.host.spec.ts diff --git a/packages/experimental/client-ui-forge/package.json b/packages/experimental/client-ui-forge/package.json index 7ab090b292..8dd840d825 100644 --- a/packages/experimental/client-ui-forge/package.json +++ b/packages/experimental/client-ui-forge/package.json @@ -71,7 +71,8 @@ "diff": "^9.0.0", "zod": "4.6.6", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-storage-domain": "workspace:*" + "@deepseek-ai/dsh-storage-domain": "workspace:*", + "picomatch": "^4.0.7" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -115,7 +116,8 @@ "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", - "@deepseek-ai/dsh-api-remote-access": "workspace:*" + "@deepseek-ai/dsh-api-remote-access": "workspace:*", + "@types/picomatch": "^4.0.3" }, "files": [ "locale/*.json", diff --git a/packages/experimental/client-ui-forge/src/diff.ts b/packages/experimental/client-ui-forge/src/diff.ts index aa6cc8515a..4ec3d62b71 100644 --- a/packages/experimental/client-ui-forge/src/diff.ts +++ b/packages/experimental/client-ui-forge/src/diff.ts @@ -1,7 +1,11 @@ /** Unified patch parsing into the panel's browser-safe diff model, and the diff and size of an untracked file. */ +import { createHash } from 'node:crypto' import { parsePatch } from 'diff' -import type { ForgeChangedFile, ForgeDiffFile, ForgeDiffStatus } from './types.ts' +import type { ForgeChangedFile, ForgeDiffFile, ForgeDiffHunk, ForgeDiffStatus } from './types.ts' + +/** A changed file before its path is classified into a review layer. */ +export type ParsedDiffFile = Omit /** Git's name for the missing side of an added or deleted file. */ const NULL_PATH = '/dev/null' @@ -9,6 +13,9 @@ const NULL_PATH = '/dev/null' /** Bytes Git inspects for a NUL byte before it calls content binary. */ const BINARY_PROBE_BYTES = 8000 +/** Blob names of both sides on a Git `index` header line, which identify a binary change that carries no hunks. */ +const INDEX_LINE = /^index ([0-9a-f]+\.\.[0-9a-f]+)/m + /** * Parse a Git-style unified patch, as `git diff` or a code host prints it, * with `a/` and `b/` path prefixes. Hunk ranges follow jsdiff: a range that @@ -16,8 +23,11 @@ const BINARY_PROBE_BYTES = 8000 * @param patch - the patch text. * @returns one file per patch section in patch order. */ -export function parseDiff(patch: string): ForgeDiffFile[] { - return parsePatch(patch).flatMap((section) => { +export function parseDiff(patch: string): ParsedDiffFile[] { + const parsed = parsePatch(patch) + // Hunk lines start with a space, `+`, `-`, or `\`, so only a Git section header starts a line with `diff --git `. + const headers = patch.split(/^(?=diff --git )/m).filter(section => section.startsWith('diff --git ')) + return parsed.flatMap((section, index) => { const before = pathOf(section.oldFileName, 'a/') const after = pathOf(section.newFileName, 'b/') const path = after ?? before @@ -33,46 +43,67 @@ export function parseDiff(patch: string): ForgeDiffFile[] { else if (line.startsWith('-')) deletions++ } } + const binary = section.isBinary === true + const previousPath = status === 'renamed' ? before : null + const hunks = section.hunks.map(({ oldStart, oldLines, newStart, newLines, lines }) => ( + { oldStart, oldLines, newStart, newLines, lines })) + // Each header matches its parsed section only when every section starts with a Git header. + const header = binary && headers.length === parsed.length ? String(headers[index]) : '' + const blobs = INDEX_LINE.exec(header)?.[1] ?? '' return [{ - path, - previousPath: status === 'renamed' ? before : null, - status, - binary: section.isBinary === true, - oversized: false, - untracked: false, - additions, - deletions, - hunks: section.hunks.map(({ oldStart, oldLines, newStart, newLines, lines }) => ({ oldStart, oldLines, newStart, newLines, lines })), + path, previousPath, status, binary, oversized: false, untracked: false, additions, deletions, hunks, + digest: changeDigest(path, previousPath, status, hunks, blobs), }] }) } +/** Digest of one change: its paths, status, hunks, and the identity of content that hunks do not show. */ +function changeDigest( + path: string, previousPath: string | null, status: ForgeDiffStatus, hunks: readonly ForgeDiffHunk[], content: string, +): string { + return createHash('sha256').update(JSON.stringify([path, previousPath, status, hunks, content])).digest('hex').slice(0, 32) +} + /** * Describe an untracked file as added in full. * @param path - path relative to the repository root. * @param content - the file's bytes. * @returns the file with one hunk of added lines, none when it is empty, or none and `binary` when its content is not text. */ -export function untrackedFile(path: string, content: Uint8Array): ForgeDiffFile { - const file: ForgeDiffFile = { - path, previousPath: null, status: 'added', binary: false, oversized: false, untracked: true, additions: 0, deletions: 0, hunks: [], - } +export function untrackedFile(path: string, content: Uint8Array): ParsedDiffFile { const text = decodeText(content) - if (text === undefined) return { ...file, binary: true } - if (text.length === 0) return file + if (text === undefined) return addedFile(path, [], { binary: true, oversized: false, content: createHash('sha256').update(content).digest('hex') }) + if (text.length === 0) return addedFile(path, [], { binary: false, oversized: false, content: '' }) const lines = text.split('\n') const ended = lines.at(-1) === '' if (ended) lines.pop() + return addedFile(path, [{ + oldStart: 1, + oldLines: 0, + newStart: 1, + newLines: lines.length, + lines: [...lines.map(line => `+${line}`), ...ended ? [] : ['\\ No newline at end of file']], + }], { binary: false, oversized: false, content: '' }) +} + +/** + * Describe an untracked file over `maxDiffBytes`, which carries no hunks and whose content is not read. + * @param path - path relative to the repository root. + * @returns the oversized added file. + */ +export function oversizedUntrackedFile(path: string): ParsedDiffFile { + return addedFile(path, [], { binary: false, oversized: true, content: 'oversized' }) +} + +/** An untracked file as added, whose digest also covers `content`, the identity of content its hunks do not show. */ +function addedFile(path: string, hunks: readonly ForgeDiffHunk[], { binary, oversized, content }: { + readonly binary: boolean + readonly oversized: boolean + readonly content: string +}): ParsedDiffFile { return { - ...file, - additions: lines.length, - hunks: [{ - oldStart: 1, - oldLines: 0, - newStart: 1, - newLines: lines.length, - lines: [...lines.map(line => `+${line}`), ...ended ? [] : ['\\ No newline at end of file']], - }], + path, previousPath: null, status: 'added', binary, oversized, untracked: true, + additions: hunks[0]?.newLines ?? 0, deletions: 0, hunks, digest: changeDigest(path, null, 'added', hunks, content), } } diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 6800a70b34..c1679818d2 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -5,7 +5,10 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-api-remote-access' import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' -import type { ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeReviewRequest, ForgeReviewResult, MergeMethod } from '@deepseek-ai/dsh-experimental-forge' +import type { + ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewRequest, ForgeReviewResult, + MergeMethod, +} from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' import type { WorktreeCleanup } from '@deepseek-ai/dsh-experimental-worktree/types' import { appendPluginRecord } from '@deepseek-ai/dsh-session' @@ -19,15 +22,17 @@ import { gitIn, inspectCheckout, listBranches } from './git.ts' import type { Inspection } from './git.ts' import { parseDiff } from './diff.ts' import { - aheadOf, BaseMissing, baseBranches, chosenForkPoint, DiffTooLarge, FilterConfigError, forkPoint, localDiff, localText, NoForkPoint, + aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, DiffTooLarge, FilterConfigError, forkPoint, localCommits, localDiff, + localText, NoForkPoint, } from './local-diff.ts' +import { ATTRIBUTES_PATH, FileLayers } from './layers.ts' import { missingWorktree, SessionPanel } from './panel.ts' import { PatchCache } from './patch-cache.ts' import { ChangeRequestStates, changeRequestStatesDomain } from './states.ts' import type { PanelConfig } from './panel.ts' import type { - Config, ForgeBaseBranches, ForgeBranches, ForgeChangeRequestDiff, ForgeChangeRequestStatesFrame, ForgeLocalDiff, ForgeLocalScope, - ForgeMissingWorktree, ForgePanelState, ForgeSessionWorktree, ForgeWorktreeState, + Config, ForgeBaseBranches, ForgeBranches, ForgeChangeRequestDiff, ForgeChangeRequestStatesFrame, ForgeCommitRange, ForgeLocalCommits, + ForgeLocalDiff, ForgeLocalScope, ForgeMissingWorktree, ForgePanelState, ForgeSessionWorktree, ForgeWorktreeState, } from './types.ts' import { forgeWorktreeProjection, messageOf, within, WORKTREE_RECORD, WorktreeCreations } from './worktree.ts' import type { HeldWork } from './worktree.ts' @@ -41,8 +46,28 @@ declare module '@deepseek-ai/cordis' { } } -/** Panel limits plus the branch listing limit and the worktree branch prefix. */ -type ControllerConfig = PanelConfig & { readonly maxBranches: number; readonly worktreePrefix: string } +/** Panel limits plus the branch and commit listing limits and the worktree branch prefix. */ +type ControllerConfig = PanelConfig & { readonly maxBranches: number; readonly maxCommits: number; readonly worktreePrefix: string } + +/** + * Tests, test data, snapshots, locale dictionaries, and lockfiles, after the review plugin's collapsed and + * non-interface paths; `locales.ts` is the typed dictionary module of a DSH Client plugin. + */ +const TEST_PATHS = [ + '**/test/**', '**/tests/**', '**/__tests__/**', '**/__mocks__/**', '**/__fixtures__/**', '**/e2e/**', + '**/*.spec.*', '**/*.test.*', '**/*.e2e.*', + '**/__snapshots__/**', 'snapshots/**', '**/*.snap', + '**/locale/*.json', '**/locales/**', '**/locales.ts', + '**/pnpm-lock.yaml', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/yarn.lock', +] as const + +/** Package manifests, type declarations, and configuration by extension, name, or directory, after the review plugin's scanner. */ +const INTERFACE_PATHS = [ + '**/package.json', '**/*.d.ts', '**/*.d.mts', '**/*.d.cts', '**/types.ts', + '**/*.{json,jsonc,yaml,yml,toml,ini,conf,cfg,properties,xml,plist}', + '**/.env', '**/.env.*', '**/.*rc', '**/.*rc.*', '**/*{config,settings,preferences,options}*', + '**/{config,configs,settings,profiles}/**', +] as const /** * Serves the forge panel. Following a Session inspects the checkout of its @@ -75,6 +100,9 @@ export class ForgePanelController extends TypertRemoteService { maxDiffBytes: z.number().default(8_388_608), maxUntrackedFiles: z.number().default(100), maxCachedDiffBytes: z.number().default(16_777_216), + maxCommits: z.number().default(250), + testPaths: z.array(z.string()).default([...TEST_PATHS]), + interfacePaths: z.array(z.string()).default([...INTERFACE_PATHS]), }) private readonly home: Context @@ -83,6 +111,7 @@ export class ForgePanelController extends TypertRemoteService { private worktreeStates!: ProjectionHandle private creations!: WorktreeCreations private readonly patches: PatchCache + private readonly layers: FileLayers private states: ChangeRequestStates | undefined private readonly stopPanels: () => Promise private readonly stopStates: () => Promise @@ -97,10 +126,14 @@ export class ForgePanelController extends TypertRemoteService { throw new Error(`forge-panel: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) } } - for (const key of ['maxBranches', 'maxDiffBytes', 'maxUntrackedFiles', 'maxCachedDiffBytes'] as const) { + for (const key of ['maxBranches', 'maxDiffBytes', 'maxUntrackedFiles', 'maxCachedDiffBytes', 'maxCommits'] as const) { if (!Number.isSafeInteger(resolved[key]) || resolved[key] <= 0) throw new Error(`forge-panel: ${key} must be a positive integer`) } if (!branchPrefix(resolved.worktreePrefix)) throw new Error('forge-panel: worktreePrefix must start a valid Git branch name') + for (const key of ['testPaths', 'interfacePaths'] as const) { + if (resolved[key].some(glob => glob.trim() === '')) throw new Error(`forge-panel: ${key} entries must not be empty`) + } + this.layers = new FileLayers(resolved.testPaths, resolved.interfacePaths) this.home = ctx this.config = resolved this.patches = new PatchCache(resolved.maxCachedDiffBytes) @@ -271,7 +304,8 @@ export class ForgePanelController extends TypertRemoteService { * @returns the files, the base of the `branch` scope, and the untracked files left out. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch * chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without - * a fork point; `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before + * a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; + * `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before * any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @Remote @@ -283,19 +317,42 @@ export class ForgePanelController extends TypertRemoteService { const created = this.worktreeState(session).created const fork = scope.kind !== 'branch' ? null : await diffing(scope.base === null ? forkPoint(git, inspection, created) : chosenForkPoint(git, scope.base)) - return await diffing(localDiff(this.home, this.config, session, root, fork, scope, signal)) + return await diffing(localDiff(this.home, this.config, session, root, fork, scope, this.layers, signal)) + } + + /** + * List the commits of the `branch` scope of the Session's current checkout, which the changes view can show one or + * several of instead of the whole scope. + * @param session - Session whose checkout is read. + * @param base - the base ref of the `branch` scope, or null for its automatic fork point. + * @param signal - request cancellation. + * @returns at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not + * exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`. + */ + @Remote + async localCommits(session: Session, base: string | null, signal: AbortSignal): Promise { + signal.throwIfAborted() + const inspection = await this.checkoutOf(session, signal) + const git = gitIn(this.home, { ...this.config, maxOutputBytes: this.config.maxDiffBytes }, session, inspection.checkout.root, signal) + const created = this.worktreeState(session).created + const fork = await diffing(base === null ? forkPoint(git, inspection, created) : chosenForkPoint(git, base)) + if (fork === null) throw new RemoteError('forgePanel/no-fork-point', 'The checkout has no fork point to compare its branch with', {}) + return await localCommits(git, fork, this.config.maxCommits) } /** * Read the new side of one changed file in a scope, which supplies the - * unchanged lines between its hunks: the index for `staged`, else the working tree. + * unchanged lines between its hunks: the index for `staged`, the last chosen commit for chosen commits of + * the `branch` scope, else the working tree. * @param session - Session whose checkout is read. * @param scope - the scope the diff was read in. * @param path - path relative to the checkout's top-level directory, as the diff names it. * @param signal - request cancellation. * @returns the text. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`; - * an error for a path outside the checkout, binary content, or a failed read. + * `forgePanel/commits-missing` for a chosen commit that is not a full object name; an error for a path outside + * the checkout, binary content, or a failed read. */ @Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise { @@ -325,36 +382,63 @@ export class ForgePanelController extends TypertRemoteService { } /** - * Read the changes of a change request of a Session as its code host - * computes them, at the head commit of its latest status. A patch already - * read at the same head commit and base branch is reused from memory, for - * any Session, until `maxCachedDiffBytes` evicts it. + * List the commits of a change request of a Session at its current head, which the changes view can show one or + * several of instead of the whole change request. * @param session - Session the change request is bound to. * @param url - binding URL of the change request. * @param signal - request cancellation. - * @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head. + * @returns the commits oldest first with the head they were read at, or null when the provider does not read commits. * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; - * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; - * `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise. + * `forgePanel/diff-failed` when the host does not list them. */ @Remote - async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise { + async changeRequestCommits(session: Session, url: string, signal: AbortSignal): Promise { + signal.throwIfAborted() + const list = await this.home.forge.commits(this.bound(session, url).ref, signal) + if (list !== undefined && 'code' in list) throw readError(list) + return list ?? null + } + + /** + * Read the changes of a change request of a Session as its code host + * computes them, with each file's layer following the root `.gitattributes` + * of the commit the diff ends at. Without `commits` the diff covers the change + * request at the head commit of its latest status; a patch already read at the + * same head commit and base branch is reused from memory with its attributes, for + * any Session, until `maxCachedDiffBytes` evicts it. With `commits`, it covers those + * commits, from the first one's first parent to the last, and is read every time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param commits - consecutive commits of the change request, or null for all of it. + * @param signal - request cancellation. + * @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order; + * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; + * `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host + * does not provide the commits, the diff, or an existing `.gitattributes` otherwise. + */ + @Remote + async changeRequestDiff( + session: Session, url: string, commits: ForgeCommitRange | null, signal: AbortSignal, + ): Promise { signal.throwIfAborted() const { ref, update } = this.bound(session, url) if (update === null || !('snapshot' in update)) { throw new RemoteError('forgePanel/diff-failed', 'The change request status has not been read yet', {}) } + if (commits !== null) return await this.commitsDiff(ref, commits, signal) const { headSha, headBranch, baseBranch } = update.snapshot const key = changeRequestKey(ref) const version = JSON.stringify([headSha, baseBranch]) - let patch = this.patches.get(key, version) - if (patch === undefined) { - const read = await this.home.forge.diff(ref, signal) - if (typeof read !== 'string') throw readError(read) + let read = this.patches.get(key, version) + if (read === undefined) { + const patch = await this.home.forge.diff(ref, signal) + if (typeof patch !== 'string') throw readError(patch) + read = { patch, attributes: await this.attributesAt(ref, headSha, signal) } this.patches.set(key, version, read) - patch = read } - const files = parseDiff(patch) + const files = this.layers.classify(parseDiff(read.patch), read.attributes) let localAhead: number | null = null try { localAhead = await aheadOf( @@ -604,6 +688,33 @@ export class ForgePanelController extends TypertRemoteService { return this.home.workingDirectory.get(session) === directory ? missing : null } + /** The changes of consecutive commits of a change request, from the first one's first parent to the last. */ + private async commitsDiff(ref: ChangeRequestRef, commits: ForgeCommitRange, signal: AbortSignal): Promise { + const list = await this.home.forge.commits(ref, signal) + if (list === undefined) throw new RemoteError('forgePanel/diff-failed', 'The code host provider does not read commits', {}) + if ('code' in list) throw readError(list) + const first = list.commits.findIndex(commit => commit.sha === commits.from) + const last = list.commits.findIndex(commit => commit.sha === commits.to) + if (first === -1 || last < first) { + throw new RemoteError('forgePanel/commits-missing', 'The chosen commits are no longer in the change request', {}) + } + const parent = list.commits[first]?.parent ?? null + if (parent === null) throw new RemoteError('forgePanel/diff-failed', 'The first chosen commit has no parent to compare with', {}) + const patch = await this.home.forge.compare(ref, parent, commits.to, signal) + if (typeof patch !== 'string') throw readError(patch) + const files = this.layers.classify(parseDiff(patch), await this.attributesAt(ref, commits.to, signal)) + const fileUrls = Object.fromEntries(files.map(file => [file.path, this.home.forge.fileUrl(ref, commits.to, file.path)])) + return { headSha: commits.to, files, localAhead: null, fileUrls } + } + + /** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */ + private async attributesAt(ref: ChangeRequestRef, commit: string, signal: AbortSignal): Promise { + const attributes = await this.home.forge.fileText(ref, commit, ATTRIBUTES_PATH, signal) + if (typeof attributes === 'string') return attributes + if (attributes.code === 'not-found') return '' + throw readError(attributes) + } + /** Inspect the checkout of the Session's current directory, which local diffs read. */ private async checkoutOf(session: Session, signal: AbortSignal): Promise { const inspection = await inspectCheckout(this.home, this.config, session, this.home.workingDirectory.get(session), signal) @@ -726,7 +837,8 @@ function readError(failure: ForgeFailure): RemoteError { } /** - * Settle a diff read, reporting its size limit, its filter configuration, a missing fork point, and a missing base branch as Remote errors. + * Settle a diff read, reporting its size limit, its filter configuration, a missing fork point, a missing base branch, and chosen + * commits that are not on the branch as Remote errors. * @param reading - the read. * @returns what the read returned. */ @@ -738,6 +850,7 @@ async function diffing(reading: Promise): Promise { if (error instanceof DiffTooLarge) throw new RemoteError('forgePanel/diff-too-large', error.message, {}, { cause: error }) if (error instanceof NoForkPoint) throw new RemoteError('forgePanel/no-fork-point', error.message, {}, { cause: error }) if (error instanceof BaseMissing) throw new RemoteError('forgePanel/base-missing', error.message, {}, { cause: error }) + if (error instanceof CommitsMissing) throw new RemoteError('forgePanel/commits-missing', error.message, {}, { cause: error }) throw error } } diff --git a/packages/experimental/client-ui-forge/src/layers.ts b/packages/experimental/client-ui-forge/src/layers.ts new file mode 100644 index 0000000000..827bbf4269 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/layers.ts @@ -0,0 +1,87 @@ +/** + * Review layers of changed files: the `testPaths` and `interfacePaths` globs of the panel's configuration, and the + * `linguist-generated` attribute of a repository's root `.gitattributes`, which also places a file in the `test` layer. + */ + +import picomatch from 'picomatch' +import type { ParsedDiffFile } from './diff.ts' +import type { ForgeDiffFile, ForgeFileLayer } from './types.ts' + +/** Path of the attributes file read beside each diff, relative to the repository root. */ +export const ATTRIBUTES_PATH = '.gitattributes' + +/** The attribute GitHub Linguist reads to treat a file as generated. */ +const GENERATED = 'linguist-generated' + +type Matcher = (path: string) => boolean + +/** + * Compile globs into one matcher; `*` matches dot files and never crosses `/`. + * @param globs - picomatch globs. + * @returns whether a repository-relative path matches any glob. + */ +function matcherOf(globs: readonly string[]): Matcher { + return globs.length === 0 ? () => false : picomatch([...globs], { dot: true }) +} + +/** + * Read which paths a `.gitattributes` text marks `linguist-generated`. Lines apply in order, so a later line overrides + * an earlier one; `linguist-generated` and `=true` set the attribute, `-linguist-generated` and `=false` unset it, and + * `!linguist-generated` returns it to unspecified. A pattern without `/` matches a file name at any depth, and one with + * `/` matches from the repository root. Macro definitions, quoted patterns, and patterns ending in `/`, which name + * directories and so never match a file, are skipped. + * @param attributes - the text of the root `.gitattributes`, empty without one. + * @returns whether a repository-relative path is generated. + */ +export function generatedMatcher(attributes: string): Matcher { + const rules: { readonly match: Matcher; readonly generated: boolean | undefined }[] = [] + for (const raw of attributes.split(/\r?\n/)) { + const [pattern, ...fields] = raw.trim().split(/\s+/) + if (pattern === undefined || pattern === '' || pattern.startsWith('#') || pattern.startsWith('[attr]') + || pattern.startsWith('"') || pattern.endsWith('/')) continue + let generated: boolean | null | undefined = null + for (const field of fields) { + if (field === GENERATED || field === `${GENERATED}=true`) generated = true + else if (field === `-${GENERATED}` || field === `${GENERATED}=false`) generated = false + else if (field === `!${GENERATED}`) generated = undefined + } + if (generated === null) continue + const anchored = pattern.replace(/^\//, '') + rules.push({ match: picomatch(anchored, { dot: true, basename: !pattern.includes('/') }), generated }) + } + return (path) => { + let generated: boolean | undefined + for (const rule of rules) if (rule.match(path)) generated = rule.generated + return generated === true + } +} + +/** Classifies changed files into review layers by the panel's path globs and a repository's attributes. */ +export class FileLayers { + private readonly test: Matcher + private readonly interface: Matcher + + /** + * @param testPaths - globs of the `test` layer. + * @param interfacePaths - globs of the `interface` layer. + */ + constructor(testPaths: readonly string[], interfacePaths: readonly string[]) { + this.test = matcherOf(testPaths) + this.interface = matcherOf(interfacePaths) + } + + /** + * Place each file in its layer: `test` first, then `interface`, else `implementation`. + * @param files - parsed files. + * @param attributes - the text of the repository's root `.gitattributes`, empty without one. + * @returns the files in the same order with their layers. + */ + classify(files: readonly ParsedDiffFile[], attributes: string): ForgeDiffFile[] { + const generated = generatedMatcher(attributes) + return files.map((file) => { + const layer: ForgeFileLayer = this.test(file.path) || generated(file.path) ? 'test' + : this.interface(file.path) ? 'interface' : 'implementation' + return { ...file, layer } + }) + } +} diff --git a/packages/experimental/client-ui-forge/src/local-diff.ts b/packages/experimental/client-ui-forge/src/local-diff.ts index 34c6ac6114..c17c443d13 100644 --- a/packages/experimental/client-ui-forge/src/local-diff.ts +++ b/packages/experimental/client-ui-forge/src/local-diff.ts @@ -10,13 +10,17 @@ import type { Context } from '@deepseek-ai/cordis' import { FsError } from '@deepseek-ai/dsh-fs' import type { Session } from '@deepseek-ai/dsh-session' import { assertNever } from '@deepseek-ai/dsh-util-values' -import { decodeText, parseDiff, parseNumstat, untrackedFile } from './diff.ts' +import { decodeText, oversizedUntrackedFile, parseDiff, parseNumstat, untrackedFile } from './diff.ts' +import type { ParsedDiffFile } from './diff.ts' import { gitIn, GitOutputTooLarge } from './git.ts' import type { Git, GitConfig, GitOutput, Inspection } from './git.ts' +import { ATTRIBUTES_PATH } from './layers.ts' +import type { FileLayers } from './layers.ts' import { BASE_REF_PREFIXES, parseBaseBranches, parseName, remoteOrder } from './parse.ts' import type { - ForgeBaseBranches, ForgeDiffFile, ForgeLocalChanges, ForgeLocalDiff, ForgeLocalScope, ForgeSessionWorktree, + ForgeCommitRange, ForgeLocalChanges, ForgeLocalCommits, ForgeLocalDiff, ForgeLocalScope, ForgeBaseBranches, ForgeSessionWorktree, } from './types.ts' +import type { ForgeCommit } from '@deepseek-ai/dsh-experimental-forge/types' /** Git limits plus the size limits of local diffs. */ export interface DiffConfig extends GitConfig { @@ -56,6 +60,17 @@ export class BaseMissing extends Error { override name = 'BaseMissing' } +/** Thrown when chosen commits are not among the commits of the `branch` scope, or are out of order. */ +export class CommitsMissing extends Error { + override name = 'CommitsMissing' +} + +/** A full SHA-1 or SHA-256 object name, the only form of a chosen commit Git receives. */ +const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ + +/** One commit per record: object name, first parent, author, author date, and subject, separated by NUL. */ +const LOG_FORMAT = '--format=%H%x00%P%x00%an%x00%aI%x00%s%x1e' + /** No executable diff helpers or submodule working-tree inspection; preserve recorded submodule commit changes. */ const DIFF = ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--submodule=short', '--ignore-submodules=dirty', '--no-color', '-M', '--src-prefix=a/', '--dst-prefix=b/'] as const @@ -155,16 +170,20 @@ export async function baseBranches( /** * Read changes at `root` with command-local clean/process filter overrides and without * submodule working-tree inspection. Git's built-in newline, ident, and encoding conversions still apply. + * Each file's layer follows `layers` and the root `.gitattributes` of the new side: the index for `staged`, + * else the working tree. * @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`. * @param config - Git and size limits. * @param session - Session whose sandbox policy and execution world apply. * @param root - top-level directory of the checkout. * @param fork - fork point of the `branch` scope, or null without one. * @param scope - which changes to read. + * @param layers - classifier of each file's review layer. * @param signal - cancellation of every command and read. * @returns tracked files in Git's order, then untracked files unless the scope is `staged`. * @throws NoForkPoint for the `branch` scope without `fork`; FilterConfigError when filter configuration - * cannot be read within `maxDiffBytes`; DiffTooLarge for oversized comparison output; an Error when Git fails. + * cannot be read within `maxDiffBytes`; DiffTooLarge for oversized comparison output or `.gitattributes`; + * an Error when Git or the `.gitattributes` read fails. */ export async function localDiff( ctx: Context, @@ -173,6 +192,7 @@ export async function localDiff( root: string, fork: ForkPoint | null, scope: ForgeLocalScope, + layers: FileLayers, signal: AbortSignal, ): Promise { const git = gitIn(ctx, { ...config, maxOutputBytes: config.maxDiffBytes }, session, root, signal) @@ -180,6 +200,7 @@ export async function localDiff( switch (scope.kind) { case 'branch': if (fork === null) throw new NoForkPoint('The checkout has no fork point to compare its branch with') + if (scope.commits !== null) return await commitsDiff(git, fork, scope.commits, layers) range = [fork.commit] break case 'uncommitted': range = [await headOrEmptyTree(git)]; break @@ -190,14 +211,28 @@ export async function localDiff( } const overrides = await diffFilterOverrides(git) const tracked = parseDiff((await bounded(git('diff', [...DIFF, ...range, '--'], undefined, overrides))).stdout) - if (scope.kind === 'staged') return { base: null, files: tracked, omitted: 0 } + if (scope.kind === 'staged') { + const shown = await bounded(git('attributes', ['show', `:${ATTRIBUTES_PATH}`], [0, 128])) + return { base: null, files: layers.classify(tracked, shown.exitCode === 0 ? shown.stdout : ''), omitted: 0 } + } const untracked = await untrackedFiles(ctx, config, git, root, signal) - return { base: fork === null || scope.kind !== 'branch' ? null : fork.label, files: [...tracked, ...untracked.files], omitted: untracked.omitted } + let attributes = '' + try { + attributes = decodeText(await readFile(ctx, config, root, ATTRIBUTES_PATH, signal)) ?? '' + } catch (error) { + if (!(error instanceof FsError && error.code === 'FS_NOT_FOUND')) throw error + } + return { + base: fork === null || scope.kind !== 'branch' ? null : fork.label, + files: layers.classify([...tracked, ...untracked.files], attributes), + omitted: untracked.omitted, + } } /** * Read the new side of one file in a scope, which supplies the unchanged - * lines between hunks: the index for `staged`, else the working tree. + * lines between hunks: the index for `staged`, the last chosen commit for + * chosen commits of the `branch` scope, else the working tree. * @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`. * @param config - Git and size limits. * @param session - Session whose sandbox policy and execution world apply. @@ -219,9 +254,11 @@ export async function localText( ): Promise { if (!insideCheckout(path)) throw new Error(`forge-panel: ${JSON.stringify(path)} is not a path inside the checkout`) let text: string | undefined - if (scope.kind === 'staged') { + const commit = scope.kind === 'staged' ? '' : scope.kind === 'branch' && scope.commits !== null ? scope.commits.to : undefined + if (commit !== undefined) { + if (commit !== '' && !COMMIT.test(commit)) throw new CommitsMissing('The chosen commit is not a full object name') const git = gitIn(ctx, { ...config, maxOutputBytes: config.maxDiffBytes }, session, root, signal) - const shown = (await bounded(git('show', ['show', `:${path}`]))).stdout + const shown = (await bounded(git('show', ['show', `${commit}:${path}`]))).stdout text = shown.slice(0, BINARY_PROBE).includes('\0') ? undefined : shown } else { text = decodeText(await readFile(ctx, config, root, path, signal)) @@ -283,10 +320,57 @@ export async function aheadOf(git: Git, branch: string, commit: string): Promise return Number((await git('rev-list', ['rev-list', '--count', `${commit}..HEAD`])).stdout.trim()) } +/** + * List the commits of the `branch` scope: those reachable from HEAD but not from the fork point, parents before children. + * @param git - runner bound to the checkout, whose output limit bounds the listing. + * @param fork - fork point of the `branch` scope. + * @param limit - most commits listed. + * @returns the oldest `limit` commits and HEAD's commit. + * @throws an Error when Git fails or prints more than the runner's output limit. + */ +export async function localCommits(git: Git, fork: ForkPoint, limit: number): Promise { + const head = (await git('head', ['rev-parse', '--verify', 'HEAD'])).stdout.trim() + const all = await branchCommits(git, fork) + return { headSha: head, commits: all.slice(0, limit), truncated: all.length > limit } +} + +/** Every commit between the fork point and HEAD, parents before children. */ +async function branchCommits(git: Git, fork: ForkPoint): Promise { + const output = await git('log', ['log', '--topo-order', '--reverse', LOG_FORMAT, `${fork.commit}..HEAD`, '--']) + return output.stdout.split('\x1e').flatMap((record): ForgeCommit[] => { + const fields = record.replace(/^\n/, '').split('\0') + // The text after the last record holds no fields. + if (fields.length !== 5) return [] + const [sha, parents, author, authoredAt, title] = fields as [string, string, string, string, string] + return [{ sha, parent: /^\S+/.exec(parents)?.[0] ?? null, title, author, authoredAt }] + }) +} + +/** The committed changes of consecutive commits of the `branch` scope, from the first commit's first parent to the last. */ +async function commitsDiff(git: Git, fork: ForkPoint, chosen: ForgeCommitRange, layers: FileLayers): Promise { + if (!COMMIT.test(chosen.from) || !COMMIT.test(chosen.to)) throw new CommitsMissing('The chosen commits are not full object names') + const commits = await branchCommits(git, fork) + const first = commits.findIndex(commit => commit.sha === chosen.from) + const start = commits[first] + if (start === undefined || commits.findIndex(commit => commit.sha === chosen.to) < first) { + throw new CommitsMissing('The chosen commits are no longer on the branch') + } + // A first commit without a parent starts an unrelated history that HEAD merged; it adds every file. + const parent = start.parent ?? await emptyTree(git) + const overrides = await diffFilterOverrides(git) + const files = parseDiff((await bounded(git('diff', [...DIFF, parent, chosen.to, '--'], undefined, overrides))).stdout) + const shown = await bounded(git('attributes', ['show', `${chosen.to}:${ATTRIBUTES_PATH}`], [0, 128])) + return { base: fork.label, files: layers.classify(files, shown.exitCode === 0 ? shown.stdout : ''), omitted: 0 } +} + /** HEAD, or the empty tree while the checked-out branch has no commit. */ async function headOrEmptyTree(git: Git): Promise { const head = await git('head', ['rev-parse', '--verify', '--quiet', 'HEAD'], [0, 1]) - if (head.exitCode === 0) return 'HEAD' + return head.exitCode === 0 ? 'HEAD' : await emptyTree(git) +} + +/** The object name of the empty tree. */ +async function emptyTree(git: Git): Promise { return (await git('empty-tree', ['hash-object', '-t', 'tree', '--stdin'])).stdout.trim() } @@ -297,17 +381,17 @@ async function untrackedFiles( git: Git, root: string, signal: AbortSignal, -): Promise<{ readonly files: ForgeDiffFile[]; readonly omitted: number }> { +): Promise<{ readonly files: ParsedDiffFile[]; readonly omitted: number }> { const listed = await bounded(git('untracked', ['ls-files', '--others', '--exclude-standard', '-z'])) const paths = listed.stdout.split('\0').filter(path => path.length > 0 && !path.endsWith('/')) - const files: ForgeDiffFile[] = [] + const files: ParsedDiffFile[] = [] for (const path of paths.slice(0, config.maxUntrackedFiles)) { let content: Uint8Array try { content = await readFile(ctx, config, root, path, signal) } catch (error) { if (error instanceof DiffTooLarge) { - files.push({ ...untrackedFile(path, new Uint8Array()), oversized: true }) + files.push(oversizedUntrackedFile(path)) continue } if (error instanceof FsError) continue diff --git a/packages/experimental/client-ui-forge/src/patch-cache.ts b/packages/experimental/client-ui-forge/src/patch-cache.ts index 9ccc8d37e9..f331c08c19 100644 --- a/packages/experimental/client-ui-forge/src/patch-cache.ts +++ b/packages/experimental/client-ui-forge/src/patch-cache.ts @@ -1,22 +1,28 @@ /** - * Change request patches kept in memory, so the changes view reads no diff - * from the code host again while a change request keeps its head commit and base branch. + * Change request patches kept in memory with the repository attributes read beside them, so the changes view reads + * neither from the code host again while a change request keeps its head commit and base branch. */ import { Buffer } from 'node:buffer' import type { ChangeRequestKey } from '@deepseek-ai/dsh-experimental-forge' +/** One change request's patch and the root `.gitattributes` text at its head commit, empty without one. */ +export interface CachedPatch { + readonly patch: string + readonly attributes: string +} + interface Entry { /** Head commit and base branch the patch was read at. */ readonly version: string - readonly patch: string - /** UTF-8 size of `patch`. */ + readonly value: CachedPatch + /** UTF-8 size of the patch and the attributes. */ readonly bytes: number } /** * One patch per change request, bounded by the total UTF-8 size of the kept - * patches; the least recently read patch leaves first. + * patches and their attributes; the least recently read patch leaves first. */ export class PatchCache { private readonly entries = new Map() @@ -29,14 +35,14 @@ export class PatchCache { * Read the patch kept for a change request at a version, marking it the most recently read. * @param key - the change request. * @param version - head commit and base branch the reader needs. - * @returns the patch, or undefined when none is kept at that version. + * @returns the patch and its attributes, or undefined when none is kept at that version. */ - get(key: ChangeRequestKey, version: string): string | undefined { + get(key: ChangeRequestKey, version: string): CachedPatch | undefined { const entry = this.entries.get(key) if (entry?.version !== version) return undefined this.entries.delete(key) this.entries.set(key, entry) - return entry.patch + return entry.value } /** @@ -44,13 +50,13 @@ export class PatchCache { * least recently read patches until the rest fit within `maxBytes`. * @param key - the change request. * @param version - head commit and base branch the patch was read at. - * @param patch - the patch. + * @param value - the patch and its attributes. */ - set(key: ChangeRequestKey, version: string, patch: string): void { + set(key: ChangeRequestKey, version: string, value: CachedPatch): void { this.remove(key) - const bytes = Buffer.byteLength(patch) + const bytes = Buffer.byteLength(value.patch) + Buffer.byteLength(value.attributes) if (bytes > this.maxBytes) return - this.entries.set(key, { version, patch, bytes }) + this.entries.set(key, { version, value, bytes }) this.bytes += bytes for (const oldest of this.entries.keys()) { if (this.bytes <= this.maxBytes) break diff --git a/packages/experimental/client-ui-forge/src/types.ts b/packages/experimental/client-ui-forge/src/types.ts index 2a11e67ac6..64cebb0aa4 100644 --- a/packages/experimental/client-ui-forge/src/types.ts +++ b/packages/experimental/client-ui-forge/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe panel state, the worktree request record, Remote error codes, and Host configuration of the forge panel. */ import type {} from '@deepseek-ai/dsh-typert-protocol' -import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ChangeRequestSnapshot, ForgeCommitList, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge/types' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session/types' /** Git facts for the checkout that contains the Session's current directory. */ @@ -242,14 +242,26 @@ export interface ForgeBaseBranches { readonly local: readonly ForgeBaseBranch[] } +/** + * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order + * the commit list shows them, oldest first; the view shows the changes from the first commit's first parent to the last. + */ +export interface ForgeCommitRange { + /** First, oldest commit shown. */ + readonly from: string + /** Last, newest commit shown; equal to `from` for one commit. */ + readonly to: string +} + /** Which local changes a diff covers; each reads the Session's checkout through Git. */ export type ForgeLocalScope = /** * The working tree and untracked files against a fork point: the merge base of HEAD with the * `base` ref, or while `base` is null, the base commit of the worktree the Session created, - * else the merge base of HEAD with the default branch of the code host remote. + * else the merge base of HEAD with the default branch of the code host remote. With `commits`, only the changes + * of those commits between the fork point and HEAD, without uncommitted or untracked files. */ - | { readonly kind: 'branch'; readonly base: string | null } + | { readonly kind: 'branch'; readonly base: string | null; readonly commits: ForgeCommitRange | null } /** The working tree and untracked files against HEAD. */ | { readonly kind: 'uncommitted' } /** The working tree and untracked files against the index. */ @@ -274,6 +286,12 @@ export interface ForgeDiffHunk { /** How one file changed. */ export type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed' +/** + * Review layer of a changed file by its path: `test` for a path matching `testPaths` or one the repository's root + * `.gitattributes` marks `linguist-generated`, else `interface` for a path matching `interfacePaths`, else `implementation`. + */ +export type ForgeFileLayer = 'interface' | 'implementation' | 'test' + /** One changed file of a diff. */ export interface ForgeDiffFile { /** Path relative to the repository root after the change, or before it for a deleted file. */ @@ -294,6 +312,13 @@ export interface ForgeDiffFile { readonly deletions: number /** Hunks in file order; none for a binary file or a change of mode or name only. */ readonly hunks: readonly ForgeDiffHunk[] + /** Review layer the path falls in. */ + readonly layer: ForgeFileLayer + /** + * Digest of the file's change: its paths, status, and hunks, plus the blob names of a binary change, or the content + * of an untracked binary file. Equal digests describe the same change; reading the same comparison again keeps it. + */ + readonly digest: string } /** Local changes of the Session's checkout in one scope. */ @@ -306,21 +331,24 @@ export interface ForgeLocalDiff { readonly omitted: number } -/** Changes of a change request as its code host computes them. */ +/** Changes of a change request, or of consecutive commits of it, as its code host computes them. */ export interface ForgeChangeRequestDiff { - /** Head commit the diff describes. */ + /** Commit whose new side the diff describes: the head, or the last chosen commit. */ readonly headSha: string /** Changed files in the host's order. */ readonly files: readonly ForgeDiffFile[] /** * Commits the checkout's branch has beyond the head when that branch is the change request's head - * branch and the head commit is present locally; null otherwise. + * branch and the head commit is present locally; null otherwise and for chosen commits. */ readonly localAhead: number | null - /** Web page of each changed file at the head commit, by path. */ + /** Web page of each changed file at `headSha`, by path. */ readonly fileUrls: Readonly> } +/** Commits of the `branch` scope, which the changes view can show one or several of. */ +export type ForgeLocalCommits = ForgeCommitList + /** One file the `branch` scope changes, without its hunks. */ export interface ForgeChangedFile { /** Path relative to the repository root after the change. */ @@ -366,6 +394,15 @@ export interface Config { maxUntrackedFiles?: number /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ maxCachedDiffBytes?: number + /** Most commits of the `branch` scope listed, oldest first. */ + maxCommits?: number + /** + * Globs of tests, snapshots, locale dictionaries, and lockfiles, matched against repository-relative paths with + * dot files included; their files form the `test` layer, which the changes view can hide. + */ + testPaths?: string[] + /** Globs of package manifests, configuration, and type declarations, matched like `testPaths`; their files form the `interface` layer. */ + interfacePaths?: string[] } declare module '@deepseek-ai/dsh-typert-protocol' { @@ -412,6 +449,8 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'forgePanel/checks-failed': Record /** The code host did not provide the change request's diff or file, or its status has not been read yet; the message says why. */ 'forgePanel/diff-failed': Record + /** A chosen commit is no longer among the listed commits of the branch or change request, or the commits are out of order. */ + 'forgePanel/commits-missing': Record } } diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index 89f380292c..feb7660187 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -501,7 +501,7 @@ describe('forgePanel local changes', () => { await writeFile(join(repo, 'notes.md'), 'hello\n') const opened = await open(repo) - const diff = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: null }, signal) + const diff = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: null, commits: null }, signal) expect(diff.base).toBe('origin/main') expect(diff.files.map(file => [file.path, file.status, file.untracked])).toEqual([['tracked.txt', 'modified', false], ['notes.md', 'added', true]]) await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'staged' }, signal)).resolves.toEqual({ base: null, files: [], omitted: 0 }) @@ -519,22 +519,57 @@ describe('forgePanel local changes', () => { const opened = await open(repo) const paths = (diff: ForgeLocalDiff): string[] => diff.files.map(file => file.path) - const automatic = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: null }, signal) + const automatic = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: null, commits: null }, signal) expect([automatic.base, paths(automatic)]).toEqual(['origin/main', ['feature.txt', 'notes.md']]) - const chosen = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: 'refs/heads/topic' }, signal) + const chosen = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: 'refs/heads/topic', commits: null }, signal) expect([chosen.base, paths(chosen)]).toEqual(['topic', ['notes.md']]) await expect(opened.ctx.forgePanel.diffBases(opened.session, '', signal)).resolves.toEqual({ automatic: { name: 'origin/main', worktree: false }, remote: [{ ref: 'refs/remotes/origin/main', name: 'origin/main' }], local: [{ ref: 'refs/heads/topic', name: 'topic' }], }) - await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: 'refs/heads/gone' }, signal)) + await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: 'refs/heads/gone', commits: null }, signal)) .rejects.toMatchObject({ code: 'forgePanel/base-missing' }) const outside = await open(await plain()) await expect(outside.ctx.forgePanel.diffBases(outside.session, '', signal)).rejects.toMatchObject({ code: 'forgePanel/not-a-checkout' }) }) + it('lists the commits of the branch scope and reads chosen ones', async () => { + const { repo } = await project() + await withDefaultBranch(repo) + for (const name of ['one', 'two', 'three']) { + await writeFile(join(repo, `${name}.txt`), `${name}\n`) + await git(repo, 'add', `${name}.txt`) + await git(repo, 'commit', '--quiet', '-m', name) + } + await git(repo, 'branch', 'topic', 'HEAD~1') + await writeFile(join(repo, 'notes.md'), 'hello\n') + const opened = await open(repo, { maxCommits: 2 }) + + const listed = await opened.ctx.forgePanel.localCommits(opened.session, null, signal) + expect(listed).toMatchObject({ truncated: true, headSha: await git(repo, 'rev-parse', 'HEAD') }) + expect(listed.commits.map(commit => commit.title)).toEqual(['one', 'two']) + await expect(opened.ctx.forgePanel.localCommits(opened.session, 'refs/heads/topic', signal)) + .resolves.toMatchObject({ truncated: false, commits: [{ title: 'three' }] }) + const [one, two] = listed.commits.map(commit => commit.sha) + const chosen = await opened.ctx.forgePanel.localDiff(opened.session, + { kind: 'branch', base: null, commits: { from: one ?? '', to: two ?? '' } }, signal) + expect(chosen.files.map(file => file.path)).toEqual(['one.txt', 'two.txt']) + await expect(opened.ctx.forgePanel.localText(opened.session, + { kind: 'branch', base: null, commits: { from: one ?? '', to: one ?? '' } }, 'one.txt', signal)).resolves.toBe('one\n') + await expect(opened.ctx.forgePanel.localDiff(opened.session, + { kind: 'branch', base: 'refs/heads/topic', commits: { from: one ?? '', to: two ?? '' } }, signal)) + .rejects.toMatchObject({ code: 'forgePanel/commits-missing' }) + await expect(opened.ctx.forgePanel.localCommits(opened.session, 'refs/heads/gone', signal)).rejects.toMatchObject({ code: 'forgePanel/base-missing' }) + + const unrelated = await project() + const lonely = await open(unrelated.repo) + await expect(lonely.ctx.forgePanel.localCommits(lonely.session, null, signal)).rejects.toMatchObject({ code: 'forgePanel/no-fork-point' }) + const outside = await open(await plain()) + await expect(outside.ctx.forgePanel.localCommits(outside.session, null, signal)).rejects.toMatchObject({ code: 'forgePanel/not-a-checkout' }) + }) + it('lists more refs than other Git output may print, up to maxDiffBytes', async () => { const { repo } = await project() await withDefaultBranch(repo) @@ -561,7 +596,7 @@ describe('forgePanel local changes', () => { const { repo } = await project() await writeFile(join(repo, 'tracked.txt'), 'x\n'.repeat(2000)) const opened = await open(repo, { maxDiffBytes: 1024 }) - await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: null }, signal)).rejects.toMatchObject({ code: 'forgePanel/no-fork-point' }) + await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch', base: null, commits: null }, signal)).rejects.toMatchObject({ code: 'forgePanel/no-fork-point' }) await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'unstaged' }, signal)).rejects.toMatchObject({ code: 'forgePanel/diff-too-large' }) await expect(opened.ctx.forgePanel.localText(opened.session, { kind: 'unstaged' }, 'tracked.txt', signal)) .rejects.toMatchObject({ code: 'forgePanel/diff-too-large' }) @@ -607,7 +642,7 @@ describe('forgePanel change request diffs', () => { shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) await reader(opened).until(state => polled(state, pullUrl(7))) - const diff = await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal) + const diff = await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal) expect(diff).toMatchObject({ headSha: head, localAhead: 1, fileUrls: { 'src/a.ts': `${pullUrl(7)}/files/${head}/src/a.ts` } }) expect(diff.files.map(file => [file.path, file.additions, file.deletions])).toEqual([['src/a.ts', 1, 1]]) await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(7), head, 'src/a.ts', signal)).resolves.toBe('new\n') @@ -621,7 +656,8 @@ describe('forgePanel change request diffs', () => { await reader(opened).until(state => polled(state, pullUrl(7))) opened.sandbox.failure = new Error('sandbox unavailable') - await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal)).resolves.toMatchObject({ localAhead: null }) + await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal)) + .resolves.toMatchObject({ localAhead: null }) }) it('downloads a diff once per head commit and base branch for every Session, keeping no failed read', async () => { @@ -631,7 +667,7 @@ describe('forgePanel change request diffs', () => { const follower = reader(opened) await follower.until(state => polled(state, pullUrl(7))) const paths = async (session: Session = opened.session): Promise => - (await opened.ctx.forgePanel.changeRequestDiff(session, pullUrl(7), signal)).files.map(file => file.path) + (await opened.ctx.forgePanel.changeRequestDiff(session, pullUrl(7), null, signal)).files.map(file => file.path) const moved = async (snapshot: { headSha: string; baseBranch: string }): Promise => { opened.provider.overrides.set(7, snapshot) await opened.ctx.forgePanel.refresh(opened.session, signal) @@ -657,6 +693,63 @@ describe('forgePanel change request diffs', () => { expect(opened.provider.diffs).toEqual([7, 7, 7, 7]) }) + it('classifies files by the root .gitattributes at the head and refuses attributes the host does not provide', async () => { + const { repo } = await project() + const opened = await open(repo) + const head = 'a'.repeat(40) + opened.provider.patches.set(7, `${PATCH}${PATCH.replaceAll('src/a.ts', 'gen/b.ts')}${PATCH.replaceAll('src/a.ts', 'src/a.spec.ts')}`) + opened.provider.texts.set(`${head}:.gitattributes`, 'gen/** linguist-generated\n') + shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await reader(opened).until(state => polled(state, pullUrl(7))) + + const diff = await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal) + expect(diff.files.map(file => [file.path, file.layer])).toEqual([['src/a.ts', 'implementation'], ['gen/b.ts', 'test'], ['src/a.spec.ts', 'test']]) + + const other = await open(repo) + other.provider.patches.set(7, PATCH) + other.provider.texts.set(`${head}:.gitattributes`, { code: 'unavailable', message: 'down' }) + shellTurn(other.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await reader(other).until(state => polled(state, pullUrl(7))) + await expect(other.ctx.forgePanel.changeRequestDiff(other.session, pullUrl(7), null, signal)) + .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' }) + }) + + it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => { + const { repo } = await project() + const opened = await open(repo) + const sha = (n: number): string => String(n).repeat(40) + const commit = (n: number, parent: string | null) => ({ sha: sha(n), parent, title: `c${n}`, author: 'octo', authoredAt: '2026-10-09T00:00:00Z' }) + const list = { headSha: sha(3), truncated: false, commits: [commit(1, null), commit(2, sha(1)), commit(3, sha(2))] } + opened.provider.commitLists.set(7, list) + opened.provider.comparisons.set(`${sha(1)}..${sha(3)}`, PATCH) + opened.provider.texts.set(`${sha(3)}:.gitattributes`, 'src/** linguist-generated\n') + shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await reader(opened).until(state => polled(state, pullUrl(7))) + const read = (from: number, to: number) => + opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), { from: sha(from), to: sha(to) }, signal) + + await expect(opened.ctx.forgePanel.changeRequestCommits(opened.session, pullUrl(7), signal)).resolves.toEqual(list) + await expect(read(2, 3)).resolves.toEqual({ + headSha: sha(3), localAhead: null, fileUrls: { 'src/a.ts': `${pullUrl(7)}/files/${sha(3)}/src/a.ts` }, + files: [expect.objectContaining({ path: 'src/a.ts', layer: 'test' })], + }) + expect(opened.provider.diffs).toEqual([]) + await expect(read(1, 1)).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'The first chosen commit has no parent to compare with' }) + await expect(read(3, 2)).rejects.toMatchObject({ code: 'forgePanel/commits-missing' }) + await expect(read(4, 3)).rejects.toMatchObject({ code: 'forgePanel/commits-missing' }) + await expect(read(3, 3)).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: `no comparison ${sha(2)}..${sha(3)}` }) + opened.provider.comparisons.set(`${sha(2)}..${sha(3)}`, { code: 'too-large', message: 'too many files' }) + await expect(read(3, 3)).rejects.toMatchObject({ code: 'forgePanel/diff-too-large' }) + + opened.provider.commitLists.set(7, { code: 'unavailable', message: 'down' }) + await expect(opened.ctx.forgePanel.changeRequestCommits(opened.session, pullUrl(7), signal)).rejects.toMatchObject({ code: 'forgePanel/diff-failed' }) + await expect(read(2, 3)).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' }) + delete opened.provider.commits + await expect(opened.ctx.forgePanel.changeRequestCommits(opened.session, pullUrl(7), signal)).resolves.toBeNull() + await expect(read(2, 3)).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'The code host provider does not read commits' }) + await expect(opened.ctx.forgePanel.changeRequestCommits(opened.session, pullUrl(9), signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) + }) + it('reports a diff or file over a size limit as too large', async () => { const { repo } = await project() const opened = await open(repo) @@ -666,7 +759,7 @@ describe('forgePanel change request diffs', () => { shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) await reader(opened).until(state => polled(state, pullUrl(7))) - await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal)) + await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal)) .rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'the diff exceeded the maximum number of files (300)' }) await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(7), head, 'big.bin', signal)) .rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'big.bin is too large to fetch' }) @@ -682,10 +775,10 @@ describe('forgePanel change request diffs', () => { ]) await reader(opened).until(state => polled(state, pullUrl(7)) && failing(state, pullUrl(8))) - await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(9), signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) - await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(8), signal)) + await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(9), null, signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) + await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(8), null, signal)) .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'The change request status has not been read yet' }) - await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal)) + await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal)) .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'no diff of #7' }) await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(7), 'a'.repeat(40), 'gone.ts', signal)) .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: `no gone.ts at ${'a'.repeat(40)}` }) @@ -1713,7 +1806,7 @@ describe('forgePanel actions', () => { await expect(opened.ctx.forgePanel.refresh(opened.session, controller.signal)).rejects.toThrow('closed') await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'unstaged' }, controller.signal)).rejects.toThrow('closed') await expect(opened.ctx.forgePanel.localText(opened.session, { kind: 'unstaged' }, 'tracked.txt', controller.signal)).rejects.toThrow('closed') - await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed') + await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(1), null, controller.signal)).rejects.toThrow('closed') await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(1), 'a'.repeat(40), 'x', controller.signal)).rejects.toThrow('closed') await expect(opened.ctx.forgePanel.markReady(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed') await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(1), 'merge', 'a'.repeat(40), controller.signal)).rejects.toThrow('closed') @@ -1765,6 +1858,9 @@ describe('forgePanel configuration', () => { [{ maxBranches: 0 }, 'forge-panel: maxBranches must be a positive integer'], [{ maxBranches: 2.5 }, 'forge-panel: maxBranches must be a positive integer'], [{ maxCachedDiffBytes: 0 }, 'forge-panel: maxCachedDiffBytes must be a positive integer'], + [{ maxCommits: 0 }, 'forge-panel: maxCommits must be a positive integer'], + [{ testPaths: ['**/*.spec.*', ' '] }, 'forge-panel: testPaths entries must not be empty'], + [{ interfacePaths: [''] }, 'forge-panel: interfacePaths entries must not be empty'], [{ worktreePrefix: '-dsh/' }, prefix], [{ worktreePrefix: 'dsh /' }, prefix], [{ worktreePrefix: 'dsh:' }, prefix], diff --git a/packages/experimental/client-ui-forge/tests/diff.host.spec.ts b/packages/experimental/client-ui-forge/tests/diff.host.spec.ts index 0d8aab0297..598109e93e 100644 --- a/packages/experimental/client-ui-forge/tests/diff.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/diff.host.spec.ts @@ -1,6 +1,6 @@ /** Patch parsing into the panel's diff model, untracked files as added in full, and numstat records. */ import { describe, expect, it } from 'vitest' -import { decodeText, parseDiff, parseNumstat, untrackedFile } from '../src/diff.ts' +import { decodeText, oversizedUntrackedFile, parseDiff, parseNumstat, untrackedFile } from '../src/diff.ts' /** * A `git diff` patch with a binary change, a pure rename, a quoted path, a path @@ -82,6 +82,26 @@ describe('parseDiff', () => { expect(files[7]?.hunks).toEqual([{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 0, lines: ['-q', '-z'] }]) }) + it('digests each change by its paths, status, and hunks, and a binary change by its blob names', () => { + const files = parseDiff(PATCH) + const digests = files.map(file => file.digest) + expect(new Set(digests).size).toBe(files.length) + expect(digests.every(digest => /^[0-9a-f]{32}$/.test(digest))).toBe(true) + expect(parseDiff(PATCH).map(file => file.digest)).toEqual(digests) + const edited = parseDiff(PATCH.replace('+B', '+C')).map(file => file.digest) + expect(edited.filter((digest, index) => digest !== digests[index])).toHaveLength(1) + expect(edited[3]).not.toBe(digests[3]) + expect(parseDiff(PATCH.replace('index bdc955b..8352675', 'index bdc955b..0000001'))[0]?.digest).not.toBe(digests[0]) + }) + + it('digests a binary change without its blob names when a section does not start with a Git header', () => { + const binary = 'diff --git a/x.png b/x.png\nindex 1111111..2222222 100644\nBinary files a/x.png and b/x.png differ\n' + const withLeader = `--- a/y\n+++ b/y\n@@ -1 +1 @@\n-a\n+b\n${binary}` + const other = withLeader.replace('2222222', '3333333') + expect(parseDiff(withLeader)[1]?.digest).toBe(parseDiff(other)[1]?.digest) + expect(parseDiff(binary)[0]?.digest).not.toBe(parseDiff(binary.replace('2222222', '3333333'))[0]?.digest) + }) + it('takes paths without a prefix as they are and skips a section without a path', () => { expect(parseDiff('--- old\n+++ new\n@@ -1 +1 @@\n-a\n+b\n').map(file => [file.path, file.status])).toEqual([['new', 'modified']]) expect(parseDiff('')).toEqual([]) @@ -94,10 +114,20 @@ describe('untrackedFile', () => { ['no final newline', 'one\ntwo', ['+one', '+two', '\\ No newline at end of file']], ['carriage returns', 'one\r\ntwo\r\n', ['+one\r', '+two\r']], ])('adds every line of a text file with %s', (_label, text, lines) => { - expect(untrackedFile('notes/new.md', new TextEncoder().encode(text))).toEqual({ + const { digest, ...added } = untrackedFile('notes/new.md', new TextEncoder().encode(text)) + expect(added).toEqual({ path: 'notes/new.md', previousPath: null, status: 'added', binary: false, oversized: false, untracked: true, additions: 2, deletions: 0, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2, lines }], }) + expect(digest).toMatch(/^[0-9a-f]{32}$/) + }) + + it('digests binary content by its bytes and an oversized file by its path alone', () => { + const digest = (bytes: number[]): string => untrackedFile('blob', Uint8Array.from(bytes)).digest + expect(digest([0, 1])).toBe(digest([0, 1])) + expect(digest([0, 1])).not.toBe(digest([0, 2])) + expect(oversizedUntrackedFile('big.log')).toMatchObject({ oversized: true, binary: false, hunks: [], untracked: true }) + expect(oversizedUntrackedFile('big.log').digest).not.toBe(untrackedFile('big.log', new Uint8Array()).digest) }) it('describes an empty file without a hunk', () => { diff --git a/packages/experimental/client-ui-forge/tests/harness.ts b/packages/experimental/client-ui-forge/tests/harness.ts index 652de1a153..6dd6e7dc18 100644 --- a/packages/experimental/client-ui-forge/tests/harness.ts +++ b/packages/experimental/client-ui-forge/tests/harness.ts @@ -12,8 +12,8 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createInboxStub, unsupportedModel } from '@deepseek-ai/dsh-agent-loop-testkit' import ForgeService, { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { - ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, - ForgeRepository, MergeRequest, + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeProvider, + ForgeReadiness, ForgeRepository, MergeRequest, } from '@deepseek-ai/dsh-experimental-forge' import ForgeSessionService from '@deepseek-ai/dsh-experimental-forge-session' import WorktreeService from '@deepseek-ai/dsh-experimental-worktree' @@ -291,6 +291,17 @@ export class FakeProvider implements ForgeProvider { readonly heads = new Map() /** Head lookups in call order, as `/ `. */ readonly lookups: string[] = [] + /** Commit lists or failures by change request number; others return a not-found failure. */ + readonly commitLists = new Map() + /** Patches or failures between two commits by `..`; others return a not-found failure. */ + readonly comparisons = new Map() + /** Commit listing; a test deletes it to stand for a provider that does not read commits. */ + commits?: (ref: ChangeRequestRef, signal: AbortSignal) => Promise = (ref: ChangeRequestRef) => + Promise.resolve(this.commitLists.get(ref.number) ?? { code: 'not-found', message: `no commits of #${ref.number}` }) + /** Comparison of two commits. */ + compare?: (ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal) => Promise = + (_ref: ChangeRequestRef, base: string, head: string) => + Promise.resolve(this.comparisons.get(`${base}..${head}`) ?? { code: 'not-found', message: `no comparison ${base}..${head}` }) repositoryFromRemote(url: string): ForgeRepository | undefined { const [, host = '', owner = '', name = ''] = /^https:\/\/([a-z.]+\.test)\/([^/]+)\/([^/]+?)\.git$/.exec(url) ?? [] diff --git a/packages/experimental/client-ui-forge/tests/layers.host.spec.ts b/packages/experimental/client-ui-forge/tests/layers.host.spec.ts new file mode 100644 index 0000000000..172ebc57d6 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/layers.host.spec.ts @@ -0,0 +1,65 @@ +/** Review layers of changed files: path globs and the `linguist-generated` attribute of a root `.gitattributes`. */ +import { describe, expect, it } from 'vitest' +import { untrackedFile } from '../src/diff.ts' +import { FileLayers, generatedMatcher } from '../src/layers.ts' + +describe('generatedMatcher', () => { + it('applies lines in order, with unset and unspecified values overriding an earlier set', () => { + const generated = generatedMatcher([ + '# comment', + '', + '[attr]binary -diff -merge -text', + '"quoted name.json" linguist-generated', + 'build/ linguist-generated', + '*.schema.json linguist-generated=true', + 'docs/kept.schema.json -linguist-generated', + 'docs/reset.schema.json !linguist-generated', + 'docs/off.schema.json linguist-generated=false', + '/root.gen.ts linguist-generated', + 'gen/*.ts text linguist-generated', + 'other.txt text', + ].join('\r\n')) + + expect(generated('a.schema.json')).toBe(true) + expect(generated('docs/deep/a.schema.json')).toBe(true) + expect(generated('docs/kept.schema.json')).toBe(false) + expect(generated('docs/reset.schema.json')).toBe(false) + expect(generated('docs/off.schema.json')).toBe(false) + expect(generated('root.gen.ts')).toBe(true) + expect(generated('nested/root.gen.ts')).toBe(false) + expect(generated('gen/a.ts')).toBe(true) + expect(generated('src/gen/a.ts')).toBe(false) + expect(generated('build/out.js')).toBe(false) + expect(generated('quoted name.json')).toBe(false) + expect(generated('other.txt')).toBe(false) + }) + + it('marks nothing without attributes', () => { + expect(generatedMatcher('')('a.ts')).toBe(false) + }) +}) + +describe('FileLayers', () => { + const file = (path: string) => untrackedFile(path, new Uint8Array()) + + it('places a file in the test layer first, then the interface layer, else the implementation layer', () => { + const layers = new FileLayers(['**/*.spec.*', '**/locale/*.json'], ['**/package.json', '**/*.json']) + const classified = layers.classify( + ['src/a.ts', 'src/a.spec.ts', 'package.json', 'locale/en.json', 'docs/schema.json', '.github/x.json'].map(file), + 'docs/schema.json linguist-generated\n', + ) + + expect(classified.map(item => [item.path, item.layer])).toEqual([ + ['src/a.ts', 'implementation'], + ['src/a.spec.ts', 'test'], + ['package.json', 'interface'], + ['locale/en.json', 'test'], + ['docs/schema.json', 'test'], + ['.github/x.json', 'interface'], + ]) + }) + + it('places every file in the implementation layer without globs or attributes', () => { + expect(new FileLayers([], []).classify([file('a.spec.ts')], '')).toMatchObject([{ layer: 'implementation' }]) + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/local-diff-safety.host.spec.ts b/packages/experimental/client-ui-forge/tests/local-diff-safety.host.spec.ts index 0a2d95cdfc..1019374d67 100644 --- a/packages/experimental/client-ui-forge/tests/local-diff-safety.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/local-diff-safety.host.spec.ts @@ -8,8 +8,11 @@ import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { inspectCheckout } from '../src/git.ts' import { FilterConfigError, localChanges, localDiff } from '../src/local-diff.ts' import type { DiffConfig } from '../src/local-diff.ts' +import { FileLayers } from '../src/layers.ts' import { git, gitContext, plainSession, repository, sandboxRecord, scratch } from './harness.ts' +const LAYERS = new FileLayers([], []) + const CONFIG: DiffConfig = { gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576, maxDiffBytes: 1_048_576, maxUntrackedFiles: 100, } @@ -54,7 +57,7 @@ describe('diff helper restrictions', () => { const spawn = vi.spyOn(b.ctx.subprocess, 'spawn') try { - await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) await localChanges(b.ctx, CONFIG, b.session, b.inspection, null, b.signal) const diffs = spawn.mock.calls.map(([spec]) => spec).filter(spec => spec.argv.includes('diff')) expect(diffs).toHaveLength(2) @@ -90,7 +93,7 @@ describe('diff helper restrictions', () => { expect(await readFile(marker, 'utf8')).toBe('ran') await rm(marker) - await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) await localChanges(b.ctx, CONFIG, b.session, b.inspection, null, b.signal) await expect(access(marker)).rejects.toThrow() }) @@ -103,7 +106,7 @@ describe('diff helper restrictions', () => { await git(b.repo, 'config', 'filter.demo.clean', `${b.command} clean`) await writeFile(join(b.repo, 'tracked.txt'), 'initial\r\n') - const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) const changes = await localChanges(b.ctx, CONFIG, b.session, b.inspection, null, b.signal) expect(diff.files).toEqual([]) expect(changes?.files.some(file => file.path === 'tracked.txt')).toBe(false) @@ -124,7 +127,7 @@ describe('diff helper restrictions', () => { const index = await readFile(join(b.repo, '.git', 'index')) const indexStat = await stat(join(b.repo, '.git', 'index')) - const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, b.fork, { kind: 'unstaged' }, b.signal) + const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, b.fork, { kind: 'unstaged' }, LAYERS, b.signal) const changes = await localChanges(b.ctx, CONFIG, b.session, b.inspection, null, b.signal) expect(diff.files.find(file => file.path === 'tracked.txt')?.hunks.flatMap(hunk => hunk.lines)).toEqual(['-initial', '+changed']) @@ -145,7 +148,7 @@ describe('diff helper restrictions', () => { expect(await readFile(b.marker, 'utf8')).toBe('ran') await rm(b.marker) - await localDiff(b.ctx, CONFIG, b.session, b.repo, b.fork, { kind: 'unstaged' }, b.signal) + await localDiff(b.ctx, CONFIG, b.session, b.repo, b.fork, { kind: 'unstaged' }, LAYERS, b.signal) await localChanges(b.ctx, CONFIG, b.session, b.inspection, null, b.signal) await expect(access(b.marker)).rejects.toThrow() }) @@ -156,7 +159,7 @@ describe('diff helper restrictions', () => { await git(b.repo, 'config', key, b.command) await writeFile(join(b.repo, 'tracked.txt'), 'changed\n') - await localDiff(b.ctx, CONFIG, b.session, b.repo, b.fork, { kind: 'unstaged' }, b.signal) + await localDiff(b.ctx, CONFIG, b.session, b.repo, b.fork, { kind: 'unstaged' }, LAYERS, b.signal) await localChanges(b.ctx, CONFIG, b.session, b.inspection, null, b.signal) await expect(access(b.marker)).rejects.toThrow() }) @@ -170,7 +173,7 @@ describe('diff helper restrictions', () => { await writeFile(join(nested, 'tracked.txt'), 'dirty\n') await git(nested, 'config', 'core.fsmonitor', b.command) await git(b.repo, 'config', 'diff.submodule', 'diff') - const clean = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + const clean = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) expect(clean.files).toEqual([]) await expect(access(b.marker)).rejects.toThrow() @@ -179,7 +182,7 @@ describe('diff helper restrictions', () => { await git(nested, 'config', 'user.email', 'forge@example.invalid') await git(nested, '-c', 'commit.gpgSign=false', 'commit', '-am', 'nested change') await git(nested, 'config', 'core.fsmonitor', b.command) - const changed = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + const changed = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) expect(changed.files.map(file => file.path)).toEqual(['nested']) expect(changed.files[0]?.hunks.flatMap(hunk => hunk.lines).join('\n')).toContain('Subproject commit') await expect(access(b.marker)).rejects.toThrow() @@ -190,7 +193,7 @@ describe('diff helper restrictions', () => { await git(b.repo, 'config', `filter.${'long'.repeat(200)}.clean`, b.command) await writeFile(join(b.repo, '.gitattributes'), `tracked.txt filter=${'long'.repeat(200)}\n`) await writeFile(join(b.repo, 'tracked.txt'), 'changed\n') - await expect(localDiff(b.ctx, { ...CONFIG, maxDiffBytes: 128 }, b.session, b.repo, null, { kind: 'unstaged' }, b.signal)) + await expect(localDiff(b.ctx, { ...CONFIG, maxDiffBytes: 128 }, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal)) .rejects.toBeInstanceOf(FilterConfigError) await expect(localChanges(b.ctx, { ...CONFIG, maxOutputBytes: 128 }, b.session, b.inspection, null, b.signal)) .rejects.toBeInstanceOf(FilterConfigError) @@ -203,7 +206,7 @@ describe('diff helper restrictions', () => { const resolve = vi.spyOn(b.ctx.subprocess, 'resolveExecutable').mockRejectedValue(failure) const spawn = vi.spyOn(b.ctx.subprocess, 'spawn') try { - await expect(localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal)) + await expect(localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal)) .rejects.toMatchObject({ name: 'FilterConfigError', cause: failure }) expect(spawn).not.toHaveBeenCalled() } finally { resolve.mockRestore(); spawn.mockRestore() } @@ -218,9 +221,9 @@ describe('diff helper restrictions', () => { await git(b.repo, 'commit', '-m', 'pointer') await git(b.repo, 'config', 'filter.lfs.process', b.command) await git(b.repo, 'config', 'filter.lfs.required', 'true') - await expect(localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal)).resolves.toMatchObject({ files: [] }) + await expect(localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal)).resolves.toMatchObject({ files: [] }) await writeFile(join(b.repo, 'asset.bin'), Uint8Array.from([0, 1, 2, 3])) - const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) expect(diff.files).toMatchObject([{ path: 'asset.bin', binary: true, hunks: [] }]) await expect(access(b.marker)).rejects.toThrow() }) @@ -241,7 +244,7 @@ describe('diff helper restrictions', () => { expect(await readFile(b.marker, 'utf8')).toBe('ran') await rm(b.marker) - const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, b.signal) + const diff = await localDiff(b.ctx, CONFIG, b.session, b.repo, null, { kind: 'unstaged' }, LAYERS, b.signal) expect(diff.files).toMatchObject([{ path: 'asset.txt', additions: 1, deletions: 3 }]) await expect(access(b.marker)).rejects.toThrow() }) diff --git a/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts b/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts index 53e1a4a925..7c7f74b9c6 100644 --- a/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts @@ -7,9 +7,11 @@ import { FsError } from '@deepseek-ai/dsh-fs' import { gitIn, inspectCheckout } from '../src/git.ts' import type { Inspection } from '../src/git.ts' import { - aheadOf, BaseMissing, baseBranches, chosenForkPoint, DiffTooLarge, forkPoint, localChanges, localDiff, localText, NoForkPoint, + aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, DiffTooLarge, forkPoint, localChanges, localCommits, localDiff, + localText, NoForkPoint, } from '../src/local-diff.ts' -import type { DiffConfig } from '../src/local-diff.ts' +import type { DiffConfig, ForkPoint } from '../src/local-diff.ts' +import { FileLayers } from '../src/layers.ts' import type { ForgeLocalScope, ForgeSessionWorktree } from '../src/types.ts' import { git, gitContext, plainSession, repository, sandboxRecord, scratch } from './harness.ts' @@ -17,6 +19,9 @@ const CONFIG: DiffConfig = { gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576, maxDiffBytes: 1_048_576, maxUntrackedFiles: 100, } +/** Text files form the `test` layer and JSON files the `interface` layer. */ +const LAYERS = new FileLayers(['**/untracked.txt'], ['**/*.json']) + const cleanups: (() => Promise)[] = [] afterEach(async () => { @@ -203,14 +208,14 @@ describe('baseBranches', () => { describe('localDiff', () => { it.each([ - [{ kind: 'branch', base: null }, 'origin/main', ['committed.txt added', 'staged.txt added', 'tracked.txt modified', 'untracked.txt added untracked']], + [{ kind: 'branch', base: null, commits: null }, 'origin/main', ['committed.txt added', 'staged.txt added', 'tracked.txt modified', 'untracked.txt added untracked']], [{ kind: 'uncommitted' }, null, ['staged.txt added', 'tracked.txt modified', 'untracked.txt added untracked']], [{ kind: 'unstaged' }, null, ['tracked.txt modified', 'untracked.txt added untracked']], [{ kind: 'staged' }, null, ['staged.txt added']], ] as const)('reads the %o scope with untracked files last, leaving out ignored files and nested repositories', async (scope, base, files) => { const { repo, fork } = await forked(await directory()) const ctx = await context(repo) - const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, { commit: fork, label: 'origin/main', worktree: false }, scope, signal) + const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, { commit: fork, label: 'origin/main', worktree: false }, scope, LAYERS, signal) expect(diff.base).toBe(base) expect(diff.omitted).toBe(0) @@ -219,7 +224,7 @@ describe('localDiff', () => { it('carries the hunks of tracked and untracked files', async () => { const { repo, fork } = await forked(await directory()) - const diff = await localDiff(await context(repo), CONFIG, plainSession(repo), repo, { commit: fork, label: 'origin/main', worktree: false }, { kind: 'unstaged' }, signal) + const diff = await localDiff(await context(repo), CONFIG, plainSession(repo), repo, { commit: fork, label: 'origin/main', worktree: false }, { kind: 'unstaged' }, LAYERS, signal) expect(diff.files.map(file => [file.additions, file.deletions, file.hunks.flatMap(hunk => hunk.lines)])).toEqual([ [1, 0, [' initial', '+changed']], @@ -230,7 +235,7 @@ describe('localDiff', () => { it('refuses the branch scope without a fork point', async () => { const { repo } = await forked(await directory()) - await expect(localDiff(await context(repo), CONFIG, plainSession(repo), repo, null, { kind: 'branch', base: null }, signal)).rejects.toBeInstanceOf(NoForkPoint) + await expect(localDiff(await context(repo), CONFIG, plainSession(repo), repo, null, { kind: 'branch', base: null, commits: null }, LAYERS, signal)).rejects.toBeInstanceOf(NoForkPoint) }) it('compares a branch without commits with the empty tree', async () => { @@ -242,7 +247,7 @@ describe('localDiff', () => { const ctx = await context(repo) for (const kind of ['staged', 'uncommitted'] as const) { - const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, null, { kind }, signal) + const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, null, { kind }, LAYERS, signal) expect(diff.files.map(file => [file.path, file.status])).toEqual([['first.txt', 'added']]) } }) @@ -251,7 +256,7 @@ describe('localDiff', () => { const { repo, fork } = await forked(await directory()) await writeFile(join(repo, 'tracked.txt'), 'line\n'.repeat(400)) - await expect(localDiff(await context(repo), { ...CONFIG, maxDiffBytes: 512 }, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, signal)) + await expect(localDiff(await context(repo), { ...CONFIG, maxDiffBytes: 512 }, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, LAYERS, signal)) .rejects.toBeInstanceOf(DiffTooLarge) }) @@ -269,7 +274,7 @@ describe('localDiff', () => { return await read(target, readSignal, maxBytes) }) const config = { ...CONFIG, maxDiffBytes: 2048, maxUntrackedFiles: 4 } - const diff = await localDiff(ctx, config, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, signal) + const diff = await localDiff(ctx, config, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, LAYERS, signal) expect(diff.files.filter(file => file.untracked).map(file => [file.path, file.binary, file.oversized, file.additions])).toEqual([ ['more/big.txt', false, true, 0], @@ -284,13 +289,137 @@ describe('localDiff', () => { const ctx = await context(repo) vi.spyOn(ctx.fs, 'readBytes').mockRejectedValue(new Error('disk on fire')) - await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, signal)) + await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, LAYERS, signal)) .rejects.toThrow('disk on fire') }) }) +describe('review layers', () => { + it('classifies files by the globs and the root .gitattributes of the working tree, or of the index for the staged scope', async () => { + const { repo, fork } = await forked(await directory()) + const point = { commit: fork, label: 'origin/main', worktree: false } + await writeFile(join(repo, 'config.json'), '{}\n') + await writeFile(join(repo, '.gitattributes'), 'tracked.txt linguist-generated\n') + const ctx = await context(repo) + const layers = async (scope: ForgeLocalScope): Promise => + (await localDiff(ctx, CONFIG, plainSession(repo), repo, point, scope, LAYERS, signal)).files.map(file => `${file.path} ${file.layer}`) + + expect(await layers({ kind: 'unstaged' })).toEqual([ + 'tracked.txt test', '.gitattributes implementation', 'config.json interface', 'untracked.txt test', + ]) + await writeFile(join(repo, '.gitattributes'), 'staged.txt linguist-generated\n') + await git(repo, 'add', '.gitattributes') + await writeFile(join(repo, '.gitattributes'), '') + expect(await layers({ kind: 'staged' })).toEqual(['.gitattributes implementation', 'staged.txt test']) + expect(await layers({ kind: 'uncommitted' })).toEqual([ + '.gitattributes implementation', 'staged.txt implementation', 'tracked.txt implementation', 'config.json interface', 'untracked.txt test', + ]) + }) + + it('reads no attributes for a staged scope without .gitattributes and stops at an unreadable working tree file', async () => { + const { repo, fork } = await forked(await directory()) + const ctx = await context(repo) + const point = { commit: fork, label: 'origin/main', worktree: false } + + await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'staged' }, LAYERS, signal)) + .resolves.toMatchObject({ files: [{ path: 'staged.txt', layer: 'implementation' }] }) + await writeFile(join(repo, '.gitattributes'), Uint8Array.from([0x74, 0, 0x74])) + await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'unstaged' }, LAYERS, signal)) + .resolves.toMatchObject({ files: [{ path: 'tracked.txt', layer: 'implementation' }, { path: '.gitattributes' }, { path: 'untracked.txt' }] }) + const read = ctx.fs.readBytes.bind(ctx.fs) + vi.spyOn(ctx.fs, 'readBytes').mockImplementation(async (target, readSignal, maxBytes) => { + if (ctx.fs.processPath(target).endsWith('.gitattributes')) throw new FsError('denied', 'FS_PERMISSION_DENIED') + return await read(target, readSignal, maxBytes) + }) + await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'unstaged' }, LAYERS, signal)).rejects.toThrow('denied') + }) +}) + +describe('commits of the branch scope', () => { + /** {@link forked} with two more commits on `feat`, `second.txt` and then `.gitattributes` with a generated `third.json`. */ + async function committed(): Promise<{ readonly repo: string; readonly point: ForkPoint; readonly shas: readonly string[] }> { + const { repo, fork } = await forked(await directory()) + await git(repo, 'commit', '--quiet', '-m', 'staged') + await writeFile(join(repo, 'second.txt'), 'two\n') + await git(repo, 'add', 'second.txt') + await git(repo, 'commit', '--quiet', '-m', 'second') + await writeFile(join(repo, '.gitattributes'), 'third.json linguist-generated\n') + await writeFile(join(repo, 'third.json'), '{}\n') + await git(repo, 'add', '.gitattributes', 'third.json') + await git(repo, 'commit', '--quiet', '-m', 'third') + const shas = (await git(repo, 'rev-list', '--reverse', `${fork}..HEAD`)).split('\n') + return { repo, point: { commit: fork, label: 'origin/main', worktree: false }, shas } + } + + it('lists the commits between the fork point and HEAD oldest first, bounded by the limit', async () => { + const { repo, point, shas } = await committed() + const ctx = await context(repo) + const runner = gitIn(ctx, CONFIG, plainSession(repo), repo, signal) + + const listed = await localCommits(runner, point, 10) + expect(listed).toMatchObject({ headSha: shas.at(-1), truncated: false }) + expect(listed.commits.map(commit => [commit.title, commit.author])).toEqual([ + ['committed', 'Forge fixture'], ['staged', 'Forge fixture'], ['second', 'Forge fixture'], ['third', 'Forge fixture'], + ]) + expect(listed.commits.map(commit => commit.sha)).toEqual(shas) + expect(listed.commits.map(commit => commit.parent)).toEqual([point.commit, ...shas.slice(0, -1)]) + expect(Date.parse(listed.commits[0]?.authoredAt ?? '')).not.toBeNaN() + await expect(localCommits(runner, point, 2)).resolves.toMatchObject({ truncated: true, commits: [{ title: 'committed' }, { title: 'staged' }] }) + }) + + it('reads the committed changes of consecutive commits with the attributes of the last one, without working tree changes', async () => { + const { repo, point, shas } = await committed() + const ctx = await context(repo) + const range = async (from: number, to: number): Promise => { + const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, point, + { kind: 'branch', base: null, commits: { from: shas[from] ?? '', to: shas[to] ?? '' } }, LAYERS, signal) + expect(diff).toMatchObject({ base: 'origin/main', omitted: 0 }) + return diff.files.map(file => `${file.path} ${file.layer}`) + } + + expect(await range(2, 2)).toEqual(['second.txt implementation']) + expect(await range(1, 3)).toEqual(['.gitattributes implementation', 'second.txt implementation', 'staged.txt implementation', 'third.json test']) + await expect(localText(ctx, CONFIG, plainSession(repo), repo, + { kind: 'branch', base: null, commits: { from: shas[2] ?? '', to: shas[2] ?? '' } }, 'second.txt', signal)).resolves.toBe('two\n') + }) + + it('compares a first commit without a parent with the empty tree', async () => { + const { repo, point } = await committed() + await git(repo, 'checkout', '--quiet', '--orphan', 'other') + await git(repo, 'rm', '--quiet', '-r', '-f', '--cached', '.') + await writeFile(join(repo, 'orphan.txt'), 'root\n') + await git(repo, 'add', 'orphan.txt') + await git(repo, 'commit', '--quiet', '-m', 'orphan') + const orphan = await git(repo, 'rev-parse', 'HEAD') + await git(repo, 'checkout', '--quiet', '--force', 'feat') + await git(repo, 'merge', '--quiet', '--allow-unrelated-histories', '--no-edit', 'other') + const ctx = await context(repo) + + const listed = await localCommits(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), point, 10) + expect(listed.commits.find(commit => commit.sha === orphan)?.parent).toBeNull() + const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, point, + { kind: 'branch', base: null, commits: { from: orphan, to: orphan } }, LAYERS, signal) + expect(diff.files.map(file => [file.path, file.status])).toEqual([['orphan.txt', 'added']]) + }) + + it('refuses commits that are not full object names, not on the branch, or out of order', async () => { + const { repo, point, shas } = await committed() + const ctx = await context(repo) + const read = (from: string, to: string): Promise => + localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'branch', base: null, commits: { from, to } }, LAYERS, signal) + const [first = '', second = ''] = shas + + await expect(read('HEAD', second)).rejects.toBeInstanceOf(CommitsMissing) + await expect(read(first, '--output=x')).rejects.toBeInstanceOf(CommitsMissing) + await expect(read(point.commit, second)).rejects.toThrow('The chosen commits are no longer on the branch') + await expect(read(second, first)).rejects.toThrow('The chosen commits are no longer on the branch') + await expect(localText(ctx, CONFIG, plainSession(repo), repo, + { kind: 'branch', base: null, commits: { from: first, to: 'HEAD' } }, 'second.txt', signal)).rejects.toBeInstanceOf(CommitsMissing) + }) +}) + describe('localText', () => { - const branch: ForgeLocalScope = { kind: 'branch', base: null } + const branch: ForgeLocalScope = { kind: 'branch', base: null, commits: null } it('reads the working tree, or the index for the staged scope', async () => { const { repo } = await forked(await directory()) diff --git a/packages/experimental/client-ui-forge/tests/patch-cache.host.spec.ts b/packages/experimental/client-ui-forge/tests/patch-cache.host.spec.ts index ab0d6ff28c..b66cc97290 100644 --- a/packages/experimental/client-ui-forge/tests/patch-cache.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/patch-cache.host.spec.ts @@ -5,6 +5,11 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { ChangeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import { PatchCache } from '../src/patch-cache.ts' +/** A patch without attributes. */ +function entry(patch: string): { readonly patch: string; readonly attributes: string } { + return { patch, attributes: '' } +} + function key(number: number): ChangeRequestKey { return changeRequestKey({ repository: { forge: 'fake', host: 'fake.test', owner: 'octo', name: 'demo' }, number }) } @@ -14,35 +19,43 @@ describe('PatchCache', () => { const cache = new PatchCache(100) expect(cache.get(key(1), 'v1')).toBeUndefined() - cache.set(key(1), 'v1', 'one') - expect(cache.get(key(1), 'v1')).toBe('one') + cache.set(key(1), 'v1', entry('one')) + expect(cache.get(key(1), 'v1')).toEqual(entry('one')) expect(cache.get(key(1), 'v2')).toBeUndefined() - cache.set(key(1), 'v2', 'two') + cache.set(key(1), 'v2', entry('two')) expect(cache.get(key(1), 'v1')).toBeUndefined() - expect(cache.get(key(1), 'v2')).toBe('two') + expect(cache.get(key(1), 'v2')).toEqual(entry('two')) }) it('drops the least recently read patches once the kept patches exceed the bound in UTF-8 bytes', () => { const cache = new PatchCache(10) - cache.set(key(1), 'v', 'aaaa') - cache.set(key(2), 'v', 'bbb') - expect(cache.get(key(1), 'v')).toBe('aaaa') + cache.set(key(1), 'v', entry('aaaa')) + cache.set(key(2), 'v', entry('bbb')) + expect(cache.get(key(1), 'v')).toEqual(entry('aaaa')) // Two three-byte characters bring the total to 13 bytes, so #2, read least recently, leaves. - cache.set(key(3), 'v', '变更') + cache.set(key(3), 'v', entry('变更')) expect(cache.get(key(2), 'v')).toBeUndefined() - expect(cache.get(key(1), 'v')).toBe('aaaa') - expect(cache.get(key(3), 'v')).toBe('变更') + expect(cache.get(key(1), 'v')).toEqual(entry('aaaa')) + expect(cache.get(key(3), 'v')).toEqual(entry('变更')) + }) + + it('counts the attributes kept beside a patch toward the bound', () => { + const cache = new PatchCache(6) + cache.set(key(1), 'v', { patch: 'aaa', attributes: 'bbbb' }) + expect(cache.get(key(1), 'v')).toBeUndefined() + cache.set(key(1), 'v', { patch: 'aaa', attributes: 'bbb' }) + expect(cache.get(key(1), 'v')).toEqual({ patch: 'aaa', attributes: 'bbb' }) }) it('keeps no patch over the bound, dropping the earlier version all the same', () => { const cache = new PatchCache(4) - cache.set(key(1), 'v1', 'one') - cache.set(key(1), 'v2', 'large') + cache.set(key(1), 'v1', entry('one')) + cache.set(key(1), 'v2', entry('large')) expect(cache.get(key(1), 'v1')).toBeUndefined() expect(cache.get(key(1), 'v2')).toBeUndefined() - cache.set(key(1), 'v3', 'four') - expect(cache.get(key(1), 'v3')).toBe('four') + cache.set(key(1), 'v3', entry('four')) + expect(cache.get(key(1), 'v3')).toEqual(entry('four')) }) }) diff --git a/packages/experimental/client-ui-forge/tsconfig.host.json b/packages/experimental/client-ui-forge/tsconfig.host.json index 13474f6353..65f55fefac 100644 --- a/packages/experimental/client-ui-forge/tsconfig.host.json +++ b/packages/experimental/client-ui-forge/tsconfig.host.json @@ -10,6 +10,7 @@ "src/diff.ts", "src/git.ts", "src/local-diff.ts", + "src/layers.ts", "src/panel.ts", "src/parse.ts", "src/patch-cache.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca94685474..8633c2464e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9246,6 +9246,9 @@ importers: diff: specifier: ^9.0.0 version: 9.0.0 + picomatch: + specifier: ^4.0.7 + version: 4.0.7 zod: specifier: 4.6.6 version: 4.6.6 @@ -9367,6 +9370,9 @@ importers: '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -31306,6 +31312,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -34001,8 +34011,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.0: {} From c313392bf822d0e1c75e461970be58b0fd8c583f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 6 Nov 2026 17:51:22 +0800 Subject: [PATCH 051/149] feat(forge-panel): review changes by layer, mark files viewed, and view chosen commits The changes view gains a lens of All, Interface, Implementation, and Tests that spans the file tree above it, a switch that hides test files from the tree and the diff, and a viewed checkbox on each file header that collapses the file. The toolbar counts viewed files and V toggles the file being read. Marks are kept per Session, source, path, and change digest in browser storage, so a file that changes again reads as changed since viewed. All commits beside Submit review, and beside the base branch of the Branch scope, shows one commit or a Shift run of commits. --- .../forge-panel/changes-view.expected.md | 6 + .../forge-panel/file-tree-toggle.expected.md | 10 + .../filter-config-failed.expected.md | 1 + .../forge-panel/review-layers.expected.md | 42 +++ apps/web/tests/forge-panel.e2e.ts | 201 +++++++++- docs/config-catalog.md | 15 +- docs/config-catalog.zh.md | 15 +- docs/subsystems/forge.i18n.yaml | 8 +- docs/subsystems/forge.md | 164 ++++++++- docs/subsystems/forge.zh.md | 164 ++++++++- .../client-ui-forge/README.i18n.yaml | 16 +- .../experimental/client-ui-forge/README.md | 21 +- .../experimental/client-ui-forge/README.zh.md | 21 +- .../src/client/ChangesTab.module.css | 142 ++++++- .../client-ui-forge/src/client/ChangesTab.tsx | 346 +++++++++++++----- .../src/client/CommitMenu.module.css | 130 +++++++ .../client-ui-forge/src/client/CommitMenu.tsx | 162 ++++++++ .../client-ui-forge/src/client/DiffFile.tsx | 18 +- .../src/client/FileTreePane.tsx | 7 +- .../src/client/ReviewControls.module.css | 147 ++++++++ .../src/client/ReviewControls.tsx | 148 ++++++++ .../src/client/changes-store.ts | 67 +++- .../client-ui-forge/src/client/locales.ts | 216 +++++++++++ .../client-ui-forge/src/client/mount.ts | 12 +- .../src/client/review-queue.ts | 18 +- .../client-ui-forge/src/client/review.ts | 24 +- .../src/client/viewed-files.ts | 145 ++++++++ .../tests/changes-address.client.spec.ts | 31 +- .../tests/changes-tab.client.spec.tsx | 242 +++++++++++- .../tests/diff-review.client.spec.tsx | 6 +- .../tests/diff-rows.client.spec.ts | 2 +- .../tests/file-tree.client.spec.tsx | 6 +- .../tests/mount.client.spec.ts | 34 +- .../tests/review-controls.client.spec.tsx | 229 ++++++++++++ .../tests/review.client.spec.tsx | 54 ++- .../tests/viewed-files.client.spec.ts | 92 +++++ .../extensions/tool-cordis/src/api-catalog.ts | 68 +++- scripts/gen-cordis-catalog.ts | 5 + scripts/type-equiv.manifest.json | 25 ++ 39 files changed, 2801 insertions(+), 259 deletions(-) create mode 100644 apps/web/tests/expected/forge-panel/review-layers.expected.md create mode 100644 packages/experimental/client-ui-forge/src/client/CommitMenu.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/CommitMenu.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ReviewControls.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ReviewControls.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/viewed-files.ts create mode 100644 packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/viewed-files.client.spec.ts diff --git a/apps/web/tests/expected/forge-panel/changes-view.expected.md b/apps/web/tests/expected/forge-panel/changes-view.expected.md index b4f0445c07..1e3111ba0c 100644 --- a/apps/web/tests/expected/forge-panel/changes-view.expected.md +++ b/apps/web/tests/expected/forge-panel/changes-view.expected.md @@ -1,8 +1,12 @@ - img "变更请求状态:需要处理" - text: "Add the pull request panel #7" +- button "查看的提交:全部提交": 全部提交 - button "提交评审" - button "在浏览器中打开" - button "全部 2 个文件" +- button "分层透镜:全部": 全部 +- switch "隐藏测试文件" +- progressbar "已审阅的文件": 已审阅 0 / 2 - text: +4 −2 - button "刷新变更" - button "全部折叠" @@ -11,6 +15,7 @@ - region "全部 2 个文件": - button "收起 panel.md" [expanded] - text: docs/panel.md 新文件 +2 −0 + - checkbox "将 panel.md 标记为已审阅" - button "panel.md 的更多操作" - button "选择第 R1 行": "1" - text: "+ # Panel" @@ -18,6 +23,7 @@ - text: + Shows the change requests of a Session. - button "收起 panel.ts" [expanded] - text: src/panel.ts +2 −2 + - checkbox "将 panel.ts 标记为已审阅" - button "panel.ts 的更多操作" - button "选择第 L1 行": "1" - text: export const n1 = 1 diff --git a/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md b/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md index 72d7382c18..e7f09431a2 100644 --- a/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md +++ b/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md @@ -1,8 +1,16 @@ - img "变更请求状态:需要处理" - text: "Add the pull request panel #7" +- button "查看的提交:全部提交": 全部提交 - button "提交评审" - button "在浏览器中打开" +- radiogroup "分层透镜": + - radio "全部 2" [checked] + - radio "接口 0" + - radio "实现 2" + - radio "测试 0" - button "全部 2 个文件" +- switch "隐藏测试文件" +- progressbar "已审阅的文件": 已审阅 0 / 2 - text: +4 −2 - button "刷新变更" - button "全部折叠" @@ -12,6 +20,7 @@ - region "全部 2 个文件": - button "收起 panel.md" [expanded] - text: docs/panel.md 新文件 +2 −0 + - checkbox "将 panel.md 标记为已审阅" - button "panel.md 的更多操作" - button "选择第 R1 行": "1" - text: "+ # Panel" @@ -19,6 +28,7 @@ - text: + Shows the change requests of a Session. - button "收起 panel.ts" [expanded] - text: src/panel.ts +2 −2 + - checkbox "将 panel.ts 标记为已审阅" - button "panel.ts 的更多操作" - button "选择第 L1 行": "1" - text: export const n1 = 1 diff --git a/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md b/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md index 114cf607bf..d5e6cce057 100644 --- a/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md +++ b/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md @@ -1,6 +1,7 @@ - text: main - button "基准分支:origin/main": origin/main - text: 包含未提交的修改 +- button "查看的提交:全部提交": 全部提交 - button "创建变更请求" - button "分支" - button "全部 0 个文件" [disabled] diff --git a/apps/web/tests/expected/forge-panel/review-layers.expected.md b/apps/web/tests/expected/forge-panel/review-layers.expected.md new file mode 100644 index 0000000000..e160dbbd40 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-layers.expected.md @@ -0,0 +1,42 @@ +- img "变更请求状态:需要处理" +- text: "Add the pull request panel #7" +- button "查看的提交:全部提交": 全部提交 +- button "提交评审" +- button "在浏览器中打开" +- radiogroup "分层透镜": + - radio "全部 3" [checked] + - radio "接口 1" + - radio "实现 2" + - radio "测试" [disabled] +- button "全部 3 个文件" +- switch "隐藏测试文件" [checked] +- progressbar "已审阅的文件": 已审阅 2 / 3 +- text: +5 −2 +- button "刷新变更" +- button "全部折叠" +- button "左右对比" [pressed] +- button "自动换行" [pressed] +- button "将文件树移到右侧" [expanded] +- region "全部 3 个文件": + - button "展开 panel.md" + - text: docs/panel.md 新文件 +2 −0 + - checkbox "将 panel.md 标记为已审阅" [checked] + - button "panel.md 的更多操作" + - button "展开 panel.ts" + - text: src/panel.ts +2 −2 + - checkbox "将 panel.ts 标记为已审阅" [checked] + - button "panel.ts 的更多操作" + - button "收起 package.json" [expanded] + - text: package.json 新文件 +1 −0 + - checkbox "将 package.json 标记为已审阅" + - button "package.json 的更多操作" + - button "选择第 R1 行": "1" + - text: "+ { \"name\": \"demo\" }" +- separator "调整文件树宽度" +- navigation "全部 3 个文件": + - textbox "筛选文件…" + - button "docs" [expanded] + - button "docs/panel.md": panel.md +2 −0 + - button "src" [expanded] + - button "src/panel.ts": panel.ts +2 −2 + - button "package.json": package.json +1 −0 diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 44f24a6a58..9335003dc1 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -45,8 +45,9 @@ const run = promisify(execFile) * `gh pr ready 9` and the REST merge of pull request 9 change what later * queries report, through `state.json`. `gh pr diff N` prints `pr-N.diff`, or * fails with `pr-N.refused` on standard error when that file exists, and the raw - * contents API serves `contents.json`, keyed by `path@commit`. Every invocation - * is appended to `calls.jsonl`. + * contents API serves `contents.json`, keyed by `path@commit`. The commit listing of + * pull request 7 serves `commits.json`, oldest first, and the compare API serves + * `compare.json`, keyed by `base...head`. Every invocation is appended to `calls.jsonl`. */ const GH = `#!/usr/bin/env node import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' @@ -96,6 +97,16 @@ if (args[0] === 'pr' && args[1] === 'diff') { await new Promise(resolve => process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'), resolve)) process.exit(0) } +if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.diff') { + const range = args[5].slice('repos/octo/demo/compare/'.length) + const patch = existsSync(join(here, 'compare.json')) ? JSON.parse(readFileSync(join(here, 'compare.json'), 'utf8'))[range] : undefined + if (patch === undefined) { + process.stderr.write('gh: Not Found (HTTP 404)\\n') + process.exit(1) + } + process.stdout.write(patch) + process.exit(0) +} if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') { const [resource, query] = args[5].split('?') const path = decodeURIComponent(resource.slice('repos/octo/demo/contents/'.length)) @@ -139,6 +150,17 @@ if (args[0] === 'api' && args[1] === 'graphql') { } } } }] }, }, } + if (query.includes('commits(first: $first')) { + const commits = existsSync(join(here, 'commits.json')) ? JSON.parse(readFileSync(join(here, 'commits.json'), 'utf8')) : [] + process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { headRefOid: 'a'.repeat(40), commits: { + totalCount: commits.length, pageInfo: { hasNextPage: false, endCursor: null }, + nodes: commits.map(commit => ({ commit: { + oid: commit.sha, messageHeadline: commit.title, authoredDate: '2026-10-08T00:00:00Z', + author: { name: 'Octo', user: { login: 'octo' } }, parents: { nodes: [{ oid: commit.parent }] }, + } })), + } } } } })) + process.exit(0) + } if (query.includes('node(id: $id)')) { process.stdout.write(JSON.stringify({ data: { node: { contexts: { pageInfo: { hasNextPage: false, endCursor: null }, @@ -204,6 +226,50 @@ const PR7_DIFF = [ '', ].join('\n') +/** A patch section that adds `lines` as the new file `path`. */ +function addedSection(path: string, lines: readonly string[]): string[] { + return [`diff --git a/${path} b/${path}`, 'new file mode 100644', 'index 0000000..4444444', '--- /dev/null', `+++ b/${path}`, + `@@ -0,0 +1,${lines.length} @@`, ...lines.map(line => `+${line}`)] +} + +/** Commits of pull request 7 in the review fixture, oldest first; the last is its head. */ +const REVIEW_COMMITS = [ + { sha: '1'.repeat(40), parent: 'f'.repeat(40), title: 'Add the pull request panel' }, + { sha: '2'.repeat(40), parent: '1'.repeat(40), title: 'Test the panel and translate it' }, + { sha: HEAD7, parent: '2'.repeat(40), title: 'Publish the panel schema' }, +] as const + +/** New files of each review fixture commit after the first, which changes `src/panel.ts` and adds `docs/panel.md`. */ +const REVIEW_SECTIONS = { + test: [ + ...addedSection('src/panel.spec.ts', ['it(\'shows the panel\', () => {})']), + ...addedSection('locale/zh.json', ['{ "title": "面板" }']), + ], + schema: [ + ...addedSection('package.json', ['{ "name": "demo" }']), + ...addedSection('docs/schema.json', ['{ "type": "object" }']), + ], +} + +/** + * Pull request 7 with review layers: `src/panel.ts` and `docs/panel.md` are implementation, `package.json` is + * interface, and the spec, the locale dictionary, and `docs/schema.json`, which `.gitattributes` marks generated, are tests. + */ +const REVIEW_DIFF = [PR7_DIFF.trimEnd(), ...REVIEW_SECTIONS.test, ...REVIEW_SECTIONS.schema, ''].join('\n') + +/** Write the review fixture of pull request 7: its patch, commits, commit ranges, and root `.gitattributes`. */ +async function writeReviewFixture(root: string): Promise { + await writeFile(join(root, 'pr-7.diff'), REVIEW_DIFF) + await writeFile(join(root, 'commits.json'), JSON.stringify(REVIEW_COMMITS)) + await writeFile(join(root, 'compare.json'), JSON.stringify({ + [`${'1'.repeat(40)}...${'2'.repeat(40)}`]: [...REVIEW_SECTIONS.test, ''].join('\n'), + [`${'1'.repeat(40)}...${HEAD7}`]: [...REVIEW_SECTIONS.test, ...REVIEW_SECTIONS.schema, ''].join('\n'), + })) + const contents = JSON.parse(await readFile(join(root, 'contents.json'), 'utf8')) as Record + for (const commit of ['2'.repeat(40), HEAD7]) contents[`.gitattributes@${commit}`] = 'docs/schema.json linguist-generated\n' + await writeFile(join(root, 'contents.json'), JSON.stringify(contents)) +} + async function git(cwd: string, ...args: string[]): Promise { const { stdout } = await run('git', args, { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } }) return stdout.trim() @@ -1604,3 +1670,134 @@ it.skipIf(MODE === 'record').each([120, 4000])('types a review comment beside %i await file.locator('[data-forge-review-thread="pending"]').getByText('检查边界条件', { exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) }) + +/** Open the change request view of pull request 7 in full screen, its tree on the left. */ +async function openReview(page: Page, scaffold: WebScaffold): Promise<{ readonly view: ReturnType; readonly sessionId: SessionId }> { + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + await page.locator('[data-forge-header]').click() + await page.locator(`[data-forge-change="${PULL}"]`).getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click() + await page.getByRole('button', { name: '全屏', exact: true }).click() + const view = page.locator('[data-forge-changes="change"]') + await view.locator('[data-forge-diff-file="src/panel.ts"]').waitFor({ timeout: 20_000 }) + return { view, sessionId } +} + +/** Paths of the diff sections a changes view shows, in order. */ +async function shownPaths(view: ReturnType): Promise<(string | null)[]> { + return await view.locator('[data-forge-diff-file]').evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))) +} + +it.skipIf(MODE === 'record')('reviews a change request by layer, hides test files, marks files viewed, and views chosen commits', async () => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-layers') + await writeReviewFixture(root) + const { view } = await openReview(page, scaffold) + const lens = view.getByRole('radiogroup', { name: '分层透镜', exact: true }) + const tree = view.getByRole('navigation') + await expect.poll(() => lens.getByRole('radio').allTextContents()).toEqual(['全部6', '接口1', '实现2', '测试3']) + // The lens spans the file tree with the filter field's insets, and its divider continues the tree's border. + const [lensBox, fieldBox, cellBox, treeBox] = await Promise.all([ + lens.boundingBox(), tree.locator('input').locator('xpath=..').boundingBox(), lens.locator('xpath=..').boundingBox(), tree.locator('xpath=..').boundingBox(), + ]) + if (lensBox === null || fieldBox === null || cellBox === null || treeBox === null) throw new Error('Lens and tree must be visible') + expect([lensBox.x, lensBox.x + lensBox.width]).toEqual([fieldBox.x, fieldBox.x + fieldBox.width]) + expect(cellBox.x + cellBox.width).toBe(treeBox.x + treeBox.width) + + await lens.getByRole('radio', { name: /^接口/ }).click() + await expect.poll(() => shownPaths(view)).toEqual(['package.json']) + await lens.getByRole('radio', { name: /^测试/ }).click() + await expect.poll(() => shownPaths(view)).toEqual(['docs/schema.json', 'locale/zh.json', 'src/panel.spec.ts']) + await view.getByRole('switch', { name: '隐藏测试文件', exact: true }).click() + await expect.poll(() => shownPaths(view)).toEqual(['docs/panel.md', 'src/panel.ts', 'package.json']) + expect(await tree.getByRole('button', { name: 'src/panel.spec.ts', exact: true }).count()).toBe(0) + expect(await lens.getByRole('radio', { name: /^测试/ }).isDisabled()).toBe(true) + + const progress = view.getByRole('progressbar', { name: '已审阅的文件', exact: true }) + await expect.poll(() => progress.getAttribute('aria-valuetext')).toBe('已审阅 0 / 3') + const docs = view.locator('[data-forge-diff-file="docs/panel.md"]') + await docs.getByRole('checkbox', { name: '将 panel.md 标记为已审阅', exact: true }).check() + await expect.poll(() => docs.getAttribute('data-expanded')).toBe(null) + await expect.poll(() => progress.getAttribute('aria-valuetext')).toBe('已审阅 1 / 3') + const panel = view.locator('[data-forge-diff-file="src/panel.ts"]') + await panel.getByText('export const n2 = \'two\'', { exact: true }).first().click() + await page.keyboard.press('v') + await expect.poll(() => panel.getAttribute('data-viewed')).toBe('viewed') + await expect.poll(() => progress.getAttribute('aria-valuetext')).toBe('已审阅 2 / 3') + await compareOrRefreshGolden(join(EXPECTED, 'review-layers.expected.md'), + await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE) + + await view.getByRole('switch', { name: '隐藏测试文件', exact: true }).click() + const commits = view.getByRole('button', { name: '查看的提交:全部提交', exact: true }) + await commits.click() + const menu = page.locator('[data-forge-commit-menu]') + await expect.poll(() => menu.getByRole('menuitem').evaluateAll(rows => rows.map(row => row.querySelector('[data-forge-commit]')?.getAttribute('data-forge-commit') ?? 'all'))) + .toEqual(['all', '1'.repeat(40), '2'.repeat(40), HEAD7]) + await menu.locator(`[data-forge-commit="${'2'.repeat(40)}"]`).click() + await expect.poll(() => shownPaths(view), { timeout: 20_000 }).toEqual(['locale/zh.json', 'src/panel.spec.ts']) + expect(await view.getByRole('button', { name: '提交评审', exact: true }).isDisabled()).toBe(true) + await view.getByRole('button', { name: '查看的提交:2222222', exact: true }).click() + await page.locator(`[data-forge-commit="${HEAD7}"]`).click({ modifiers: ['Shift'] }) + await view.getByRole('button', { name: `查看的提交:2222222…${HEAD7.slice(0, 7)}`, exact: true }).waitFor() + await expect.poll(() => shownPaths(view), { timeout: 20_000 }).toEqual(['docs/schema.json', 'locale/zh.json', 'src/panel.spec.ts', 'package.json']) + await expect.poll(() => lens.getByRole('radio').allTextContents()).toEqual(['全部4', '接口1', '实现0', '测试3']) + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as string[]) + expect(calls.filter(args => args[0] === 'api' && args[4] === 'Accept: application/vnd.github.diff').map(args => args[5])).toEqual([ + `repos/octo/demo/compare/${'1'.repeat(40)}...${'2'.repeat(40)}`, `repos/octo/demo/compare/${'1'.repeat(40)}...${HEAD7}`, + ]) + await view.getByRole('button', { name: `查看的提交:2222222…${HEAD7.slice(0, 7)}`, exact: true }).click() + await page.locator('[data-forge-commit-menu]').getByRole('menuitem', { name: /^全部提交/ }).click() + await expect.poll(() => shownPaths(view), { timeout: 20_000 }).toHaveLength(6) + await expect.poll(() => docs.getAttribute('data-viewed')).toBe('viewed') + expect(tripwire.pageErrors).toEqual([]) +}) + +it.skipIf(MODE === 'record')('keeps local viewed marks across reloads, clears a mark when the file changes again, and views branch commits', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-review-local', 'normal', { branch: 'feat' }) + await git(project, 'update-ref', 'refs/remotes/origin/main', 'main') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await mkdir(join(project, 'src')) + for (const [path, text, message] of [ + ['src/app.ts', 'export const app = 1\n', 'Add the app'], + ['src/app.spec.ts', 'it(\'runs\', () => {})\n', 'Test the app'], + ] as const) { + await writeFile(join(project, path), text) + await git(project, 'add', path) + await git(project, 'commit', '--quiet', '-m', message) + } + await writeFile(join(project, 'notes.md'), 'note\n') + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.getByRole('button', { name: '打开右侧边栏', exact: true }).click() + await page.locator('[data-sidebar-right-guide]').getByRole('button', { name: /^代码审查和变更/ }).click() + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + const card = page.locator('[data-forge-local]') + await card.getByRole('button', { name: /^查看本会话的变更:3 个文件/ }).click({ timeout: 20_000 }) + const local = page.locator('[data-forge-changes="local"]') + const app = local.locator('[data-forge-diff-file="src/app.ts"]') + await app.getByRole('checkbox', { name: '将 app.ts 标记为已审阅', exact: true }).check({ timeout: 20_000 }) + await expect.poll(() => app.getAttribute('data-expanded')).toBe(null) + + await page.reload() + await page.locator('[data-forge-changes="local"] [data-forge-diff-file="src/app.ts"]').waitFor({ timeout: 20_000 }) + await expect.poll(() => app.getAttribute('data-viewed')).toBe('viewed') + expect(await app.getAttribute('data-expanded')).toBe(null) + await writeFile(join(project, 'src', 'app.ts'), 'export const app = 2\n') + await local.getByRole('button', { name: '刷新变更', exact: true }).click() + await expect.poll(() => app.getAttribute('data-viewed'), { timeout: 20_000 }).toBe('changed') + await app.getByText('有新改动', { exact: true }).waitFor() + await expect.poll(() => app.getAttribute('data-expanded')).toBe('') + expect(await app.getByRole('checkbox', { name: '将 app.ts 标记为已审阅', exact: true }).isChecked()).toBe(false) + + await local.getByRole('button', { name: '查看的提交:全部提交', exact: true }).click() + const menu = page.locator('[data-forge-commit-menu]') + await menu.getByRole('menuitem', { name: /Test the app/ }).click() + await expect.poll(() => shownPaths(local), { timeout: 20_000 }).toEqual(['src/app.spec.ts']) + // Without the file tree beside the diff, the toolbar offers the lens as a menu. + await local.getByRole('button', { name: '分层透镜:全部', exact: true }).click() + await expect.poll(() => page.getByRole('menu').getByRole('menuitem').allTextContents()).toEqual(['全部1', '接口0', '实现0', '测试1']) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7562c8bf96..eb88c30e25 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1049,7 +1049,7 @@ export interface CotTranslationPreferences { ## `@deepseek-ai/dsh-experimental-client-ui-forge` - `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` -- `source`: [`packages/experimental/client-ui-forge/src/types.ts:345`](../packages/experimental/client-ui-forge/src/types.ts) +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:373`](../packages/experimental/client-ui-forge/src/types.ts) ```ts config-catalog /** Host settings of the forge panel Remote. */ @@ -1077,6 +1077,15 @@ export interface Config { maxUntrackedFiles?: number /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ maxCachedDiffBytes?: number + /** Most commits of the `branch` scope listed, oldest first. */ + maxCommits?: number + /** + * Globs of tests, snapshots, locale dictionaries, and lockfiles, matched against repository-relative paths with + * dot files included; their files form the `test` layer, which the changes view can hide. + */ + testPaths?: string[] + /** Globs of package manifests, configuration, and type declarations, matched like `testPaths`; their files form the `interface` layer. */ + interfacePaths?: string[] } ``` @@ -1142,7 +1151,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:232`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:266`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1178,6 +1187,8 @@ export interface Config { timeoutMs?: number /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ maxOutputBytes?: number + /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ + maxCommits?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d75b6e523..3188fdb42d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1051,7 +1051,7 @@ export interface CotTranslationPreferences { ## `@deepseek-ai/dsh-experimental-client-ui-forge` - `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` -- `source`: [`packages/experimental/client-ui-forge/src/types.ts:345`](../packages/experimental/client-ui-forge/src/types.ts) +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:373`](../packages/experimental/client-ui-forge/src/types.ts) ```ts config-catalog /** Host settings of the forge panel Remote. */ @@ -1079,6 +1079,15 @@ export interface Config { maxUntrackedFiles?: number /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ maxCachedDiffBytes?: number + /** Most commits of the `branch` scope listed, oldest first. */ + maxCommits?: number + /** + * Globs of tests, snapshots, locale dictionaries, and lockfiles, matched against repository-relative paths with + * dot files included; their files form the `test` layer, which the changes view can hide. + */ + testPaths?: string[] + /** Globs of package manifests, configuration, and type declarations, matched like `testPaths`; their files form the `interface` layer. */ + interfacePaths?: string[] } ``` @@ -1144,7 +1153,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:232`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:266`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1180,6 +1189,8 @@ export interface Config { timeoutMs?: number /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ maxOutputBytes?: number + /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ + maxCommits?: number } ``` diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 3f4994aa39..5c531ea679 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -6,8 +6,8 @@ en: 3990a17e12c66a23 zh: 4170c33e49248f8f /forges/providers-and-change-requests: - en: 4ec7d1892b4067db - zh: 6bb70d21ae3d7a89 + en: e5d807b29f553751 + zh: e0b25d9e964f988e /forges/status-polling: en: 26ccfa2e342e3e67 zh: fe6cc2a622757c48 @@ -18,5 +18,5 @@ en: 13dfd36f2166bba0 zh: a369434f52f04d57 /forges/panel: - en: dbc28e731f357edf - zh: a1977fafc0651427 + en: 5fcac3740f59ffc0 + zh: a95a881cd06a59d8 diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index f36141ea1b..4f4febadfe 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -74,9 +74,49 @@ interface ForgeProvider { fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ fileUrl(ref: ChangeRequestRef, commit: string, path: string): string + /** + * List the commits of a change request at its current head. A provider implements `commits` and `compare` + * together; omitting either declares that the change request can be read only as a whole. + */ + commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** + * Read the changes from commit `base` to commit `head` in the change request's repository, as one unified patch + * with `a/` and `b/` path prefixes. Both are full object names; `head` descends from `base`. + */ + compare?(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise } ``` +```ts type-equiv +/** One commit of a change request. */ +interface ForgeCommit { + /** Full commit object name. */ + readonly sha: string + /** Full object name of the first parent, or null for a root commit. */ + readonly parent: string | null + /** First line of the commit message. */ + readonly title: string + /** Author name or login as the host reports it, or null when it reports none. */ + readonly author: string | null + /** ISO 8601 time the commit was authored. */ + readonly authoredAt: string +} +``` + +```ts type-equiv +/** The commits of a change request at one head commit. */ +interface ForgeCommitList { + /** Head commit the list was read at. */ + readonly headSha: string + /** Commits oldest first, ending with the head commit unless `truncated`. */ + readonly commits: readonly ForgeCommit[] + /** Whether the change request has more commits than the provider lists; the listed ones are the oldest. */ + readonly truncated: boolean +} +``` + +`ctx.forge.commits` and `ctx.forge.compare` delegate to a provider that implements both; otherwise `commits` resolves to undefined and `compare` to an `unavailable` failure, so the forge panel offers its commit picker only where the provider reads commits. + ```ts type-equiv /** Whether a provider can reach one host with the current credentials. */ interface ForgeReadiness { @@ -568,7 +608,20 @@ type ForgeChangeRequestStatesFrame = | { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] } ``` -The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`; both return files that the Host parsed into `ForgeDiffFile` hunks. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point. +The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`, or the `ForgeCommitRange` it names through the provider's `compare`; both return files that the Host parsed into `ForgeDiffFile` hunks, each with its review layer and change digest. `localCommits` and `changeRequestCommits` list the commits a range can name, the latter as null when the provider does not read commits. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point. + +```ts type-equiv +/** + * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order + * the commit list shows them, oldest first; the view shows the changes from the first commit's first parent to the last. + */ +interface ForgeCommitRange { + /** First, oldest commit shown. */ + readonly from: string + /** Last, newest commit shown; equal to `from` for one commit. */ + readonly to: string +} +``` ```ts type-equiv /** Which local changes a diff covers; each reads the Session's checkout through Git. */ @@ -576,9 +629,10 @@ type ForgeLocalScope = /** * The working tree and untracked files against a fork point: the merge base of HEAD with the * `base` ref, or while `base` is null, the base commit of the worktree the Session created, - * else the merge base of HEAD with the default branch of the code host remote. + * else the merge base of HEAD with the default branch of the code host remote. With `commits`, only the changes + * of those commits between the fork point and HEAD, without uncommitted or untracked files. */ - | { readonly kind: 'branch'; readonly base: string | null } + | { readonly kind: 'branch'; readonly base: string | null; readonly commits: ForgeCommitRange | null } /** The working tree and untracked files against HEAD. */ | { readonly kind: 'uncommitted' } /** The working tree and untracked files against the index. */ @@ -640,6 +694,14 @@ interface ForgeDiffHunk { type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed' ``` +```ts type-equiv +/** + * Review layer of a changed file by its path: `test` for a path matching `testPaths` or one the repository's root + * `.gitattributes` marks `linguist-generated`, else `interface` for a path matching `interfacePaths`, else `implementation`. + */ +type ForgeFileLayer = 'interface' | 'implementation' | 'test' +``` + ```ts type-equiv /** One changed file of a diff. */ interface ForgeDiffFile { @@ -661,6 +723,13 @@ interface ForgeDiffFile { readonly deletions: number /** Hunks in file order; none for a binary file or a change of mode or name only. */ readonly hunks: readonly ForgeDiffHunk[] + /** Review layer the path falls in. */ + readonly layer: ForgeFileLayer + /** + * Digest of the file's change: its paths, status, and hunks, plus the blob names of a binary change, or the content + * of an untracked binary file. Equal digests describe the same change; reading the same comparison again keeps it. + */ + readonly digest: string } ``` @@ -677,22 +746,27 @@ interface ForgeLocalDiff { ``` ```ts type-equiv -/** Changes of a change request as its code host computes them. */ +/** Changes of a change request, or of consecutive commits of it, as its code host computes them. */ interface ForgeChangeRequestDiff { - /** Head commit the diff describes. */ + /** Commit whose new side the diff describes: the head, or the last chosen commit. */ readonly headSha: string /** Changed files in the host's order. */ readonly files: readonly ForgeDiffFile[] /** * Commits the checkout's branch has beyond the head when that branch is the change request's head - * branch and the head commit is present locally; null otherwise. + * branch and the head commit is present locally; null otherwise and for chosen commits. */ readonly localAhead: number | null - /** Web page of each changed file at the head commit, by path. */ + /** Web page of each changed file at `headSha`, by path. */ readonly fileUrls: Readonly> } ``` +```ts type-equiv +/** Commits of the `branch` scope, which the changes view can show one or several of. */ +type ForgeLocalCommits = ForgeCommitList +``` + ```ts type-equiv /** One file the `branch` scope changes, without its hunks. */ interface ForgeChangedFile { @@ -822,6 +896,27 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * List the commits of a change request at its current head. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the read. + * @returns the commits, why the host did not provide them, or undefined when the provider does not read commits. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise + +/** + * Read the changes between two commits of a change request's repository. + * @param ref - change request served by a registered provider. + * @param base - full object name of the commit compared from. + * @param head - full object name of a commit that descends from `base`. + * @param signal - cancellation of the read. + * @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it; a provider that + * does not read commits returns `unavailable`. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async compare(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise + /** * Publish one comment or a completed review, then refresh the watched status. * @param ref - change request served by a registered provider. @@ -975,21 +1070,36 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @returns the files, the base of the `branch` scope, and the untracked files left out. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch * chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without - * a fork point; `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before + * a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; + * `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before * any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise +/** + * List the commits of the `branch` scope of the Session's current checkout, which the changes view can show one or + * several of instead of the whole scope. + * @param session - Session whose checkout is read. + * @param base - the base ref of the `branch` scope, or null for its automatic fork point. + * @param signal - request cancellation. + * @returns at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not + * exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`. + */ +@Remote async localCommits(session: Session, base: string | null, signal: AbortSignal): Promise + /** * Read the new side of one changed file in a scope, which supplies the - * unchanged lines between its hunks: the index for `staged`, else the working tree. + * unchanged lines between its hunks: the index for `staged`, the last chosen commit for chosen commits of + * the `branch` scope, else the working tree. * @param session - Session whose checkout is read. * @param scope - the scope the diff was read in. * @param path - path relative to the checkout's top-level directory, as the diff names it. * @param signal - request cancellation. * @returns the text. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`; - * an error for a path outside the checkout, binary content, or a failed read. + * `forgePanel/commits-missing` for a chosen commit that is not a full object name; an error for a path outside + * the checkout, binary content, or a failed read. */ @Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise @@ -1006,19 +1116,37 @@ Serves the forge panel. Following a Session inspects the checkout of its current @Remote async changeRequestChecks(session: Session, url: string, headSha: string, signal: AbortSignal): Promise /** - * Read the changes of a change request of a Session as its code host - * computes them, at the head commit of its latest status. A patch already - * read at the same head commit and base branch is reused from memory, for - * any Session, until `maxCachedDiffBytes` evicts it. + * List the commits of a change request of a Session at its current head, which the changes view can show one or + * several of instead of the whole change request. * @param session - Session the change request is bound to. * @param url - binding URL of the change request. * @param signal - request cancellation. - * @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head. + * @returns the commits oldest first with the head they were read at, or null when the provider does not read commits. * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; - * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; - * `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise. + * `forgePanel/diff-failed` when the host does not list them. */ -@Remote async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise +@Remote async changeRequestCommits(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Read the changes of a change request of a Session as its code host + * computes them, with each file's layer following the root `.gitattributes` + * of the commit the diff ends at. Without `commits` the diff covers the change + * request at the head commit of its latest status; a patch already read at the + * same head commit and base branch is reused from memory with its attributes, for + * any Session, until `maxCachedDiffBytes` evicts it. With `commits`, it covers those + * commits, from the first one's first parent to the last, and is read every time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param commits - consecutive commits of the change request, or null for all of it. + * @param signal - request cancellation. + * @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order; + * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; + * `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host + * does not provide the commits, the diff, or an existing `.gitattributes` otherwise. + */ +@Remote async changeRequestDiff( session: Session, url: string, commits: ForgeCommitRange | null, signal: AbortSignal, ): Promise /** * Read one file of a change request's repository at a commit, which diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 61c53fbd6c..e731e65c23 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -74,9 +74,49 @@ interface ForgeProvider { fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ fileUrl(ref: ChangeRequestRef, commit: string, path: string): string + /** + * List the commits of a change request at its current head. A provider implements `commits` and `compare` + * together; omitting either declares that the change request can be read only as a whole. + */ + commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** + * Read the changes from commit `base` to commit `head` in the change request's repository, as one unified patch + * with `a/` and `b/` path prefixes. Both are full object names; `head` descends from `base`. + */ + compare?(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise } ``` +```ts type-equiv +/** One commit of a change request. */ +interface ForgeCommit { + /** Full commit object name. */ + readonly sha: string + /** Full object name of the first parent, or null for a root commit. */ + readonly parent: string | null + /** First line of the commit message. */ + readonly title: string + /** Author name or login as the host reports it, or null when it reports none. */ + readonly author: string | null + /** ISO 8601 time the commit was authored. */ + readonly authoredAt: string +} +``` + +```ts type-equiv +/** The commits of a change request at one head commit. */ +interface ForgeCommitList { + /** Head commit the list was read at. */ + readonly headSha: string + /** Commits oldest first, ending with the head commit unless `truncated`. */ + readonly commits: readonly ForgeCommit[] + /** Whether the change request has more commits than the provider lists; the listed ones are the oldest. */ + readonly truncated: boolean +} +``` + +`ctx.forge.commits` 与 `ctx.forge.compare` 委托给同时实现两者的提供方;否则 `commits` 返回 undefined,`compare` 返回 `unavailable` 失败,因此 forge 面板只在提供方能读取提交时提供提交选择器。 + ```ts type-equiv /** Whether a provider can reach one host with the current credentials. */ interface ForgeReadiness { @@ -568,7 +608,20 @@ type ForgeChangeRequestStatesFrame = | { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] } ``` -变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。 +变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更,或经提供方的 `compare` 读取它指定的 `ForgeCommitRange`;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk,并带有分层与改动摘要。`localCommits` 与 `changeRequestCommits` 列出范围可以指定的提交,提供方不读取提交时后者返回 null。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。 + +```ts type-equiv +/** + * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order + * the commit list shows them, oldest first; the view shows the changes from the first commit's first parent to the last. + */ +interface ForgeCommitRange { + /** First, oldest commit shown. */ + readonly from: string + /** Last, newest commit shown; equal to `from` for one commit. */ + readonly to: string +} +``` ```ts type-equiv /** Which local changes a diff covers; each reads the Session's checkout through Git. */ @@ -576,9 +629,10 @@ type ForgeLocalScope = /** * The working tree and untracked files against a fork point: the merge base of HEAD with the * `base` ref, or while `base` is null, the base commit of the worktree the Session created, - * else the merge base of HEAD with the default branch of the code host remote. + * else the merge base of HEAD with the default branch of the code host remote. With `commits`, only the changes + * of those commits between the fork point and HEAD, without uncommitted or untracked files. */ - | { readonly kind: 'branch'; readonly base: string | null } + | { readonly kind: 'branch'; readonly base: string | null; readonly commits: ForgeCommitRange | null } /** The working tree and untracked files against HEAD. */ | { readonly kind: 'uncommitted' } /** The working tree and untracked files against the index. */ @@ -640,6 +694,14 @@ interface ForgeDiffHunk { type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed' ``` +```ts type-equiv +/** + * Review layer of a changed file by its path: `test` for a path matching `testPaths` or one the repository's root + * `.gitattributes` marks `linguist-generated`, else `interface` for a path matching `interfacePaths`, else `implementation`. + */ +type ForgeFileLayer = 'interface' | 'implementation' | 'test' +``` + ```ts type-equiv /** One changed file of a diff. */ interface ForgeDiffFile { @@ -661,6 +723,13 @@ interface ForgeDiffFile { readonly deletions: number /** Hunks in file order; none for a binary file or a change of mode or name only. */ readonly hunks: readonly ForgeDiffHunk[] + /** Review layer the path falls in. */ + readonly layer: ForgeFileLayer + /** + * Digest of the file's change: its paths, status, and hunks, plus the blob names of a binary change, or the content + * of an untracked binary file. Equal digests describe the same change; reading the same comparison again keeps it. + */ + readonly digest: string } ``` @@ -677,22 +746,27 @@ interface ForgeLocalDiff { ``` ```ts type-equiv -/** Changes of a change request as its code host computes them. */ +/** Changes of a change request, or of consecutive commits of it, as its code host computes them. */ interface ForgeChangeRequestDiff { - /** Head commit the diff describes. */ + /** Commit whose new side the diff describes: the head, or the last chosen commit. */ readonly headSha: string /** Changed files in the host's order. */ readonly files: readonly ForgeDiffFile[] /** * Commits the checkout's branch has beyond the head when that branch is the change request's head - * branch and the head commit is present locally; null otherwise. + * branch and the head commit is present locally; null otherwise and for chosen commits. */ readonly localAhead: number | null - /** Web page of each changed file at the head commit, by path. */ + /** Web page of each changed file at `headSha`, by path. */ readonly fileUrls: Readonly> } ``` +```ts type-equiv +/** Commits of the `branch` scope, which the changes view can show one or several of. */ +type ForgeLocalCommits = ForgeCommitList +``` + ```ts type-equiv /** One file the `branch` scope changes, without its hunks. */ interface ForgeChangedFile { @@ -822,6 +896,27 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * List the commits of a change request at its current head. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the read. + * @returns the commits, why the host did not provide them, or undefined when the provider does not read commits. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise + +/** + * Read the changes between two commits of a change request's repository. + * @param ref - change request served by a registered provider. + * @param base - full object name of the commit compared from. + * @param head - full object name of a commit that descends from `base`. + * @param signal - cancellation of the read. + * @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it; a provider that + * does not read commits returns `unavailable`. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async compare(ref: ChangeRequestRef, base: string, head: string, signal: AbortSignal): Promise + /** * Publish one comment or a completed review, then refresh the watched status. * @param ref - change request served by a registered provider. @@ -975,21 +1070,36 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @returns the files, the base of the `branch` scope, and the untracked files left out. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch * chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without - * a fork point; `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before + * a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; + * `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before * any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise +/** + * List the commits of the `branch` scope of the Session's current checkout, which the changes view can show one or + * several of instead of the whole scope. + * @param session - Session whose checkout is read. + * @param base - the base ref of the `branch` scope, or null for its automatic fork point. + * @param signal - request cancellation. + * @returns at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not + * exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`. + */ +@Remote async localCommits(session: Session, base: string | null, signal: AbortSignal): Promise + /** * Read the new side of one changed file in a scope, which supplies the - * unchanged lines between its hunks: the index for `staged`, else the working tree. + * unchanged lines between its hunks: the index for `staged`, the last chosen commit for chosen commits of + * the `branch` scope, else the working tree. * @param session - Session whose checkout is read. * @param scope - the scope the diff was read in. * @param path - path relative to the checkout's top-level directory, as the diff names it. * @param signal - request cancellation. * @returns the text. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`; - * an error for a path outside the checkout, binary content, or a failed read. + * `forgePanel/commits-missing` for a chosen commit that is not a full object name; an error for a path outside + * the checkout, binary content, or a failed read. */ @Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise @@ -1006,19 +1116,37 @@ Serves the forge panel. Following a Session inspects the checkout of its current @Remote async changeRequestChecks(session: Session, url: string, headSha: string, signal: AbortSignal): Promise /** - * Read the changes of a change request of a Session as its code host - * computes them, at the head commit of its latest status. A patch already - * read at the same head commit and base branch is reused from memory, for - * any Session, until `maxCachedDiffBytes` evicts it. + * List the commits of a change request of a Session at its current head, which the changes view can show one or + * several of instead of the whole change request. * @param session - Session the change request is bound to. * @param url - binding URL of the change request. * @param signal - request cancellation. - * @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head. + * @returns the commits oldest first with the head they were read at, or null when the provider does not read commits. * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; - * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; - * `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise. + * `forgePanel/diff-failed` when the host does not list them. */ -@Remote async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise +@Remote async changeRequestCommits(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Read the changes of a change request of a Session as its code host + * computes them, with each file's layer following the root `.gitattributes` + * of the commit the diff ends at. Without `commits` the diff covers the change + * request at the head commit of its latest status; a patch already read at the + * same head commit and base branch is reused from memory with its attributes, for + * any Session, until `maxCachedDiffBytes` evicts it. With `commits`, it covers those + * commits, from the first one's first parent to the last, and is read every time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param commits - consecutive commits of the change request, or null for all of it. + * @param signal - request cancellation. + * @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order; + * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; + * `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host + * does not provide the commits, the diff, or an existing `.gitattributes` otherwise. + */ +@Remote async changeRequestDiff( session: Session, url: string, commits: ForgeCommitRange | null, signal: AbortSignal, ): Promise /** * Read one file of a change request's repository at a commit, which diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 203cd3e907..445e3f73db 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,23 +15,23 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 1101418b5621ba9d - zh: 4d66052d4708d02c + en: dcde9f0c7c82ab08 + zh: 77a570133b45dbd9 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 0c2dd86b5f6b489d - zh: 2f0fce0affa48f59 + en: 1c8c6cfa6ad4b906 + zh: 15a0beb677f6e0fd /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b /deepseek-ai-dsh-experimental-client-ui-forge/model-experience: - en: d62a9841cc34dfdd - zh: 5aa4ffb331c5bb93 + en: 10101427d1e0589b + zh: c981cec12f64eb75 /deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect: en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 0f795ca1c607af01 - zh: a05ac28fb2313d6e + en: 51f6e22d3f2b7d03 + zh: 252f1ba8b2941b60 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 29afc7d4e6..a24de40f12 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -40,11 +40,13 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Local changes card** — whenever the `branch` scope changes files, independently of bound change requests, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, which is always the default base below, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered when the code host repository is recognized and no open change request (including drafts) has the checked-out branch as its head, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. +- **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. A file is in Tests when its path matches `testPaths` or the repository's root `.gitattributes` marks it `linguist-generated`, else in Interface when its path matches `interfacePaths`, such as package manifests, configuration, and type declarations, else in Implementation. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first; a click shows one commit and a Shift click the commits from the last one clicked; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and a chosen commit that is gone offers Show all commits. + The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. The diff follows the file tree’s depth-first order: directories before files, with each group sorted by displayed name in case-sensitive UTF-16 code-unit order. Compacted directory chains are compared as one complete name. Filtering or folding the tree does not reorder the diff. Click the diff content to focus it. Left and Right move to the previous and next file in diff order and expand it; navigation stops at the first and last file. Up and Down keep their normal scrolling behavior. After manual scrolling, file navigation starts from the file at the top of the viewport. Text editing, non-collapsed text selections, modified keys, menus, and file-tree controls keep their own keyboard behavior. -`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). All files opens a searchable directory tree with the selected file scrolled to its middle. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. @@ -67,11 +69,11 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, and hunks, plus the blob names of a binary change or the content of an untracked binary file, and a layer from picomatch matches of `testPaths` and `interfacePaths`, with dot files included, and from the `linguist-generated` lines of the root `.gitattributes`: the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point with `log --topo-order --reverse`, at most `maxCommits`. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| @@ -82,13 +84,17 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t | [`src/git.ts`](src/git.ts) | Git queries under Session permissions | | [`src/parse.ts`](src/parse.ts) | Git output parsing and remote order | | [`src/diff.ts`](src/diff.ts) | Patch parsing, untracked files as additions, and numstat records | -| [`src/local-diff.ts`](src/local-diff.ts) | Fork point, local scopes, new-side text, and the `branch` scope summary | +| [`src/local-diff.ts`](src/local-diff.ts) | Fork point, local scopes, branch commits, new-side text, and the `branch` scope summary | +| [`src/layers.ts`](src/layers.ts) | Review layers from path globs and `linguist-generated` attributes | | [`src/client/mount.ts`](src/client/mount.ts) | Remote mount, slot entries, and action outcome mapping | | [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | Reusable change request card | | [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | Local changes card | | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | Base branch menu of the Branch scope | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view | +| [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress | +| [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker | +| [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | Changed file label: name, then directory | | [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | New Session worktree option | | [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | Worktree creation and removed-worktree card | @@ -111,7 +117,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t ## Model Experience -Indirectly, through the working-directory service and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, and local Branch quotes the base the view compared with. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. +Indirectly, through the working-directory service and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. #### KV Cache effect @@ -130,9 +136,12 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Waiting messages** — while a message waits, the Agent stays idle in maintenance and the composer offers no stop; the card's actions, the Agent's cancellation, or the Remote's stop release it, and callers that wait for the Agent to become idle wait as well. - **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. The [worktree service](../worktree/README.md#use-this-package) validates the recorded target and removes only its missing, unlocked registration. An existing or incomplete directory stays blocked; preserve its files and remove the directory before retrying, or continue in the source project. - **Linked-worktree projects** — the option is unavailable when the project is itself a linked worktree: under workspace-write its Git directory lies outside the writable root, so neither the new branch nor later commits could be written. -- **Changes scopes** — deferred: scopes for one commit and for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. +- **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. +- **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. +- **Review layers** — the Interface layer follows path globs only and keeps whole files; finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. +- **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, 250 for GitHub, and a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. - **Review page** — deferred: a left-navigation page that lists the user's change requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index f45d0c2568..cdeaac4ac9 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -40,11 +40,13 @@ kind: "package-reference" - **本地变更卡片**——`branch` 范围有文件变更时始终显示,不受已关联变更请求影响。变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`,始终是下文的默认基准)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库、且没有以当前检出分支为源分支的打开中变更请求(含草稿)时,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 +- **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。路径匹配 `testPaths` 或被仓库根目录 `.gitattributes` 标为 `linguist-generated` 的文件属于“测试”;否则路径匹配 `interfacePaths`(例如包清单、配置与类型声明)的文件属于“接口”;其余属于“实现”。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在时提供“查看全部提交”。 + 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 diff 按文件树的深度优先顺序排列:同层目录在前、文件在后,各组按显示名称以区分大小写的 UTF-16 码元顺序排序。压缩目录链作为一个完整名称参与比较。筛选或折叠文件树不会改变 diff 的顺序。点击 diff 内容区可将焦点移入。左右方向键按 diff 顺序跳到上一个、下一个文件并展开它;到达首尾时停留。上下方向键保留原有滚动行为。手动滚动后,文件跳转从视口顶部的文件继续。文本编辑、非折叠文本选区、组合键、菜单与文件树控件保留各自的键盘行为。 -`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 “全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 @@ -67,11 +69,11 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态与 hunk 组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并用 picomatch(包含点文件)匹配 `testPaths` 与 `interfacePaths`、读取根目录 `.gitattributes` 中的 `linguist-generated` 行来确定分层:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --topo-order --reverse` 列出分叉点之后的提交,最多 `maxCommits` 个。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| @@ -82,13 +84,17 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/git.ts`](src/git.ts) | 按 Session 权限执行 Git 查询 | | [`src/parse.ts`](src/parse.ts) | Git 输出解析与远端顺序 | | [`src/diff.ts`](src/diff.ts) | 补丁解析、把未跟踪文件表示为新增,以及 numstat 记录 | -| [`src/local-diff.ts`](src/local-diff.ts) | 分叉点、本地范围、新版本文本与 `branch` 范围摘要 | +| [`src/local-diff.ts`](src/local-diff.ts) | 分叉点、本地范围、分支提交、新版本文本与 `branch` 范围摘要 | +| [`src/layers.ts`](src/layers.ts) | 按路径 glob 与 `linguist-generated` 属性分层 | | [`src/client/mount.ts`](src/client/mount.ts) | Remote 挂载、slot 条目与操作结果映射 | | [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | 可复用的变更请求卡片 | | [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | 本地变更卡片 | | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | “分支”范围的基准分支菜单 | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 | +| [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 | +| [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 | +| [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | 变更文件的标签:文件名在前,目录在后 | | [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 | | [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 | @@ -111,7 +117,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi ## 模型体验 -间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。 +间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。 #### KV Cache 影响 @@ -130,9 +136,12 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **等待中的消息**——消息等待期间,Agent 以空闲状态处于维护任务中,输入框不提供停止;只有卡片上的操作、Agent 的取消或 Remote 的停止能放行它,等待 Agent 空闲的调用方也会一并等待。 - **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。[worktree 服务](../worktree/README.zh.md#use-this-package)校验记录的目标,只移除该目标缺失且未锁定的登记。已有或未恢复完成的目录仍会阻止恢复;请先保存其中的文件并移除目录再重试,或在源项目继续。 - **链接 worktree 项目**——项目本身是链接 worktree 时该选项不可用:在 workspace-write 下它的 Git 目录位于可写根之外,新分支与之后的提交都无法写入。 -- **变更范围**——待办:单个提交与上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 +- **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 +- **分层**——“接口”层只按路径 glob 划分并保留整个文件;找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 +- **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为 250 个),每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 - **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 47193f0887..f664c4b2fa 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -91,21 +91,96 @@ } .headerEnd { - flex: none; + display: inline-flex; + flex: 0 1 auto; + align-items: center; + gap: 4px; + min-width: 0; margin-left: auto; } + .toolbar { display: flex; flex: none; - align-items: center; - gap: 4px; + align-items: stretch; box-sizing: border-box; - min-height: 38px; - padding: 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +/* Spans the file tree's width with the tree's search inset, and its divider continues the tree's border. */ +.lensCell { + display: none; + flex: none; + align-items: center; + box-sizing: border-box; + width: var(--forge-tree-width); + padding: 0 8px; + border-inline-end: 0.5px solid var(--dsw-alias-border-l3); +} + +.toolbar[data-tree='right'] .lensCell { + order: 1; + border-inline-start: 0.5px solid var(--dsw-alias-border-l3); + border-inline-end: 0; +} + +.toolbarMain { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 0 4px; + box-sizing: border-box; + min-width: 0; + min-height: 38px; + padding: 5px 8px; +} + +.lensMenu { + display: inline-flex; +} + +@container forge-changes (min-width: 760px) { + .toolbar:not([data-tree='hidden']) .lensCell { + display: flex; + } + + .toolbar:not([data-tree='hidden']) .lensMenu { + display: none; + } +} + +.spacer { + flex: 1; +} + +.progressSlot { + display: inline-flex; +} + +/* Breaks the toolbar's review controls onto a second row where one row is too narrow for them. */ +.rowBreak { + display: none; +} + +@container forge-changes (max-width: 640px) { + .reviewRow { + order: 2; + } + + .rowBreak { + display: block; + flex-basis: 100%; + order: 1; + height: 4px; + } + + .progressSlot { + margin-left: auto; + } +} + .select { display: inline-flex; flex: 0 1 auto; @@ -173,7 +248,6 @@ flex: none; align-items: center; gap: 2px; - margin-left: auto; } .tool { @@ -371,6 +445,62 @@ transform: rotate(-90deg); } +.changedTag { + flex: none; +} + +/* The viewed mark: a native checkbox with its label as one bordered chip, peer to the file menu button. */ +.viewed { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + box-sizing: border-box; + height: 24px; + padding: 0 8px 0 6px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-sm); + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + cursor: pointer; + user-select: none; +} + +.viewed input { + margin: 0; + accent-color: var(--dsw-alias-state-business-primary); + cursor: pointer; +} + +.viewed:has(input:checked) { + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .viewed:hover { + color: var(--dsw-alias-label-primary); + } +} + +.viewed:has(input:focus-visible) { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -1px; +} + +.viewed input:focus-visible { + outline: none; +} + +@container forge-changes (max-width: 520px) { + .viewed { + padding: 0 6px; + } + + .viewedLabel { + display: none; + } +} + .badge { flex: none; max-width: 50%; diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 983c6b58a3..833585a83a 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -7,7 +7,10 @@ * scope summary changes; a read keeps the previous changes on screen until it settles. */ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from 'react' +import { + useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, + type ReactNode, +} from 'react' import clsx from 'clsx' import { Button, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconCompareSplitOutlineRegular, @@ -21,10 +24,10 @@ import type { SidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-rig import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { - ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeDiffFile, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, - ForgePanelState, + ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeCommitRange, ForgeDiffFile, ForgeLocalCommits, ForgeLocalDiff, + ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, } from '../types.ts' -import type { ForgeReviewRequest, ForgeReviewResult } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgeCommitList, ForgeReviewRequest, ForgeReviewResult } from '@deepseek-ai/dsh-experimental-forge/types' import { sameReviewSource, type ReviewQueue } from './review-queue.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' import type { ReviewActions } from './ReviewComposer.tsx' @@ -32,7 +35,10 @@ import { SubmitReview } from './SubmitReview.tsx' import type { ReviewSelection, ReviewSource } from './review.ts' import { BaseMenu } from './BaseMenu.tsx' import { parseChangesAddress, type ChangesSource } from './changes-address.ts' -import type { createChangesStore } from './changes-store.ts' +import type { ChangesLens, createChangesStore } from './changes-store.ts' +import { CommitMenu } from './CommitMenu.tsx' +import { HideTestsSwitch, LensControl, LensMenu, LENSES, ViewedProgress } from './ReviewControls.tsx' +import { viewedStateOf, type ViewedFiles, type ViewedMarks } from './viewed-files.ts' import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx' import { FileTreePane, type FileTreePosition } from './FileTreePane.tsx' import { changedFileTree, filesInTreeOrder } from './file-tree.ts' @@ -68,8 +74,16 @@ export interface ChangesTabInjected { readonly diffBases: (query: string, signal: AbortSignal) => Promise> /** Read the new side of one file in a local scope. */ readonly localText: (scope: ForgeLocalScope, path: string) => Promise> - /** Read the changes of a change request by its binding URL; `signal` aborts the read. */ - readonly changeRequestDiff: (url: string, signal: AbortSignal) => Promise> + /** List the commits of the `branch` scope compared with `base`, or with the automatic fork point for null; `signal` aborts the read. */ + readonly localCommits: (base: string | null, signal: AbortSignal) => Promise> + /** Read the changes of a change request, or of consecutive commits of it, by its binding URL; `signal` aborts the read. */ + readonly changeRequestDiff: ( + url: string, commits: ForgeCommitRange | null, signal: AbortSignal, + ) => Promise> + /** List the commits of a change request, or null when its provider does not read commits; `signal` aborts the read. */ + readonly changeRequestCommits: (url: string, signal: AbortSignal) => Promise> + /** The Session's viewed marks, kept in the browser. */ + readonly viewedFiles: ViewedFiles /** Read one file of a change request at a commit. */ readonly changeRequestText: (url: string, commit: string, path: string) => Promise> /** Open a URL outside the app. */ @@ -106,8 +120,10 @@ type ChangesData = readonly files: readonly ForgeDiffFile[] /** Binding URL of the change request. */ readonly url: string - /** Head commit the files describe, which supplies unchanged lines. */ + /** Commit the files end at, the head or the last chosen commit, which supplies unchanged lines. */ readonly headSha: string + /** Commits the files cover, or null for the whole change request. */ + readonly commits: ForgeCommitRange | null /** Commits the checkout's branch has beyond the head, or null. */ readonly localAhead: number | null /** Web page of each file at the head. */ @@ -148,6 +164,7 @@ const FAILURE: Partial> = { 'forgePanel/no-fork-point': 'diff.noForkPoint', 'forgePanel/diff-too-large': 'diff.tooLarge', 'forgePanel/filter-config-failed': 'diff.filterConfigFailed', + 'forgePanel/commits-missing': 'commits.missing', } /** Failures of a chosen base branch, which offer the automatic fork point instead of a retry. */ @@ -171,6 +188,7 @@ function baseFailureOf(failed: string | null, base: ForgeBaseBranch | null, t: T async function readChanges( source: ChangesSource, scope: ForgeLocalScope, + commits: ForgeCommitRange | null, reads: Pick, signal: AbortSignal, ): Promise> { @@ -180,10 +198,10 @@ async function readChanges( const { files, base, omitted } = result.value return { ok: true, value: { kind: 'local', files, scope, base, omitted } } } - const result = await reads.changeRequestDiff(source.url, signal) + const result = await reads.changeRequestDiff(source.url, commits, signal) if (!result.ok) return result const { files, headSha, localAhead, fileUrls } = result.value - return { ok: true, value: { kind: 'change', files, url: source.url, headSha, localAhead, fileUrls } } + return { ok: true, value: { kind: 'change', files, url: source.url, headSha, commits, localAhead, fileUrls } } } /** The ref that identifies a comparison: the chosen base branch, or null for the automatic fork point and other scopes. */ @@ -191,25 +209,51 @@ function baseRefOf(scope: ForgeLocalScope): string | null { return scope.kind === 'branch' ? scope.base : null } +/** The commits a local scope shows, or null for all of them and for scopes other than `branch`. */ +function commitsOf(scope: ForgeLocalScope): ForgeCommitRange | null { + return scope.kind === 'branch' ? scope.commits : null +} + /** The source review comments on loaded changes record: the change request head, or the local scope with its base. */ function reviewSourceOf(data: ChangesData): ReviewSource { return data.kind === 'local' - ? { kind: 'local', scope: data.scope.kind, base: data.base, baseRef: baseRefOf(data.scope) } - : { kind: 'change', url: data.url, headSha: data.headSha } + ? { kind: 'local', scope: data.scope.kind, base: data.base, baseRef: baseRefOf(data.scope), commits: commitsOf(data.scope) } + : { kind: 'change', url: data.url, headSha: data.headSha, commits: data.commits } } -/** The files that start expanded: every file until those above it change more than {@link OPEN_LINES} lines. */ -function initiallyOpen(files: readonly ForgeDiffFile[]): ReadonlySet { +/** + * Identity of a diff source for viewed marks: the change request or local scope with its base ref and chosen commits. + * A change request's marks stay across head commits, so a file whose change a new push altered reads as changed. + */ +function viewedSourceOf(source: ReviewSource): string { + return JSON.stringify(source.kind === 'local' + ? ['local', source.scope, source.baseRef, source.commits] + : ['change', source.url, source.commits]) +} + +/** + * The files that start expanded: every file not marked viewed until those above it change more than {@link OPEN_LINES} lines. + * @param files - every file in display order. + * @param marks - the viewed marks when the files loaded. + * @returns the paths of the expanded files. + */ +function initiallyOpen(files: readonly ForgeDiffFile[], marks: ViewedMarks): ReadonlySet { const open = new Set() let above = 0 for (const file of files) { if (above > OPEN_LINES) break + if (viewedStateOf(marks, file.path, file.digest) === 'viewed') continue open.add(file.path) above += drawnLines(file) } return open } +/** Whether a file shows under a lens while test files hide or not. */ +function shownBy(file: ForgeDiffFile, lens: ChangesLens, hideTests: boolean): boolean { + return !(hideTests && file.layer === 'test') && (lens === 'all' || file.layer === lens) +} + /** What a rename badge names: the old file name, or the old path when only the directory changed. */ function renamedFrom(previous: string, path: string): string { const name = pathPartsOf(previous).name @@ -231,9 +275,11 @@ function badgeOf(file: ForgeDiffFile, data: ChangesData, t: Translate): string { return parts.join(' · ') } -function ChangeHeader({ url, entry, openLink, reviewCount, reviewDisabled, onReview, t }: { +function ChangeHeader({ url, entry, openLink, commitMenu, reviewCount, reviewDisabled, onReview, t }: { readonly url: string readonly entry: ForgePanelChangeRequest | undefined + /** Commit picker left of the review button. */ + readonly commitMenu: ReactNode readonly reviewCount: number readonly reviewDisabled: boolean readonly onReview: () => void @@ -249,9 +295,12 @@ function ChangeHeader({ url, entry, openLink, reviewCount, reviewDisabled, onRev {snapshot === null ? url : snapshot.title} {number !== null && {t('change.number', { number })}} - + + {commitMenu} + + - )} + {(scope === 'branch' || creatable) && + {scope === 'branch' && commitMenu} + {creatable && } + } ) } @@ -369,8 +421,8 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { * @returns the view body. */ export function ChangesTab({ - useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, changeRequestDiff, - changeRequestText, + useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, localCommits, changeRequestDiff, + changeRequestText, changeRequestCommits, viewedFiles, openLink, copyPath, createChangeRequest, addReviewToInput, reviewQueue, submitReview, report, t, }: ChangesTabProps): ReactNode { const { tab } = useTabInfo() @@ -423,6 +475,7 @@ export function ChangesTab({ const [wanted, setWanted] = useState(null) const [treeState, setTreeState] = useState({ query: '', collapsed: {}, selected: null }) const [treePosition, setTreePosition] = useState('left') + const [treeWidth, setTreeWidth] = useState(0) const treeId = useId() const [chosen, setChosen] = useState>>({}) // Each navigation applies once; its file, if it names one, opens and scrolls into view. @@ -447,7 +500,18 @@ export function ChangesTab({ const state = snapshot?.state ?? null const scope = choices?.scope ?? 'branch' const base = choices?.base ?? null - const localScope = useMemo((): ForgeLocalScope => scope === 'branch' ? { kind: 'branch', base: base?.ref ?? null } : { kind: scope }, [scope, base]) + const chosenCommits = choices?.commits ?? null + const lens = choices?.lens ?? 'all' + const hideTests = choices?.hideTests ?? false + const localScope = useMemo((): ForgeLocalScope => scope === 'branch' + ? { kind: 'branch', base: base?.ref ?? null, commits: chosenCommits } : { kind: scope }, [scope, base, chosenCommits]) + const changeCommits = source.kind === 'change' ? chosenCommits : null + const changeUrlOf = source.kind === 'change' ? source.url : '' + const listChangeCommits = useCallback( + (signal: AbortSignal) => changeRequestCommits(changeUrlOf, signal), [changeRequestCommits, changeUrlOf], + ) + const baseRef = base?.ref ?? null + const listLocalCommits = useCallback((signal: AbortSignal) => localCommits(baseRef, signal), [localCommits, baseRef]) const split = choices?.split ?? true const wrap = choices?.wrap ?? true const entry = source.kind === 'change' ? state?.changeRequests.find(item => item.url === source.url) : undefined @@ -456,7 +520,9 @@ export function ChangesTab({ const pending = entry !== undefined && entry.ref !== null && entry.update === null const head = entry?.snapshot?.headSha ?? (pending ? null : '') const version = state === null ? null : source.kind === 'local' ? `${scope}\n${JSON.stringify(state.local)}` : head - const dataKey = source.kind === 'local' ? JSON.stringify(localScope) : 'change' + // The commits change with the head of a change request, and with the local branch or its base. + const commitsVersion = source.kind === 'local' ? `${baseRef ?? ''}\n${JSON.stringify(state?.local ?? null)}` : head ?? '' + const dataKey = source.kind === 'local' ? JSON.stringify(localScope) : `change\n${JSON.stringify(changeCommits)}` const [loaded, setLoaded] = useState<{ readonly key: string; readonly data: ChangesData } | null>(null) const [failure, setFailure] = useState<{ readonly key: string; readonly code: string } | null>(null) // Names the read the inputs ask for; it is in flight until `finished` matches it. @@ -465,7 +531,7 @@ export function ChangesTab({ useEffect(() => { if (!tab.visible || read === null || read === finished) return const controller = new AbortController() - void readChanges(source, localScope, { localDiff, changeRequestDiff }, controller.signal).then((result) => { + void readChanges(source, localScope, changeCommits, { localDiff, changeRequestDiff }, controller.signal).then((result) => { if (controller.signal.aborted) return setFinished(read) if (result.ok) { @@ -476,9 +542,15 @@ export function ChangesTab({ } }) return () => { controller.abort() } - }, [tab.visible, read, source, localScope, dataKey, localDiff, changeRequestDiff]) + }, [tab.visible, read, source, localScope, changeCommits, dataKey, localDiff, changeRequestDiff]) const data = loaded !== null && loaded.key === dataKey ? loaded.data : null - const files = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) + const allFiles = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) + const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) + const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item, + allFiles.filter(file => shownBy(file, item, hideTests)).length])) as Record, [allFiles, hideTests]) + const shownSource = data === null ? null : reviewSourceOf(data) + const viewedKey = shownSource === null ? '' : viewedSourceOf(shownSource) + const marks = useSyncExternalStore(viewedFiles.subscribe, () => viewedFiles.marksOf(viewedKey)) // The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again. const rereading = read !== null && finished !== read && data !== null const failed = failure !== null && failure.key === dataKey ? failure.code : null @@ -487,17 +559,29 @@ export function ChangesTab({ const latest = useRef(data) useEffect(() => { latest.current = data }, [data]) const [revealed, setRevealed] = useState>(new Set()) - const initial = useMemo(() => initiallyOpen(files), [files]) + // The line budget counts the files not marked viewed when the diff loads, so later marks leave it unchanged. A file stays + // collapsed while it is marked viewed unless the reader expands it; a file whose change differs from its mark expands + // like an unmarked one. + const initial = useMemo(() => initiallyOpen(allFiles, marks), [allFiles]) + const expandedByDefault = (file: ForgeDiffFile): boolean => initial.has(file.path) && viewedStateOf(marks, file.path, file.digest) !== 'viewed' + const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file) const sections = useRef(new Map()) const scrollport = useRef(null) // A short final file cannot align with the viewport top; retain its jump until the reader scrolls away. const jumpPosition = useRef<{ readonly data: ChangesData; readonly top: number } | null>(null) useLayoutEffect(() => { if (!tab.visible || wanted === null || data === null) return + // A file the lens or the test switch hides is shown before the view scrolls to it. + const hidden = allFiles.find(file => file.path === wanted && !shownBy(file, lens, hideTests)) + if (hidden !== undefined) { + if (lens !== 'all') actions.lensed(tab.id, 'all') + if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id) + return + } sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } setWanted(null) - }, [tab.visible, wanted, data]) + }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null // A chosen base branch that is gone or shares no history with HEAD offers the automatic fork point instead. @@ -507,8 +591,10 @@ export function ChangesTab({ const failureText = baseFailure ?? t(message ?? 'diff.error') const changeUrl = source.kind === 'change' ? entry?.snapshot?.url ?? source.url : null const retry = baseFailure === undefined && (message === undefined || failed === 'forgePanel/filter-config-failed') - const automaticBase = baseFailure !== undefined && ( + const automaticBase = baseFailure !== undefined ? ( + ) : failed === 'forgePanel/commits-missing' && ( + ) /** Apply a change to the unchanged lines of `owner`, unless newer changes replaced it. */ @@ -553,7 +639,8 @@ export function ChangesTab({ return list } - const reviewForm = tab.visible && data !== null && reviewOpen && data.kind === 'change' && { setReviewOpen(false) }} check={() => { openLink(data.url) }} reconcile={() => { queue.finish(false); setReviewError(null) }} submit={() => { @@ -563,9 +650,9 @@ export function ChangesTab({ }} t={t} /> const recordActions: ReviewActions = { - source: data !== null ? reviewSourceOf(data) : source.kind === 'local' - ? { kind: 'local', scope, base: null, baseRef: baseRefOf(localScope) } - : { kind: 'change', url: source.url, headSha: '' }, + source: shownSource ?? (source.kind === 'local' + ? { kind: 'local', scope, base: null, baseRef: baseRefOf(localScope), commits: commitsOf(localScope) } + : { kind: 'change', url: source.url, headSha: '', commits: changeCommits }), records: queue, addToInput: addReviewToInput, openLink, } const absentPaths = [...new Set([...reviewState.comments, ...reviewState.records.map(item => item.selection)] @@ -578,26 +665,35 @@ export function ChangesTab({ setChosen(value => ({ ...value, [path]: true })) setWanted(path) } + /** + * The file being read: the file last jumped to while the scroll stays there, else the last file whose section starts + * above the viewport top. + */ + const currentIndex = (viewport: HTMLElement): number => { + const remembered = jumpPosition.current + const index = remembered !== null && remembered.data === data && remembered.top === viewport.scrollTop + ? files.findIndex(file => file.path === treeState.selected) : -1 + if (index >= 0) return index + const top = viewport.getBoundingClientRect().top + return Math.max(0, files.findLastIndex((file) => { + const section = sections.current.get(file.path) + return section !== undefined && section.getBoundingClientRect().top <= top + 1 + })) + } + /** Whether a key press belongs to a field, a comment, a menu, or a text selection rather than to the file list. */ + const typing = (container: HTMLElement, origin: EventTarget): boolean => { + const selection = window.getSelection() + return !(origin instanceof HTMLElement) || !container.contains(origin) + || origin.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-forge-comment], [role="menu"]') !== null + || (selection !== null && !selection.isCollapsed) + } const navigateFiles = (event: KeyboardEvent): void => { if (!tab.visible || event.defaultPrevented || event.nativeEvent.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return const viewport = event.currentTarget - const origin = event.target - const selection = window.getSelection() - if (!(origin instanceof HTMLElement) || !viewport.contains(origin) - || origin.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-forge-comment]') !== null - || (selection !== null && !selection.isCollapsed)) return - const remembered = jumpPosition.current - let index = remembered !== null && remembered.data === data && remembered.top === viewport.scrollTop - ? files.findIndex(file => file.path === treeState.selected) : -1 - if (index < 0) { - const top = viewport.getBoundingClientRect().top - index = Math.max(0, files.findLastIndex((file) => { - const section = sections.current.get(file.path) - return section !== undefined && section.getBoundingClientRect().top <= top + 1 - })) - } + if (typing(viewport, event.target)) return + const index = currentIndex(viewport) event.preventDefault() event.stopPropagation() const next = files[index + (event.key === 'ArrowRight' ? 1 : -1)] @@ -605,6 +701,37 @@ export function ChangesTab({ viewport.focus({ preventScroll: true }) jump(next.path) } + /** Mark a file viewed and collapse it, keeping its header in view, or clear its mark and expand it. */ + const markViewed = (owner: ChangesData, file: ForgeDiffFile, viewed: boolean): void => { + if (!viewedFiles.mark(viewedKey, owner.files, file.path, viewed)) { + report('viewed.saveFailed') + return + } + const section = sections.current.get(file.path) + const viewport = scrollport.current + if (viewed && section !== undefined && viewport !== null + && section.getBoundingClientRect().top < viewport.getBoundingClientRect().top) setWanted(file.path) + // A new mark collapses the file through its default; clearing a mark expands it. + setChosen(({ [file.path]: _choice, ...value }) => viewed ? value : { ...value, [file.path]: true }) + } + /** The shown file whose section holds an element or a text node, if any. */ + const fileAt = (target: EventTarget | null | undefined): ForgeDiffFile | undefined => { + const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null + const path = element?.closest('[data-forge-diff-file]')?.getAttribute('data-forge-diff-file') + return files.find(file => file.path === path) + } + // `V` marks or clears the file being read, from anywhere in the tab except fields, comments, and menus: the file + // holding the focus, else the one holding the text cursor, as after clicking its code, else the file at the top. + const toggleViewed = (event: KeyboardEvent): void => { + const viewport = scrollport.current + if (!tab.visible || event.defaultPrevented || event.nativeEvent.isComposing || event.altKey || event.ctrlKey || event.metaKey + || event.key.toLowerCase() !== 'v' || viewport === null || data === null || typing(event.currentTarget, event.target)) return + const file = fileAt(event.target) ?? fileAt(window.getSelection()?.anchorNode) ?? files[currentIndex(viewport)] + /* v8 ignore next -- the files region renders only while files show, so its position always names one */ + if (file === undefined) return + event.preventDefault() + markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed') + } let body: ReactNode if (data === null) { @@ -635,10 +762,12 @@ export function ChangesTab({ } } else if (data.files.length === 0) { body =
{orphanRecords}
{t('diff.empty')}
+ } else if (files.length === 0) { + body =
{orphanRecords}
{t('lens.empty')}
} else { body = (
{ if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null }}> @@ -654,7 +783,7 @@ export function ChangesTab({ records: queue, openLink, reviewStarted: reviewState.comments.length > 0, onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') }, publishingBlocked: reviewState.uncertain !== null, - ...data.kind === 'local' ? {} : { + ...data.kind === 'local' || data.commits !== null ? {} : { queue: selection => queue.add(selection), publish: selection => selection.source.kind === 'change' ? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, @@ -662,8 +791,8 @@ export function ChangesTab({ : Promise.resolve(t('review.stale')), }, }} - expanded={chosen[path] ?? initial.has(path)} - onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? initial.has(path)) })) }} + expanded={isExpanded(file)} + onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? expandedByDefault(file)) })) }} large={drawnLines(file) > LARGE_FILE_LINES} revealed={revealed.has(path)} onReveal={() => { setRevealed(value => new Set([...value, path])) }} @@ -675,6 +804,8 @@ export function ChangesTab({ badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} actions={fileActions(data, file)} + viewed={viewedStateOf(marks, path, file.digest)} + onViewed={(viewed) => { markViewed(data, file, viewed) }} sectionRef={(element) => { if (element === null) sections.current.delete(path) else sections.current.set(path, element) @@ -694,7 +825,7 @@ export function ChangesTab({ jump, } // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. - const anyExpanded = files.some(file => chosen[file.path] ?? initial.has(file.path)) + const anyExpanded = files.some(isExpanded) const expansionLabel = t(anyExpanded ? 'diff.collapseAll' : 'diff.expandAll') const treeAction = t(treePosition === 'left' ? 'diff.moveFileTreeRight' : treePosition === 'right' ? 'diff.hideFileTree' : 'diff.showFileTreeLeft') let additions = 0 @@ -703,13 +834,21 @@ export function ChangesTab({ additions += file.additions deletions += file.deletions } + const viewedCount = files.filter(file => viewedStateOf(marks, file.path, file.digest) === 'viewed').length + const treeShown = allFiles.length > 0 && treePosition !== 'hidden' + const lensProps = { lens, counts: lensCounts, hideTests, choose: (next: ChangesLens) => { actions.lensed(tab.id, next) }, t } + const commitMenu = source.kind === 'local' && scope !== 'branch' ? null : { actions.committed(tab.id, next) }} t={t} /> + const toolbarStyle: CSSProperties & { '--forge-tree-width': string } = { '--forge-tree-width': `${treeWidth}px` } return ( -
+
{source.kind === 'local' ? { actions.based(tab.id, next) }} createChangeRequest={createChangeRequest} t={t} /> - : { setReviewOpen(true) }} t={t} />} + diffBases={diffBases} choose={(next) => { actions.based(tab.id, next) }} commitMenu={commitMenu} + createChangeRequest={createChangeRequest} t={t} /> + : { setReviewOpen(true) }} t={t} />} {!reviewOpen && reviewState.error !== null && {t(reviewState.error === 'load' ? 'review.loadFailed' : 'review.saveFailed')}} {!reviewOpen && reviewState.uncertain !== null && !reviewBusy && source.kind === 'change' &&
{t('review.unknown')} @@ -718,45 +857,58 @@ export function ChangesTab({
} {!reviewOpen && reviewError !== null && reviewState.uncertain === null && {reviewError}} -
- {tab.visible && source.kind === 'local' && { actions.scoped(tab.id, next) }} t={t} />} - {tab.visible && } - {files.length > 0 && } - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + {allFiles.length > 0 && } +
{data !== null && failed !== null && ( {body} - {files.length > 0 && } + {allFiles.length > 0 && }
) diff --git a/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css b/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css new file mode 100644 index 0000000000..34159de448 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css @@ -0,0 +1,130 @@ +/* Commit picker trigger in the changes header and its menu of commits, oldest first. */ +.anchor { + flex: 0 1 auto; + min-width: 0; +} + +.trigger { + display: inline-flex; + flex: 0 1 auto; + align-items: center; + gap: 4px; + box-sizing: border-box; + min-width: 0; + max-width: 100%; + height: 28px; + padding: 0 6px 0 8px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + white-space: nowrap; + cursor: pointer; +} + +.chosen .triggerName { + font-family: var(--ds-font-family-code); +} + +.trigger[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.trigger:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +.trigger svg { + flex: none; +} + +.triggerName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu { + width: min(400px, calc(100vw - 24px)); +} + +.rows { + max-height: 360px; + overflow: auto; + padding: 4px; + color: var(--dsw-alias-label-primary); + font-size: 12px; +} + +.option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +.allTitle { + flex: 1; + min-width: 0; +} + +.sha { + flex: none; + color: var(--dsw-alias-label-secondary); + font-family: var(--ds-font-family-code); +} + +.title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.check { + display: inline-flex; + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.unchecked { + visibility: hidden; +} + +.label { + padding: 6px 8px 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 15px; +} + +.status { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 8px; + color: var(--dsw-alias-label-secondary); +} + +.note { + padding: 8px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} diff --git a/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx b/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx new file mode 100644 index 0000000000..58e7702b75 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx @@ -0,0 +1,162 @@ +/** + * Commit picker of the changes header: every commit, or one commit, or with Shift a run of consecutive commits of a + * change request or of the local `branch` scope, listed oldest first like the code host's commit list. + */ + +import { useEffect, useRef, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, Menu, MenuItemButton, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeCommitList } from '@deepseek-ai/dsh-experimental-forge/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { ForgeCommitRange } from '../types.ts' +import { formatAgo } from './format.ts' +import type { NS } from './locales.ts' +import css from './CommitMenu.module.css' + +/** Characters of a commit object name the picker shows. */ +const SHORT = 7 + +/** Props of the commit picker. */ +export interface CommitMenuProps extends PropsLocale { + /** Commits shown, or null for all of them. */ + readonly chosen: ForgeCommitRange | null + /** Whether the owning tab is visible; a hidden tab keeps the menu closed. */ + readonly visible: boolean + /** Read the commits; null means the source cannot show single commits. `signal` aborts the read. */ + readonly list: (signal: AbortSignal) => Promise> + /** Changes when the commits may have changed, such as a new head commit; the next opening reads them again. */ + readonly version: string + /** Show consecutive commits, or every commit for null. */ + readonly choose: (commits: ForgeCommitRange | null) => void +} + +/** + * Name the shown commits on the trigger. + * @param chosen - the shown commits, or null for all of them. + * @param t - forge panel copy. + * @returns the label. + */ +export function commitsLabel(chosen: ForgeCommitRange | null, t: PropsLocale['t']): string { + if (chosen === null) return t('commits.all') + if (chosen.from === chosen.to) return chosen.from.slice(0, SHORT) + return t('commits.range', { from: chosen.from.slice(0, SHORT), to: chosen.to.slice(0, SHORT) }) +} + +/** + * Render the trigger and, while open, the commit list. A click shows one commit; a Shift click shows the commits + * from the last commit clicked without Shift to the clicked one. + * @param props - the shown commits, the listing read, the choose action, and copy. + * @returns the trigger with its portalled menu, or nothing when the source cannot show single commits. + */ +export function CommitMenu({ chosen, visible, list, version, choose, t }: CommitMenuProps): ReactNode { + const [open, setOpen] = useState(false) + const [attempt, setAttempt] = useState(0) + const [listing, setListing] = useState<{ readonly version: string; readonly result: RemoteResult } | null>(null) + const shift = useRef(false) + const anchor = useRef(chosen?.from ?? null) + const loaded = useRef(null) + const shown = visible && open + const current = listing?.version === version ? listing.result : null + // The list is read once per version, which tells whether the source has commits to pick, and again on each opening. + useEffect(() => { + if (!visible || (!shown && loaded.current === version)) return + const controller = new AbortController() + void list(controller.signal).then((result) => { + if (controller.signal.aborted) return + loaded.current = version + setListing({ version, result }) + }) + return () => { controller.abort() } + }, [visible, shown, version, attempt, list]) + if (current?.ok === true && current.value === null) return null + const commits = current?.ok === true && current.value !== null ? current.value : null + const pick = (sha: string | null): void => { + setOpen(false) + if (sha === null || commits === null) { + anchor.current = null + if (chosen !== null) choose(null) + return + } + const order = commits.commits.map(commit => commit.sha) + const start = shift.current && anchor.current !== null && order.includes(anchor.current) ? anchor.current : sha + if (!shift.current || start === sha) anchor.current = sha + const [from, to] = order.indexOf(start) <= order.indexOf(sha) ? [start, sha] : [sha, start] + if (from !== chosen?.from || to !== chosen.to) choose({ from, to }) + } + const inChosen = (index: number): boolean => { + if (chosen === null || commits === null) return false + const order = commits.commits.map(commit => commit.sha) + return index >= order.indexOf(chosen.from) && index <= order.indexOf(chosen.to) && order.includes(chosen.from) + } + + let body: ReactNode + if (current === null) { + body =
{t('commits.loading')}
+ } else if (!current.ok || commits === null) { + body = ( +
+ {t('commits.failed')} + +
+ ) + } else { + const now = Date.now() + body = <> + { pick(null) }}> + + {t('commits.all')} + {t('commits.count', { count: commits.commits.length })} + {chosen === null && } + + + {commits.commits.length > 0 &&
{t('commits.shiftHint')}
} + {commits.commits.map((commit, index) => { + const selected = inChosen(index) + return ( + { pick(commit.sha) }}> + + {commit.sha.slice(0, SHORT)} + {commit.title} + {formatAgo(commit.authoredAt, now, t) ?? ''} + + + + ) + })} + {commits.truncated &&
{t('commits.truncated', { count: commits.commits.length })}
} + + } + + const label = commitsLabel(chosen, t) + return ( + { setOpen(false) }} + anchor={( + + + + )} + > + {/* Rows report activation without the event, so the modifier is read while the click or key travels to them. */} +
{ shift.current = event.shiftKey }} + onKeyDownCapture={(event) => { shift.current = event.shiftKey }}> + {body} +
+
+ ) +} diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index eb7e21e6b0..ad46c66132 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -9,7 +9,7 @@ import clsx from 'clsx' import { flushSync } from 'react-dom' import { FileTypeIcon, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconEllipsisOutlineRegular, IconPlusOutlineRegular, - Tooltip, + Tag, Tooltip, languageForPath, Menu, MenuItemButton, useCodeHighlighter, type CodeHighlighter, type HighlightSpan, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -23,6 +23,7 @@ import { ReviewComposer, type ReviewActions } from './ReviewComposer.tsx' import reviewCss from './ReviewComposer.module.css' import { FileLabel } from './FileLabel.tsx' import type { ForgePanelKey, NS } from './locales.ts' +import type { ViewedState } from './viewed-files.ts' import css from './ChangesTab.module.css' type Translate = PropsLocale['t'] @@ -72,6 +73,10 @@ export interface DiffFileProps { readonly tooltip: string /** Rows of the file's menu. */ readonly actions: readonly DiffFileAction[] + /** Whether the reader marked this change viewed, or marked an earlier change of the file. */ + readonly viewed: ViewedState + /** Mark the file viewed, which also collapses it, or clear the mark. */ + readonly onViewed: (viewed: boolean) => void /** Receives the section element, which scrolling to the file targets. */ readonly sectionRef: (element: HTMLElement | null) => void /** Forge panel copy. */ @@ -438,10 +443,11 @@ function FileMenu({ name, actions, t }: { * @returns the file section. */ export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, sectionRef, t } = props + const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) return ( -
+
{expanded && } diff --git a/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx b/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx index b055655814..a4995615f1 100644 --- a/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx +++ b/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx @@ -15,12 +15,14 @@ export type FileTreePosition = 'right' | 'left' | 'hidden' /** * Render a keyboard- and pointer-resizable tree beside the diff. - * @param props - tree rows, pane position, and the toggle's controlled element id. + * @param props - tree rows, pane position, the toggle's controlled element id, and the receiver of the shown width. * @returns the bounded pane and its resize separator. */ -export function FileTreePane({ position, id, ...props }: ChangedFilesProps & { +export function FileTreePane({ position, id, onWidth, ...props }: ChangedFilesProps & { readonly position: FileTreePosition readonly id: string + /** Receives the pane's width in pixels whenever it changes, so the toolbar can align a cell with it. */ + readonly onWidth: (width: number) => void }): ReactNode { const pane = useRef(null) const drag = useRef<{ pointer: number; x: number; width: number } | null>(null) @@ -41,6 +43,7 @@ export function FileTreePane({ position, id, ...props }: ChangedFilesProps & { return () => { observer.disconnect() } }, []) const shown = Math.min(width, maximum) + useLayoutEffect(() => { onWidth(shown) }, [shown, onWidth]) const direction = position === 'left' ? 1 : -1 const resize = (next: number): void => { setWidth(Math.round(Math.max(MIN_WIDTH, Math.min(maximum, next)))) } return - {isExpanded && ( + {!isExpanded && !external && !requiresOpening && childCatalog?.state === 'error' && ( +
+ {t('load.error')} + +
+ )} + {childCatalog !== undefined && isExpanded && (
- {childCatalog === undefined - ? - : ( - - )} +
)} @@ -497,7 +498,7 @@ type CatalogDropdownProps = CatalogDropdownSharedProps & ( function CatalogDropdown(props: CatalogDropdownProps) { const { rootSessionId, currentSessionId, displayTitle, openTitle, variant, - useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t, hasSharedTasks = false, + useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, prepareCatalog, t, hasSharedTasks = false, } = props const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined const projections = useSessions(state => state.projectionsBySession) @@ -507,7 +508,7 @@ function CatalogDropdown(props: CatalogDropdownProps) { return Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => { const projected = snapshot.values.subagentCatalog const state = snapshot.state === 'idle' - ? projected === undefined ? 'loading' : 'ready' + ? projected === undefined ? 'idle' : 'ready' : snapshot.state === 'migration-required' && projected !== undefined ? 'ready' : snapshot.state return [id, { state, @@ -521,6 +522,11 @@ function CatalogDropdown(props: CatalogDropdownProps) { })) }, [projections, summaries, statuses]) const catalog = catalogs[rootSessionId] + const prepareLevel = (sessionId: SessionId): void => { + void prepareCatalog(sessionId).catch((error: unknown) => { + console.error('[ui-subagent] catalog discovery failed:', error) + }) + } const [open, setOpen] = useState(false) const [expanded, setExpanded] = useState>(() => new Set()) const rootRef = useRef(null) @@ -622,7 +628,7 @@ function CatalogDropdown(props: CatalogDropdownProps) { return } setExpanded(current => new Set(current).add(childSessionId)) - refreshProjection(childSessionId) + prepareLevel(childSessionId) } useEffect(() => { @@ -736,6 +742,7 @@ function CatalogDropdown(props: CatalogDropdownProps) { } pinnedRef.current = true if (!open) changeOpen(true) + prepareLevel(rootSessionId) queueMicrotask(() => { menuRef.current?.focus() }) } : () => { @@ -748,6 +755,7 @@ function CatalogDropdown(props: CatalogDropdownProps) { event.preventDefault() if (variant === 'count' && !allowsHoverOpen()) pinnedRef.current = true if (!open) changeOpen(true) + prepareLevel(rootSessionId) queueMicrotask(() => { focusAt(0) }) }} > @@ -841,7 +849,7 @@ export function SubagentCatalogAction(props: SubagentCatalogActionProps) { /** Bind task-board visibility only in root conversations. */ function RootCatalogAction({ sessionId, useProjection, useSessions, useSessionStatus, useFooterAvailable, - openChild, openChildAside, refreshProjection, t, renderSlot, + openChild, openChildAside, refreshProjection, prepareCatalog, t, renderSlot, }: SubagentCatalogActionProps) { const hasSharedTasks = useProjection('agentTeam', team => team?.enabled === true && team.tasks.some(task => task.status !== 'deleted')) @@ -860,6 +868,7 @@ function RootCatalogAction({ openChild={openChild} openChildAside={openChildAside} refreshProjection={refreshProjection} + prepareCatalog={prepareCatalog} t={t} /> ) @@ -872,7 +881,7 @@ function RootCatalogAction({ */ export function SubagentHeaderLineage({ lineageSessionId, displayTitle, openTitle, - useSessions, useSession, useSessionStatus, openChild, openChildAside, refreshProjection, t, + useSessions, useSession, useSessionStatus, openChild, openChildAside, refreshProjection, prepareCatalog, t, }: SubagentHeaderLineageProps) { const address = useSession(session => session.subagent?.address) const parentId = useSessions((state) => { @@ -883,7 +892,7 @@ export function SubagentHeaderLineage({ const summary = state.byId[lineageSessionId] return summary?.origin === 'subagent' ? summary.parentId : undefined }) - const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t } + const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, prepareCatalog, t } // Root sessions carry no breadcrumb; their descendant count lives in the // header actions band (SubagentCatalogAction). if (parentId === undefined) return null diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index a84992246d..b44d4f8e55 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -62,6 +62,19 @@ export function apply(ctx: ClientContext): void { preferNewPane: true, }) }, + async prepareCatalog(parentSessionId: SessionId) { + await ctx.sessions.refreshProjections(parentSessionId) + const parent = ctx.sessions.list.getSnapshot().projectionsBySession[parentSessionId] + if (parent?.state !== 'ready') return + for (const entry of parent.values.subagentCatalog ?? []) { + if (entry.mode === 'external') continue + const state = ctx.sessions.list.getSnapshot() + if (state.byId[entry.id]?.formatStatus === 'migration-required' + || state.projectionsBySession[entry.id]?.state === 'migration-required' + || state.projectionsBySession[entry.id]?.state === 'error') continue + await ctx.sessions.refreshProjections(entry.id) + } + }, refreshProjection(parentSessionId: SessionId) { void ctx.sessions.refreshProjections(parentSessionId) }, diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 27a5b61391..e7edefbb2c 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -27,7 +27,6 @@ export const zh = { 'tokens.total': '{value} tok', 'loading.label': '正在加载子智能体…', 'load.error': '无法加载子智能体', - 'catalog.migrationRequired': '需要迁移', 'retry': '重试', 'mode.external': '外部任务', 'mode.oneShot': '一次性', @@ -78,7 +77,6 @@ export const en: Record = { 'tokens.total': '{value} tok', 'loading.label': 'Loading subagents…', 'load.error': 'Unable to load subagents', - 'catalog.migrationRequired': 'Migration required', 'retry': 'Retry', 'mode.external': 'External task', 'mode.oneShot': 'one-shot', @@ -132,7 +130,6 @@ export const ja = { 'tokens.total': '{value} tok', 'loading.label': 'サブエージェントを読み込み中…', 'load.error': 'サブエージェントを読み込めません', - 'catalog.migrationRequired': '移行が必要です', 'retry': '再試行', 'mode.external': '外部タスク', 'mode.oneShot': '単発', @@ -183,7 +180,6 @@ export const es = { 'tokens.total': 'Tok {value}', 'loading.label': 'Cargando subagentes…', 'load.error': 'No se pueden cargar subagentes', - 'catalog.migrationRequired': 'Migración requerida', 'retry': 'Reintentar', 'mode.external': 'tarea externa', 'mode.oneShot': 'un solo disparo', @@ -234,7 +230,6 @@ export const fr = { 'tokens.total': 'Jeton {value}', 'loading.label': 'Chargement des sous-agents…', 'load.error': 'Impossible de charger les sous-agents', - 'catalog.migrationRequired': 'Migration requise', 'retry': 'Réessayer', 'mode.external': 'Tâche externe', 'mode.oneShot': 'un coup', @@ -285,7 +280,6 @@ export const de = { 'tokens.total': '{value}-Token', 'loading.label': 'Subagenten werden geladen…', 'load.error': 'Subagenten können nicht geladen werden', - 'catalog.migrationRequired': 'Migration erforderlich', 'retry': 'Erneut versuchen', 'mode.external': 'Externe Aufgabe', 'mode.oneShot': 'One-Shot', @@ -336,7 +330,6 @@ export const ru = { 'tokens.total': '{value} ток', 'loading.label': 'Загрузка субагентов…', 'load.error': 'Невозможно загрузить субагенты', - 'catalog.migrationRequired': 'Требуется миграция', 'retry': 'Повторить', 'mode.external': 'Внешняя задача', 'mode.oneShot': 'одноразовый', @@ -387,7 +380,6 @@ export const pt = { 'tokens.total': '{value} valor', 'loading.label': 'Carregando subagentes…', 'load.error': 'Não foi possível carregar subagentes', - 'catalog.migrationRequired': 'Migração necessária', 'retry': 'Tentar novamente', 'mode.external': 'Tarefa externa', 'mode.oneShot': 'tiro único', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index b5006a277b..83c5839e8d 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { - SessionListState, SessionSnapshot, SessionSummary, + SessionListState, SessionSnapshot, SessionSummary, SessionProjectionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -35,7 +35,8 @@ const sid = (id: string) => id as SessionId function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s - const snapshot: SessionListState = { ids: sessions.map(s => s.id), byId, phase: 'ready', projectionsBySession: {} } + const projections: Record = {} + const snapshot: SessionListState = { ids: sessions.map(s => s.id), byId, phase: 'ready', projectionsBySession: projections } const actionCalls: { method: string; args: unknown[] }[] = [] const address: SubagentAddress = { parentSessionId: sid('parent'), @@ -48,6 +49,7 @@ function sessionsWith(sessions: SessionSummary[]) { subscribe: () => () => {}, }, actionCalls, + projections, subagentAddress: (childSessionId: SessionId) => childSessionId === address.childSessionId ? address : undefined, @@ -121,6 +123,40 @@ describe('apply', () => { expect(ctx.slots.entries('conversation.session.header.actions.subagent.footer')).toHaveLength(1) }) + it('reads direct child catalogs sequentially after reading the parent', async () => { + const { ctx, face } = await fullBench([...FAMILY, summary({ id: sid('legacy'), formatStatus: 'migration-required' })]) + onTestFinished(() => ctx.fiber.dispose()) + const entry = ctx.slots.entries('conversation.session.header.lineage') + .find(entry => entry.component === SubagentHeaderLineage)! + const actions = (entry.inject as (id: SessionId) => SubagentCatalogInjected)(sid('parent')) + const projections = face.projections + const held = Promise.withResolvers() + const refresh = vi.spyOn(face, 'refreshProjections').mockImplementation(async (id) => { + if (id === sid('parent')) projections[id] = { state: 'ready', error: null, values: { subagentCatalog: [ + { id: sid('external'), mode: 'external', createdAt: 1 }, + ...['c1', 'c2', 'legacy', 'blocked', 'failed'].map(id => ({ id: sid(id), mode: 'one-shot' as const, createdAt: 1 })), + ] } } + if (id === sid('c1')) { + await held.promise + projections[id] = { state: 'ready', error: null, values: { subagentCatalog: [ + { id: sid('grandchild'), mode: 'one-shot', createdAt: 1 }, + ] } } + } + }) + onTestFinished(() => { refresh.mockRestore() }) + projections[sid('blocked')] = { state: 'migration-required', error: null, values: {} } + projections[sid('failed')] = { state: 'error', error: null, values: {} } + const reading = actions.prepareCatalog(sid('parent')) + await vi.waitFor(() => { expect(refresh.mock.calls).toEqual([[sid('parent')], [sid('c1')]]) }) + held.resolve(undefined) + await reading + expect(refresh.mock.calls).toEqual([[sid('parent')], [sid('c1')], [sid('c2')]]) + await actions.prepareCatalog(sid('c2')) + projections[sid('c2')] = { state: 'ready', error: null, values: {} } + await actions.prepareCatalog(sid('c2')) + expect(refresh.mock.calls.slice(3)).toEqual([[sid('c2')], [sid('c2')]]) + }) + it('registers catalog actions and selects read-only subagent composers from session facts', async () => { const { ctx, face } = await fullBench(FAMILY) const catalogEntry = ctx.slots.entries('conversation.session.header.lineage') diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 43d2255936..ea189bcd8e 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -75,31 +75,44 @@ function props( ) { const catalogs = value === undefined ? nested : { [PARENT]: value, ...nested } const statuses: SessionStatusSnapshot = new Map() + const byId: Record = { + ...Object.fromEntries(Object.values(catalogs).flatMap(catalog => catalog.entries.map(entry => [entry.id, { + id: entry.id, displayTitle: entry.label ?? entry.id, running: entry.activity === 'running', blank: false, updatedAt: 1, retainedBy: {}, projectionValues: { subagentCatalog: [] }, + }]))), + [CHILD]: { + id: CHILD, + title: '正在扫描项目文件', + displayTitle: 'worker', + running: Object.values(catalogs).some(catalog => catalog.entries.some(entry => entry.id === CHILD && entry.activity === 'running')), + retainedBy: {}, + blank: false, + updatedAt: Date.now(), + projectionValues: { subagentCatalog: [] }, + }, + ...summaries, + } const state = { ids: [CHILD], - byId: summaries ?? { - ...Object.fromEntries(Object.values(catalogs).flatMap(catalog => catalog.entries.map(entry => [entry.id, { - id: entry.id, displayTitle: entry.label ?? entry.id, running: entry.activity === 'running', blank: false, updatedAt: 1, retainedBy: {}, - }]))), - [CHILD]: { - id: CHILD, - title: '正在扫描项目文件', - displayTitle: 'worker', - running: Object.values(catalogs).some(catalog => catalog.entries.some(entry => entry.id === CHILD && entry.activity === 'running')), - retainedBy: {}, - blank: false, - updatedAt: Date.now(), - }, - }, + byId, phase: 'ready', - projectionsBySession: Object.fromEntries(Object.entries(catalogs).map(([id, catalog]) => [id, { - state: catalog.state, error: catalog.error, - values: { - ...catalog.team === undefined ? {} : { agentTeam: catalog.team }, - subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })), - }, - }])) , + projectionsBySession: { + ...Object.fromEntries(Object.entries(byId).flatMap(([id, entry]) => entry.projectionValues === undefined ? [] : [[id, { + state: 'idle' as const, error: null, values: entry.projectionValues, + }]])), + ...Object.fromEntries(Object.entries(catalogs).map(([id, catalog]) => [id, { + state: catalog.state, error: catalog.error, + values: { + ...byId[id as SessionId]?.projectionValues, + ...catalog.team === undefined ? {} : { agentTeam: catalog.team }, + subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })), + }, + }])), + }, } satisfies SessionListState + for (const [id, entry] of Object.entries(byId)) { + const projection = state.projectionsBySession[id] + if (projection !== undefined) byId[id as SessionId] = { ...entry, projectionValues: projection.values } + } function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } @@ -142,6 +155,7 @@ function props( openChild: vi.fn(), openChildAside: vi.fn(), refreshProjection: vi.fn(), + prepareCatalog: vi.fn<(id: SessionId) => Promise>().mockResolvedValue(), renderSlot: vi.fn(() => null), lineageSessionId: PARENT, displayTitle: 'Parent title', @@ -528,8 +542,8 @@ describe('SubagentHeaderLineage', () => { expect(screen.getByText('worker')).toBeTruthy() expect(screen.queryByText('正在扫描项目文件')).toBeNull() expect(screen.getByRole('treeitem', { name: /reviewer child-2 · 一次性 · 当前未运行/ })).toBeTruthy() - expect(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })).toBeTruthy() - expect(screen.getByRole('button', { name: '展开 reviewer 的下级子智能体' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull() + expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子智能体' })).toBeNull() const sidebarButton = within(screen.getByRole('treeitem', { name: /worker/ })) .getByRole('button', { name: '在侧边栏打开 worker' }) @@ -732,8 +746,11 @@ describe('SubagentHeaderLineage', () => { expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1) }) - it('aligns a known leaf with siblings whose child catalog is still unknown', () => { - render() + it('aligns a known leaf with siblings whose child catalog contains descendants', () => { + render() hoverCatalog(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(2) @@ -1043,6 +1060,7 @@ describe('SubagentHeaderLineage', () => { origin: 'subagent' as const, running: activity === 'running', projectionValues: { + subagentCatalog: [], subagentTiming: { settledMs, ...(id === 'finished' @@ -1111,6 +1129,7 @@ describe('SubagentHeaderLineage', () => { origin: 'subagent', running: true, projectionValues: { + subagentCatalog: [], subagentTiming: { settledMs: 0, active: { since: now - 5_000, through: now } }, }, }, @@ -1147,7 +1166,7 @@ describe('SubagentHeaderLineage', () => { hoverCatalog(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) - expect(input.refreshProjection).toHaveBeenCalledWith(CHILD) + expect(input.refreshProjection).not.toHaveBeenCalled() const nested = screen.getByRole('treeitem', { name: /indexer/ }) expect(nested.getAttribute('aria-level')).toBe('2') @@ -1158,15 +1177,15 @@ describe('SubagentHeaderLineage', () => { }) }) - it('keeps ordinary cold descendants loading until their real catalog arrives', () => { + it('reveals cold descendants only when shared catalog data arrives', () => { const secondGrandchild = 'grandchild-2' as SessionId const summaries = { [CHILD]: { ...summary(CHILD, 1), running: true }, [GRANDCHILD]: { - ...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const, + ...summary(GRANDCHILD, 1), projectionValues: { subagentCatalog: [] }, parentId: CHILD, origin: 'subagent' as const, }, [secondGrandchild]: { - ...summary(secondGrandchild, 1), parentId: CHILD, origin: 'subagent' as const, + ...summary(secondGrandchild, 1), projectionValues: { subagentCatalog: [] }, parentId: CHILD, origin: 'subagent' as const, running: true, }, } @@ -1176,11 +1195,11 @@ describe('SubagentHeaderLineage', () => { const current = { ...deferred, useSessions: (select: (snapshot: SessionListState) => T): T => select(state) } const view = render() hoverCatalog(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull() - expect(deferred.refreshProjection).toHaveBeenCalledWith(CHILD) - expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true') - expect(screen.getByText('正在加载子智能体…')).toBeTruthy() + expect(deferred.refreshProjection).not.toHaveBeenCalled() + expect(screen.getByRole('treeitem', { name: /worker/ }).getAttribute('aria-busy')).toBeNull() + expect(screen.queryByText('正在加载子智能体…')).toBeNull() expect(screen.queryByRole('treeitem', { name: '正在加载子智能体' })).toBeNull() expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull() expect(screen.queryByText(/模式未知/)).toBeNull() @@ -1193,7 +1212,7 @@ describe('SubagentHeaderLineage', () => { }, } view.rerender() - expect(screen.getByText('正在加载子智能体…')).toBeTruthy() + expect(screen.queryByText('正在加载子智能体…')).toBeNull() expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull() expect(screen.queryByText(/模式未知/)).toBeNull() @@ -1213,16 +1232,161 @@ describe('SubagentHeaderLineage', () => { }, summaries) state = { ...state, projectionsBySession: ready.useSessions(value => value.projectionsBySession) } view.rerender() + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull() expect(screen.getByRole('treeitem', { name: /indexer/ }).querySelector('[data-state="idle"]')).not.toBeNull() expect(screen.getByRole('treeitem', { name: /critic/ }).querySelector('[data-state="ongoing"]')).not.toBeNull() expect(screen.queryByText('正在加载子智能体…')).toBeNull() expect(screen.queryByText(/模式未知/)).toBeNull() - expect(deferred.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD) + expect(deferred.refreshProjection).not.toHaveBeenCalled() expect(deferred.openChild).not.toHaveBeenCalled() }) - it.each([false, true])('shows list migration status before expanding, with cached descendants=%s', (cached) => { + it.each([ + { running: true, completed: false, dot: 'ongoing' }, + { running: false, completed: true, dot: 'done' }, + { running: false, completed: false, dot: 'idle' }, + ])('preserves the $dot activity marker while an unknown directory loads', ({ running, completed, dot }) => { + const input = props(catalog({ entries: [{ + id: CHILD, mode: 'continuable', label: 'worker', activity: running ? 'running' : 'inactive', + }] }), {}, { [CHILD]: { + ...summary(CHILD, 1), running, + projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: completed } }, + } }) + let state = input.useSessions(value => value) + const current = { ...input, useSessions: (select: (value: SessionListState) => T): T => select(state) } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '1 个子智能体' })) + const row = screen.getByRole('treeitem', { name: /worker/ }) + const marker = row.querySelector(`[data-state="${dot}"]`) + expect(marker).not.toBeNull() + expect(row.children).toHaveLength(1) + expect(input.refreshProjection).not.toHaveBeenCalled() + + state = { ...state, projectionsBySession: { ...state.projectionsBySession, [CHILD]: { + ...state.projectionsBySession[CHILD]!, state: 'loading', + } } } + view.rerender() + expect(screen.getByRole('treeitem', { name: /worker/ })).toBe(row) + expect(row.querySelector(`[data-state="${dot}"]`)).toBe(marker) + expect(screen.queryByText('正在加载子智能体…')).toBeNull() + expect(row.hasAttribute('aria-busy')).toBe(false) + expect(row.hasAttribute('aria-expanded')).toBe(false) + expect(within(row).getByRole('button', { name: '在侧边栏打开 worker' }).hasAttribute('disabled')).toBe(false) + expect(input.refreshProjection).not.toHaveBeenCalled() + }) + + it('leaves missing catalogs unread and retries only an explicitly selected failure', () => { + const input = props(catalog(), {}, { [CHILD]: summary(CHILD, 1) }) + let state = input.useSessions(value => value) + const current = { ...input, useSessions: (select: (value: SessionListState) => T): T => select(state) } + const view = render() + expect(input.refreshProjection).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '2 个子智能体' })) + expect(input.refreshProjection).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull() + expect(screen.queryByText('正在加载子智能体…')).toBeNull() + + state = { ...state, projectionsBySession: { + ...state.projectionsBySession, + [CHILD]: { state: 'error', values: {}, error: new RemoteError('gateway/internal', 'private diagnostic', {}) }, + } } + view.rerender() + expect(screen.getByRole('alert').textContent).toContain('无法加载子智能体') + expect(screen.queryByText('private diagnostic')).toBeNull() + expect(screen.queryByRole('status')).toBeNull() + expect(input.refreshProjection).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '重试' })) + expect(input.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD) + expect(input.openChild).not.toHaveBeenCalled() + state = { ...state, projectionsBySession: { + ...state.projectionsBySession, [CHILD]: { state: 'ready', values: { subagentCatalog: [] }, error: null }, + } } + view.rerender() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull() + expect(screen.getByRole('treeitem', { name: /worker/ }).getAttribute('aria-busy')).toBeNull() + }) + + it('expands cached shared projections without reading the next level', () => { + const childSummary = { ...summary(CHILD, 1), projectionValues: { subagentCatalog: [ + { id: GRANDCHILD, createdAt: 1, mode: 'continuable' as const, label: 'indexer' }, + ] } } + const input = props(catalog(), {}, { [CHILD]: childSummary, [GRANDCHILD]: summary(GRANDCHILD, 1) }) + let state = input.useSessions(value => value) + const current = { ...input, useSessions: (select: (value: SessionListState) => T): T => select(state) } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '2 个子智能体' })) + expect(input.refreshProjection).not.toHaveBeenCalled() + expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy() + expect(input.refreshProjection).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: '展开 indexer 的下级子智能体' })).toBeNull() + + state = { ...state, projectionsBySession: { + ...state.projectionsBySession, [CHILD]: { state: 'ready', error: null, values: { subagentCatalog: [] } }, + } } + view.rerender() + expect(screen.queryByRole('button', { name: '收起 worker 的下级子智能体' })).toBeNull() + expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull() + }) + + it('updates disclosure when a visible leaf gains children without fetching any level', () => { + const input = props(catalog()) + let state = input.useSessions(value => value) + const current = { ...input, useSessions: (select: (value: SessionListState) => T): T => select(state) } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '2 个子智能体' })) + const worker = screen.getByRole('treeitem', { name: /worker/ }) + expect(worker.hasAttribute('aria-expanded')).toBe(false) + state = { ...state, projectionsBySession: { ...state.projectionsBySession, [CHILD]: { + state: 'idle', error: null, values: { + subagentCatalog: [{ id: GRANDCHILD, createdAt: 1, mode: 'continuable', label: 'indexer' }], + }, + } } } + view.rerender() + expect(screen.getByRole('treeitem', { name: /worker/ })).toBe(worker) + expect(worker.getAttribute('aria-expanded')).toBe('false') + expect(input.refreshProjection).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(input.refreshProjection).not.toHaveBeenCalled() + expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy() + }) + + it('prepares only explicitly opened levels', () => { + const input = props(catalog(), { + [CHILD]: catalog({ entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'inactive' }] }), + }) + render() + const trigger = screen.getByRole('button', { name: /\d+ 个子智能体$/ }) + hoverCatalog(trigger) + expect(input.prepareCatalog).not.toHaveBeenCalled() + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(input.prepareCatalog.mock.calls).toEqual([[PARENT], [CHILD]]) + fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子智能体' })) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(input.prepareCatalog).toHaveBeenCalledTimes(2) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + expect(input.prepareCatalog.mock.calls.at(-1)).toEqual([PARENT]) + }) + + it('reports an unexpected explicit catalog failure', async () => { + const input = props(catalog()) + const failure = new Error('projection reader failed') + input.prepareCatalog.mockRejectedValueOnce(failure) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + render() + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + await vi.waitFor(() => { expect(report).toHaveBeenCalledWith('[ui-subagent] catalog discovery failed:', failure) }) + } finally { + report.mockRestore() + } + }) + + it.each([false, true])('keeps legacy rows navigable without a migration notice, with cached descendants=%s', (cached) => { const input = props(catalog(), cached ? { [CHILD]: catalog({ entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'inactive' }] }), } : {}, { @@ -1235,7 +1399,8 @@ describe('SubagentHeaderLineage', () => { hoverCatalog(trigger) const child = screen.getByRole('treeitem', { name: /worker/ }) - expect(within(child).getByText(/需要迁移/)).toBeTruthy() + expect(within(child).queryByText(/需要迁移/)).toBeNull() + expect(child.getAttribute('aria-label')).not.toContain('需要迁移') expect(within(child).queryByRole('button', { name: /下级子智能体/ })).toBeNull() fireEvent.keyDown(child, { key: 'ArrowRight' }) expect(input.refreshProjection).not.toHaveBeenCalled() @@ -1248,8 +1413,13 @@ describe('SubagentHeaderLineage', () => { view.rerender() hoverCatalog(trigger) expect(screen.queryByText(/需要迁移/)).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) - expect(input.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD) + if (cached) { + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(input.refreshProjection).not.toHaveBeenCalled() + } else { + expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull() + expect(input.refreshProjection).not.toHaveBeenCalled() + } }) it.each(['ready', 'migration-required'] as const)('stops at a migration-required child until its real catalog arrives with %s read state', (readState) => { @@ -1272,7 +1442,7 @@ describe('SubagentHeaderLineage', () => { const current = { ...input, useSessions: (select: (snapshot: SessionListState) => T): T => select(state) } const view = render() hoverCatalog(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull() expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull() state = { @@ -1287,15 +1457,15 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull() expect(screen.queryByRole('treeitem', { name: /ordinary-fork/ })).toBeNull() const child = screen.getByRole('treeitem', { name: /worker/ }) - expect(within(child).getByText(/需要迁移/)).toBeTruthy() + expect(within(child).queryByText(/需要迁移/)).toBeNull() + expect(child.getAttribute('aria-label')).not.toContain('需要迁移') expect(within(child).queryByText(/模式未知/)).toBeNull() - expect(en['catalog.migrationRequired']).toBe('Migration required') expect(child.hasAttribute('aria-expanded')).toBe(false) expect(within(child).queryByRole('button', { name: /下级子智能体/ })).toBeNull() expect(screen.queryByRole('group')).toBeNull() expect(screen.queryByText('正在加载子智能体…')).toBeNull() fireEvent.keyDown(child, { key: 'ArrowRight' }) - expect(input.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD) + expect(input.refreshProjection).not.toHaveBeenCalled() expect(input.openChild).not.toHaveBeenCalled() fireEvent.click(child) expect(input.openChild).toHaveBeenCalledExactlyOnceWith({ @@ -1347,7 +1517,8 @@ describe('SubagentHeaderLineage', () => { fireEvent.keyDown(child, { key: 'ArrowRight' }) fireEvent.keyDown(child, { key: 'ArrowRight' }) expect(input.refreshProjection).not.toHaveBeenCalled() - expect(within(child).getByText(/需要迁移/)).toBeTruthy() + expect(within(child).queryByText(/需要迁移/)).toBeNull() + expect(child.getAttribute('aria-label')).not.toContain('需要迁移') expect(within(child).queryByText(/模式未知/)).toBeNull() expect(child.hasAttribute('aria-expanded')).toBe(false) expect(within(child).queryByRole('button', { name: /下级子智能体/ })).toBeNull() @@ -1406,7 +1577,30 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull() }) - it('closes expanded descendants even when their own catalogs have not arrived', () => { + it('collapses expanded descendants whose catalog is removed before their parent closes', () => { + const input = props(catalog(), { + [CHILD]: catalog({ entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'inactive' }] }), + [GRANDCHILD]: catalog({ entries: [{ id: 'great-grandchild' as SessionId, mode: 'continuable', label: 'deep', activity: 'inactive' }] }), + }) + let state = input.useSessions(value => value) + const current = { ...input, useSessions: (select: (value: SessionListState) => T): T => select(state) } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '2 个子智能体' })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子智能体' })) + const { [GRANDCHILD]: _projection, ...projectionsBySession } = state.projectionsBySession + const { [GRANDCHILD]: _summary, ...byId } = state.byId + state = { ...state, projectionsBySession, byId } + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子智能体' })) + expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull() + state = input.useSessions(value => value) + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(screen.getByRole('treeitem', { name: /indexer/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('closes a branch while the visible descendants are loading', () => { const input = props(catalog(), { [CHILD]: catalog({ entries: [{ @@ -1414,11 +1608,12 @@ describe('SubagentHeaderLineage', () => { label: 'indexer', activity: 'running', }], }), - }) + }, { [GRANDCHILD]: summary(GRANDCHILD, 1) }) render() hoverCatalog(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) - fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子智能体' })) + expect(screen.queryByRole('button', { name: '展开 indexer 的下级子智能体' })).toBeNull() + expect(screen.getByRole('treeitem', { name: /indexer/ }).getAttribute('aria-busy')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子智能体' })) expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull() diff --git a/snapshots/web/subagent-conversation/disclosures.expected.md b/snapshots/web/subagent-conversation/disclosures.expected.md new file mode 100644 index 0000000000..fa9997580d --- /dev/null +++ b/snapshots/web/subagent-conversation/disclosures.expected.md @@ -0,0 +1,8 @@ +- tree "Subagent sessions": + - treeitem "event-sourcing researcher agent-1 · continuable · completed 7.9K tok · {{duration}}" [level=1]: + - button "Expand event-sourcing researcher descendants" + - text: event-sourcing researcher agent-1 7.9K tok {{duration}} + - button "Open event-sourcing researcher in sidebar" + - treeitem "event-sourcing reviewer recorded-one-shot · one-shot · completed 0 tok · {{duration}}" [level=1]: + - text: event-sourcing reviewer recorded-one-shot 0 tok ~6mo 12d + - button "Open event-sourcing reviewer in sidebar" From c867b5cc7e0aeb24f962d61d8417a57e7ae96cae Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 5 Nov 2026 15:49:11 +0800 Subject: [PATCH 064/149] docs(ui-subagent): align catalog preparation descriptions --- .../2026-09-29-explicit-session-history-preparation.i18n.yaml | 4 ++-- .../2026-09-29-explicit-session-history-preparation.md | 2 +- .../2026-09-29-explicit-session-history-preparation.zh.md | 2 +- packages/client/ui-subagent/README.i18n.yaml | 4 ++-- packages/client/ui-subagent/README.md | 4 ++-- packages/client/ui-subagent/README.zh.md | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml index ddd6d80912..5a918c8cc1 100644 --- a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml @@ -9,8 +9,8 @@ en: ab63c8127d68d294 zh: d3f99489c96ff1d4 /agent-note-open-the-session-when-migration-is-required/decision: - en: 32627d744a6e7410 - zh: 2836a95ee5390111 + en: 310ff84cece51901 + zh: a9a29e3ba30e03a8 /agent-note-open-the-session-when-migration-is-required/alternatives-considered: en: baea781018a49252 zh: bcc452029bf229fd diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md index 84b30b6cb4..c452ae85ae 100644 --- a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md @@ -18,7 +18,7 @@ Other fork callers omit that option and allow automatic migration. The `formatStatus` returned by `stat` carries the migration library's existing classification; `header` remains the current logical format. It describes the stored format, not migration-task progress or prepared in-memory history. -When migration is required and `subagentCatalog` is absent, Web marks the Session's row “Migration required” and keeps its history-opening actions without descendant expansion or changing its subagent mode. Opening the Session supplies its catalog through history and control projections. Available catalogs remain usable, including empty catalogs. Session-list lineage resolves navigation addresses, not missing catalog membership. Ordinary loading, cancellation, and storage errors do not become this migration branch. +When migration is required and `subagentCatalog` is absent, Web keeps the Session row's history-opening actions without a format notice, descendant expansion, or changing its subagent mode. Opening the Session supplies its catalog through history and control projections. Available catalogs remain usable, including empty catalogs. Session-list lineage resolves navigation addresses, not missing catalog membership. Ordinary loading, cancellation, and storage errors do not become this migration branch. Create/adopt, full-text search, and ordinary history opening allow automatic migration. diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md index 31925f7a9f..e68747a9b6 100644 --- a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md @@ -18,7 +18,7 @@ WorkspaceBrowser 行菜单的 fork 请求携带 `allowMigration: false`,由 Ho `stat` 返回的 `formatStatus` 携带迁移库已有的分类;`header` 仍是当前逻辑格式。它描述存储格式,不描述迁移任务进度或内存准备缓存。 -在需要迁移且缺少 `subagentCatalog` 时,Web 将该会话行标为“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode。打开会话后,历史与 control 投影提供其目录。已有目录仍可使用,包括空目录。会话列表的谱系用于解析导航地址,不用于补齐缺失的目录成员。普通加载、取消和存储错误不转换成这一迁移分支。 +在需要迁移且缺少 `subagentCatalog` 时,Web 保留该会话行打开历史的操作,不显示格式提示,不展开下级,也不改变子会话的 mode。打开会话后,历史与 control 投影提供其目录。已有目录仍可使用,包括空目录。会话列表的谱系用于解析导航地址,不用于补齐缺失的目录成员。普通加载、取消和存储错误不转换成这一迁移分支。 create/adopt、全文搜索和普通历史打开允许自动迁移。 diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 37ef8fd189..ae80fd109c 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -30,8 +30,8 @@ en: c158f12dcaed87b7 zh: e5d315e7b1fb4fa9 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation: - en: 22b8ca76ca8d64a6 - zh: 300231110a4a208d + en: c7a6ed23ce7ec331 + zh: 92b60359a490b270 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens: en: dcbc58d4cad47fa8 zh: e962a8cbdfd4a914 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 74810feff4..325dde5777 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -61,11 +61,11 @@ The [subagent runtime](../../subagent/subagent/README.md) owns continuation and ### Catalog derivation -The header lineage renderer reads `subagentCatalog` only from `projectionsBySession` through the standard `useSessions` hook. The Session object layer merges list-cache hints, explicit reads, and the Host-wide control stream into these shared values; live frames update loaded levels without per-menu subscriptions. Activity prefers the unified UI status and falls back to Session summaries; summaries also supply titles and usage. Rendering, hovering, and expanding the tree use only local projections and never request missing catalogs. Missing and explicitly empty catalogs have no expansion arrow; a later shared projection with children makes the arrow available. Failures require explicit retry. Migration-required rows keep their history-opening actions without a format notice or descendant expansion; opening the Session supplies its catalog through history and control projections. External rows are always leaves and do not load child Sessions. +The header lineage renderer reads `subagentCatalog` only from `projectionsBySession` through the standard `useSessions` hook. The Session object layer merges list-cache hints, explicit reads, and the Host-wide control stream into these shared values; live frames update loaded levels without per-menu subscriptions. Activity prefers the unified UI status and falls back to Session summaries; summaries also supply titles and usage. Rendering and hovering use only local projections and never request missing catalogs. Missing and explicitly empty catalogs have no expansion arrow; a later shared projection with children makes the arrow available. Failures require explicit retry. Migration-required rows keep their history-opening actions without a format notice or descendant expansion; opening the Session supplies its catalog through history and control projections. External rows are always leaves and do not load child Sessions. Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. CSS anchors keep the panel attached to its trigger within layout-viewport size limits. Browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Internal scrolling, hover behavior, pinned opening, and focus restoration remain independent. -Opening a catalog dropdown does not request its root catalog. Only explicit failed-read retries call `refreshProjection`; the Session object layer owns request deduplication and reconnect recovery. +Explicit menu activation and branch expansion call `prepareCatalog` to read the current catalog and its direct children. Ancestor-title clicks navigate to that Session. Failed-read retry buttons call `refreshProjection`; the Session object layer deduplicates reads and refreshes previously requested projections after reconnect. Breadcrumb addresses derive from the Provider-bound Session address, loaded parent catalogs, or already-listed subagent lineage, including never-selected ancestors. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 3ea5bef7e9..fea6f5d76c 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -61,11 +61,11 @@ kind: "package-reference" ### 目录派生 -页头谱系 renderer 通过标准 `useSessions` 钩子,仅从 `projectionsBySession` 读取 `subagentCatalog`。Session 对象层将列表缓存提示、显式读取和 Host 全局 control 流合并到这些共享值;实时帧更新已加载层级,无需每个菜单单独订阅。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要还提供标题与用量。渲染、悬停和展开目录树仅使用本地投影,不会请求缺失目录。缺失或显式为空的目录没有展开箭头;后续共享投影包含下级时才显示箭头。读取失败后需要显式重试。需要迁移的行保留打开历史的操作,不显示格式提示,也不展开下级;打开 Session 后,历史与 control 投影提供其目录。外部行始终为叶子,不加载子 Session。 +页头谱系 renderer 通过标准 `useSessions` 钩子,仅从 `projectionsBySession` 读取 `subagentCatalog`。Session 对象层将列表缓存提示、显式读取和 Host 全局 control 流合并到这些共享值;实时帧更新已加载层级,无需每个菜单单独订阅。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要还提供标题与用量。渲染与悬停仅使用本地投影,不会请求缺失目录。缺失或显式为空的目录没有展开箭头;后续共享投影包含下级时才显示箭头。读取失败后需要显式重试。需要迁移的行保留打开历史的操作,不显示格式提示,也不展开下级;打开 Session 后,历史与 control 投影提供其目录。外部行始终为叶子,不加载子 Session。 每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。CSS 锚点让面板跟随触发器,并按布局视口限制尺寸。浏览器缩放和平移时面板随页面移动,因此可以超出视觉视口。内部滚动、悬停行为、点击固定展开和焦点恢复保持独立。 -打开目录下拉菜单不会请求其根目录。只有显式重试失败读取时才调用 `refreshProjection`;Session 对象层负责请求去重和重连恢复。 +显式激活菜单或展开分支时,`prepareCatalog` 读取当前目录及其直接子节点。点击祖先标题会导航到该 Session。失败读取的重试按钮调用 `refreshProjection`;Session 对象层合并重复读取,并在重连后刷新之前请求过的投影。 面包屑地址从 Provider 所绑定的 Session 地址、已加载的 parent 目录或已列出的 subagent 谱系推导,也包括从未选中过的祖先。 From 636c34f5a6610541d725a3ee0ea2b683c71ce289 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 12:17:15 +0800 Subject: [PATCH 065/149] feat(cua): support overlapping macOS key presses --- .../experimental/cua-macos/README.i18n.yaml | 4 +- packages/experimental/cua-macos/README.md | 4 +- packages/experimental/cua-macos/README.zh.md | 4 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../native/Sources/CuaNative/AXWorker.swift | 4 +- .../Sources/CuaNative/KeyboardInput.swift | 109 ++++++++---------- .../Sources/CuaNative/RequestAdmission.swift | 9 ++ .../cua-macos/native/protocol.schema.json | 16 ++- .../native/tests/capabilities_e2e.py | 6 + .../tests/swift/KeyboardChordTests.swift | 36 ++++++ .../cua-macos/tests/guidance.spec.ts | 4 +- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/src/guidance.ts | 2 +- packages/experimental/cua/src/protocol.ts | 2 +- packages/experimental/cua/src/types.ts | 6 +- packages/experimental/cua/tests/api.spec.ts | 5 +- .../cua-native/system-prompt.expected.md | 2 +- .../cua-native/tool-schemas.expected.json | 16 ++- .../session/cua-ptc/system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- 24 files changed, 158 insertions(+), 91 deletions(-) create mode 100644 packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 0d83c6e538..bcc12b8ecc 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: 533ed4dd11af5126 - zh: 03036e8c949d6aca + en: e3643640ef764471 + zh: 5d25e833cb78bf9b /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 46b035112e..63e1c495d2 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -79,7 +79,7 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-cua-macos) lists all fields. Native tool calls use `{method, params}`; the complete method-specific schema is exposed to the model. `getApp` returns the `appHandle` used by every app-targeted operation, and `app.getWindow(windowId)` returns the object that owns screenshots and pointer input. -`pressKey(key, modifiers?)` presses and releases one key. Pass modifier names separately, for example `pressKey('Tab', ['shift'])` or `pressKey('+')`; an omitted modifier array means none. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. On macOS, use `pressKey` on the App object. The shared `sendKeyEvent` operation returns `NOT_IMPLEMENTED` on this provider. +`pressKey(key, modifiers?)` accepts one key or a nonempty array of distinct keys. `pressKey(['w', 'd'])` presses W then D and releases D then W. Pass modifier names separately, for example `pressKey('Tab', ['shift'])` or `pressKey('+')`; an omitted modifier array means none. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. On macOS, use `pressKey` on the App object. The shared `sendKeyEvent` operation returns `NOT_IMPLEMENTED` on this provider. Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot. @@ -221,7 +221,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 341796b8c4..276f725cb3 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -79,7 +79,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-cua-macos)列出全部字段。原生工具调用使用 `{method, params}`,模型可以看到每个方法的完整参数定义。`getApp` 返回每个面向应用的操作所需的 `appHandle`,`app.getWindow(windowId)` 返回负责截图和指针输入的对象。 -`pressKey(key, modifiers?)` 按下并释放一个键。修饰键名称单独传入,例如 `pressKey('Tab', ['shift'])` 或 `pressKey('+')`;省略修饰键数组表示不按修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。在 macOS 上,通过 App 对象调用 `pressKey`。调用共享接口 `sendKeyEvent` 会收到 `NOT_IMPLEMENTED`。 +`pressKey(key, modifiers?)` 接受单个键名或由不同键名组成的非空数组。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。修饰键名称单独传入,例如 `pressKey('Tab', ['shift'])` 或 `pressKey('+')`;省略修饰键数组表示不按修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。在 macOS 上,通过 App 对象调用 `pressKey`。调用共享接口 `sendKeyEvent` 会收到 `NOT_IMPLEMENTED`。 Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 @@ -221,7 +221,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 8d43fbd6b8..6e9294f0ce 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 88a59904ca4ebedc zh: 14f2b666d7382145 /dsh-cua-native-helper/observation-and-input: - en: 2d380e942a4057e4 - zh: fe7c9339ec63a6e5 + en: d19180b1a6db2e48 + zh: c6279db9957218eb /dsh-cua-native-helper/verification: en: 49f4cce2a4cc3114 zh: e9d65c92ef35fe24 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index bc80110951..b94d053746 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -103,7 +103,7 @@ Clicks send mouse-down and mouse-up events at the element center or the supplied Capture and pointer delivery read CG bounds before and after an exact-window AX frame query. Movement during the query returns WINDOW_CHANGED. Valid AX/CG frames must have equal outward-rounded integral point rectangles; a known mismatch returns WINDOW_CHANGED before mouse movement, priming or button delivery. The helper preserves both coordinate sources rather than substituting AX geometry for CG geometry. Missing or unusable AX geometry remains unknown, while AX communication, permission and invalid-element errors propagate. Capture without Accessibility permission retains its CG/SCK checks. Advertised AX actions bypass this pointer preflight; an AX action error does not trigger pointer input. These reads do not lock the window against later movement, and no geometry check activates or raises it. -`typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses and releases one key while holding its `modifiers` array, sends PID-targeted modifier transitions, and releases those modifiers in reverse order; these events do not use the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. +`typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses its modifiers followed by the requested keys in order, then releases all pressed keys in reverse order, including on cancellation; these events do not use the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. The selected helper application needs macOS Accessibility permission for AX state and input, and Screen Recording permission for screenshots. Capability preflight and permission errors do not grant permissions. Capture refuses a window that moves or resizes while the image is being read. Unassociated system panels and accessory-only applications are not selectable. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 0927a82bce..7ddde7d4f4 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -103,7 +103,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 截图和鼠标输入在精确窗口的 AX 边界查询前后读取 CG 边界,查询期间移动会返回 WINDOW_CHANGED。有效的 AX/CG 边界向外取整后必须一致;已知不一致会在移动、预备事件或按键发送前返回 WINDOW_CHANGED。helper 保留两个坐标来源,不用 AX 几何替换 CG 几何。缺失或无效的 AX 几何仍表示未知,AX 通信、权限和无效元素错误继续传递。无辅助功能权限的截图保留 CG/SCK 检查。声明的 AX 动作不经过鼠标预检,AX 动作失败也不改用鼠标输入。这些读取不会锁住窗口以阻止后续移动,几何检查不激活或抬起窗口。 -`typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 在按住所列 `modifiers` 时按下并释放一个键,先向 PID 发送修饰键变化,再发送主键,并逆序释放修饰键,不使用全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 +`typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和指定按键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 所选 helper 应用需要 macOS 辅助功能权限以读取 AX 和输入,需要屏幕录制权限以截图。能力预检和权限错误不授予权限。截图会拒绝在读取图片期间移动或缩放的窗口。无法确认归属的系统面板及仅后台辅助类型的应用不可选择。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index c4cc6066c8..da1afe5a3d 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -118,7 +118,7 @@ actor AXWorker { if let keyboard, let pointer { return (keyboard, pointer) } let poster = PIDEventPoster() let latch = cancellation - let key = KeyboardInput(poster: poster, textEventInterval: textEventInterval, checkCancellation: { try latch.check() }) + let key = KeyboardInput(post: poster.post, textEventInterval: textEventInterval, checkCancellation: { try latch.check() }) let mouse = WindowPointerInput(poster: poster, checkCancellation: { try latch.check() }) keyboard = key; pointer = mouse return (key, mouse) @@ -335,7 +335,7 @@ actor AXWorker { let recipient = try ax().keyboardRecipient(identity, catalog: catalog) try NativePlatform.verify(recipient) if method == "typeText" { try inputs().0.typeText(params.string("text"), to: recipient.pid) } - else { try inputs().0.pressKey(params.string("key"), modifiers: params.modifiers(), to: recipient.pid) } + else { try inputs().0.pressKey(params.pressKeys(), modifiers: params.modifiers(), to: recipient.pid) } case "setValue": try ax().setAttribute(element(handle, params), "AXValue", params.values["value"]) case "performAction": try ax().performAction(element(handle, params), action: params.string("action"), argument: params.values["argument"]) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift index be75257f1c..2dbf648ecf 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift @@ -4,76 +4,62 @@ import Foundation /// Balanced PID-targeted chords and scalar-based Unicode input on one serial worker. final class KeyboardInput { - private struct Modifier { + private struct Key { let keyCode: CGKeyCode let flag: CGEventFlags } - private struct ModifierEvents { - let down: CGEvent - let up: CGEvent - } - - private let poster: PIDEventPoster + private let post: (CGEvent, Int32) -> Void private let textEventInterval: Double private let checkCancellation: @Sendable () throws -> Void - init(poster: PIDEventPoster, textEventInterval: Double, checkCancellation: @escaping @Sendable () throws -> Void = {}) { - self.poster = poster + init(post: @escaping (CGEvent, Int32) -> Void, textEventInterval: Double, checkCancellation: @escaping @Sendable () throws -> Void = {}) { + self.post = post self.textEventInterval = textEventInterval self.checkCancellation = checkCancellation } - /// Construct the entire chord before posting; cleanup releases only keys pressed by this call. - func pressKey(_ key: String, modifiers: [String], to pid: Int32) throws { + /// Prepare every event before dispatch. On cancellation, release only the keys this call pressed. + func pressKey(_ keys: [String], modifiers: [String], to pid: Int32) throws { try checkCancellation() - var names = modifiers - let isPlus = key == "+" || key.lowercased() == "plus" - if isPlus { names.append("shift") } - let code = try Self.keyCode(isPlus ? "=" : key) - var selected: [Modifier] = [] - for name in names { - let modifier = try Self.modifier(name) - if !selected.contains(where: { $0.keyCode == modifier.keyCode }) { - selected.append(modifier) + var modifierNames = modifiers + let mainKeys = try keys.map { name -> Key in + if name == "+" || name.lowercased() == "plus" { + modifierNames.append("shift") + return Key(keyCode: try Self.keyCode("="), flag: []) } + if let modifier = Self.namedModifier(name) { return modifier } + return Key(keyCode: try Self.keyCode(name), flag: []) } - let source = try Self.source() + guard Set(mainKeys.map(\.keyCode)).count == mainKeys.count else { + throw RequestParameters.invalid("A chord must contain distinct physical keys") + } + var selected = Set() var flags: CGEventFlags = [] - var modifierEvents: [ModifierEvents] = [] - for modifier in selected { + var events: [(down: CGEvent, up: CGEvent)] = [] + let source = try Self.source() + for key in try modifierNames.map(Self.modifier) + mainKeys where selected.insert(key.keyCode).inserted { let releasedFlags = flags - flags.insert(modifier.flag) - modifierEvents.append(ModifierEvents( - down: try Self.keyEvent(source: source, code: modifier.keyCode, down: true, flags: flags), - up: try Self.keyEvent(source: source, code: modifier.keyCode, down: false, flags: releasedFlags) + flags.formUnion(key.flag) + events.append(( + down: try Self.keyEvent(source: source, code: key.keyCode, down: true, flags: flags), + up: try Self.keyEvent(source: source, code: key.keyCode, down: false, flags: releasedFlags) )) } - let down = try Self.keyEvent(source: source, code: code, down: true, flags: flags) - let up = try Self.keyEvent(source: source, code: code, down: false, flags: flags) - var heldModifiers = 0 - var keyHeld = false + var pressed = 0 do { defer { - if keyHeld { poster.post(up, to: pid) } - for index in (0.. 0 { Thread.sleep(forTimeInterval: 0.008) } } } - for events in modifierEvents { + for event in events { try checkCancellation() - poster.post(events.down, to: pid) - heldModifiers += 1 + post(event.down, pid) + pressed += 1 Thread.sleep(forTimeInterval: 0.008) } - try checkCancellation() - poster.post(down, to: pid) - keyHeld = true - Thread.sleep(forTimeInterval: 0.008) - try checkCancellation() - poster.post(up, to: pid) - keyHeld = false } try checkCancellation() } @@ -104,16 +90,16 @@ final class KeyboardInput { do { defer { if keyHeld { - poster.post(up, to: pid) + post(up, pid) Thread.sleep(forTimeInterval: textEventInterval) } } try checkCancellation() - poster.post(down, to: pid) + post(down, pid) keyHeld = true Thread.sleep(forTimeInterval: textEventInterval) try checkCancellation() - poster.post(up, to: pid) + post(up, pid) keyHeld = false Thread.sleep(forTimeInterval: textEventInterval) } @@ -139,22 +125,30 @@ final class KeyboardInput { return event } - private static func modifier(_ name: String) throws -> Modifier { + private static func namedModifier(_ name: String) -> Key? { switch name.lowercased() { - case "command", "cmd": return Modifier(keyCode: 55, flag: .maskCommand) - case "shift": return Modifier(keyCode: 56, flag: .maskShift) - case "option", "alt": return Modifier(keyCode: 58, flag: .maskAlternate) - case "control", "ctrl": return Modifier(keyCode: 59, flag: .maskControl) - case "fn": return Modifier(keyCode: 63, flag: .maskSecondaryFn) - default: throw NativeFailure(code: "INVALID_ARGUMENT", message: "Unsupported key modifier: \(name)") + case "command", "cmd": return Key(keyCode: 55, flag: .maskCommand) + case "shift": return Key(keyCode: 56, flag: .maskShift) + case "option", "alt": return Key(keyCode: 58, flag: .maskAlternate) + case "control", "ctrl": return Key(keyCode: 59, flag: .maskControl) + case "fn": return Key(keyCode: 63, flag: .maskSecondaryFn) + default: return nil } } + private static func modifier(_ name: String) throws -> Key { + guard let modifier = namedModifier(name) else { + throw NativeFailure(code: "INVALID_ARGUMENT", message: "Unsupported key modifier: \(name)") + } + return modifier + } + static func modifierFlags(_ names: [String]) throws -> CGEventFlags { try names.reduce(into: CGEventFlags()) { flags, name in flags.formUnion(try modifier(name).flag) } } static func keyCode(_ key: String) throws -> CGKeyCode { + if let modifier = namedModifier(key) { return modifier.keyCode } switch key.lowercased() { case "kp_0": return 82 case "kp_1": return 83 @@ -180,12 +174,7 @@ final class KeyboardInput { case "space": return 49 case "delete", "backspace": return 51 case "escape", "esc": return 53 - case "command", "cmd": return 55 - case "shift": return 56 case "capslock": return 57 - case "option", "alt": return 58 - case "control", "ctrl": return 59 - case "fn": return 63 case "home": return 115 case "pageup": return 116 case "del", "forward_delete": return 117 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift index 5eb70381e4..ef2b6dc101 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift @@ -36,6 +36,15 @@ struct RequestParameters: Sendable { return path } } + func pressKeys() throws -> [String] { + if let name = values["key"]?.string { + guard !name.isEmpty else { throw Self.invalid("key must not be empty") } + return [name] + } + let names = try strings("key") + guard !names.isEmpty else { throw Self.invalid("key must name at least one key") } + return names + } func optionalString(_ key: String) throws -> String? { contains(key) ? try string(key) : nil } func optionalBoolean(_ key: String) throws -> Bool? { guard let raw = values[key] else { return nil } diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index b0d74a84dc..aa6d201dcb 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -993,8 +993,20 @@ "maximum": 4294967295 }, "key": { - "type": "string", - "minLength": 1 + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] }, "modifiers": { "type": "array", diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 650aff14e9..95a94c97b6 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -191,6 +191,12 @@ def main(): call("drag", {**window, "fromX": point["x"], "fromY": point["y"], "toX": point["x"] + 80, "toY": point["y"] + 20, "durationMs": 200, "steps": 10, "mouseButton": "middle", "modifiers": ["shift"]}) state = wait_for_state(state_file, lambda value: any(event["type"] == 27 and event.get("button") == 2 and event["shift"] for event in value["events"])) + call("pressKey", {**owner, "key": ["w", "d"]}) + expected_chord = [(10, 13), (10, 2), (11, 2), (11, 13)] + state = wait_for_state(state_file, lambda value: any( + event["type"] == 11 and event.get("keyCode") == 13 for event in value["events"])) + assert [(event["type"], event["keyCode"]) for event in state["events"] + if event.get("keyCode") in (13, 2)] == expected_chord, state["events"] call("pressKey", {**owner, "key": "KP_0"}) call("pressKey", {**owner, "key": "KP_Enter"}) state = wait_for_state(state_file, lambda value: {82, 76}.issubset({event.get("keyCode") for event in value["events"]})) diff --git a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift new file mode 100644 index 0000000000..ae98c891d6 --- /dev/null +++ b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift @@ -0,0 +1,36 @@ +// Observe the actual CoreGraphics events without sending keyboard input to the desktop. +import CoreGraphics +import Testing +@testable import CuaNative + +@Test func chordKeepsModifiersThroughBothLetterReleases() throws { + var events: [CGEvent] = [] + let input = KeyboardInput(post: { event, pid in + #expect(pid == 42) + events.append(event) + }, textEventInterval: 0) + try input.pressKey(["w", "d"], modifiers: ["shift"], to: 42) + #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [56, 13, 2, 2, 13, 56]) + #expect(events.map(\.type) == [.flagsChanged, .keyDown, .keyDown, .keyUp, .keyUp, .flagsChanged]) + #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, true, true, false]) +} + +@Test(arguments: [1, 2]) +func cancelledChordReleasesOnlyPressedKeys(_ cancelAfter: Int) { + let cancellation = CancellationLatch() + var events: [CGEvent] = [] + let input = KeyboardInput(post: { event, _ in + events.append(event) + if events.count == cancelAfter { cancellation.cancel() } + }, textEventInterval: 0, checkCancellation: cancellation.check) + #expect(throws: NativeFailure.self) { try input.pressKey(["w", "d"], modifiers: [], to: 42) } + let held = [Int64(13), 2].prefix(cancelAfter) + #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == Array(held) + held.reversed()) + #expect(events.map(\.type) == Array(repeating: .keyDown, count: cancelAfter) + Array(repeating: .keyUp, count: cancelAfter)) +} + +@Test(arguments: [["w", "unknown"], ["w", "W"], ["Enter", "Return"]]) +func invalidChordPostsNoInput(_ keys: [String]) { + let input = KeyboardInput(post: { _, _ in Issue.record("Invalid chord posted input") }, textEventInterval: 0) + #expect(throws: NativeFailure.self) { try input.pressKey(keys, modifiers: ["shift"], to: 42) } +} diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index c4a6aeec7d..f3e530b7de 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -89,7 +89,7 @@ it('preserves image formats and pointer modifier enums without restricting keybo for (const name of ['click', 'drag']) { expect(lines.find(line => line.startsWith(`- ${name}(`))).toContain("modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[]") } - expect(COMMON_GUIDANCE).toContain('pressKey(key: string, modifiers?: string[])') + expect(COMMON_GUIDANCE).toContain('pressKey(key: string | string[], modifiers?: string[])') expect(COMMON_GUIDANCE).toContain('Key and modifier names are platform-defined strings') }) @@ -130,7 +130,7 @@ it('documents facade initialization, emitted observations, and separate key argu expect(COMMON_GUIDANCE).toContain('host-provided bridge initialization once') expect(COMMON_GUIDANCE).toContain('not a preinstalled global') expect(COMMON_GUIDANCE).toContain('void await') - expect(COMMON_GUIDANCE).toContain('do not pack them into a chord string') + expect(COMMON_GUIDANCE).toContain("pressKey(['w', 'd']) presses W then D and releases D then W") expect(COMMON_GUIDANCE).toContain('perform a left-button double-click') }) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index f4abb13c43..0d16bc4b02 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -21,8 +21,8 @@ en: d92d5948a7941e8d zh: d43491ae6521b87e /deepseek-ai-dsh-cua/understand-the-implementation: - en: 2ab74a1ff120459e - zh: 39b3ef198989f133 + en: e38d57709aa6d001 + zh: 231b2dcaf826d386 /deepseek-ai-dsh-cua/further-exploration: en: c174fb5a00ea3681 zh: 039340042676881d diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index c95e4ae8ec..e6c0f0778f 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -91,7 +91,7 @@ One object facade ([src/api.ts](src/api.ts)) builds a shared `CuaSurface` and de Provider startup capabilities are fixed at installation: platform, methods, app-level accessibility, keyboard scopes, browser support, and coordinate space. The native `getCapabilities` RPC reports runtime permissions and session lock only; `installCua` merges both layers for the model-visible result. The raw RPC remains a common operation representation rather than a capability inventory, so provider guidance states the actions its platform supports. -Check `methods` and `keyboard` in the result of `await cua.getCapabilities()` for the supported operations and input scopes. `pressKey(key, modifiers?)` presses and releases one key while holding the listed modifiers; `sendKeyEvent(key, direction)` sends exactly one key-down or key-up. `typeText` and `paste` deliver literal text at the provider's declared keyboard scope. Keyboard modifier names are plain strings that the provider interprets. +Check `methods` and `keyboard` in the result of `await cua.getCapabilities()` for the supported operations and input scopes. `pressKey(key, modifiers?)` holds the listed modifiers while pressing the requested keys in order and releasing them in reverse. For example, `pressKey(['w', 'd'])` holds W through the D keystroke; `sendKeyEvent(key, direction)` sends exactly one key-down or key-up. `typeText` and `paste` deliver literal text at the provider's declared keyboard scope. Keyboard modifier names are plain strings that the provider interprets. Platform providers import `CuaConfigFields` from `@deepseek-ai/dsh-cua/config` to extend the shared configuration. The root Host entry exports `installCua`, `CuaConfig`, and the Typert-discovered `CuaAccess` service; its `apply()` only marks the optional Client contribution. `/client` loads the permission UI through the existing module loader. Separate Host and Client compiler projects keep React, CSS, and browser registrations out of headless imports. `/api` owns the app/window API and its types; `/bridge`, `/native-client`, and `/protocol` retain runtime and platform consumers. `/types` exports only the named types required by generated Remote declarations. The Client uses package-local types. `/typert` supplies Host reflection and `/remote` supplies generated browser descriptors and codecs; permission authority remains on the Host. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index c1c5ed863c..2f8e31b77c 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -91,7 +91,7 @@ Computer Use 详情页的权限卡片、组件列表和来源信息使用 l3 边 提供方启动能力在安装时固定:平台、方法、应用级辅助功能、键盘作用域、浏览器支持和坐标空间。原生 `getCapabilities` RPC 只报告运行时权限和会话锁定状态;`installCua` 为模型可见结果合并两层。原生 RPC 仍是通用操作表示,而不是能力清单,因此 provider 指引说明其平台支持的动作。 -通过 `await cua.getCapabilities()` 返回的 `methods` 和 `keyboard` 确认支持的操作和输入范围。`pressKey(key, modifiers?)` 在按住所列修饰键时按下并释放一个键;`sendKeyEvent(key, direction)` 只发送一次按下或抬起。`typeText` 和 `paste` 在 provider 声明的键盘作用域内传送字面文本。键盘修饰键名称是 provider 解释的普通字符串。 +通过 `await cua.getCapabilities()` 返回的 `methods` 和 `keyboard` 确认支持的操作和输入范围。`pressKey(key, modifiers?)` 按住所列修饰键,依次按下指定的键,再逆序释放。例如,`pressKey(['w', 'd'])` 在按下和松开 D 期间持续按住 W。`sendKeyEvent(key, direction)` 只发送一次按下或抬起。`typeText` 和 `paste` 在 provider 声明的键盘作用域内传送字面文本。键盘修饰键名称是 provider 解释的普通字符串。 平台提供方从 `@deepseek-ai/dsh-cua/config` 导入 `CuaConfigFields` 来扩展共享配置。根 Host 入口导出 `installCua`、`CuaConfig` 和供 Typert 发现的 `CuaAccess` 服务,`apply()` 只标记可选 Client 贡献。`/client` 通过现有模块加载器加载权限界面。Host 和 Client 使用独立编译项目,headless 导入不会加载 React、CSS 或浏览器注册。`/api` 提供应用、窗口 API 及其类型;`/bridge`、`/native-client` 和 `/protocol` 保留运行时和平台消费者。`/types` 仅导出生成 Remote 声明所需的命名类型,Client 使用包内类型。`/typert` 提供 Host 反射,`/remote` 提供浏览器描述和编解码,权限判断仍由 Host 执行。 diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 745ccfe9be..0677be5433 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -43,6 +43,6 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.` diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 538c95cfe6..8488f32af3 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -62,7 +62,7 @@ export const Request = z.discriminatedUnion('method', [ selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), - operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }), + operation('pressKey', { ...app, windowId: id.optional(), key: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]), modifiers: z.array(z.string()).optional() }), operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), z.object({ method: z.literal('scroll'), params: z.union([ z.object({ ...absoluteCoordinate, diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index b84663f9e2..875418d805 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -160,13 +160,13 @@ export interface CuaSurface { */ paste(text: string, options?: CuaPasteOptions): Promise /** - * Press and release one key at the current input focus. + * Press the keys in order at the current focus, then release them in reverse order. * Rejects when the provider does not support keyboard input at this scope. - * @param key - Provider-defined key name to press and release; not a chord. + * @param key - One provider-defined key name or a nonempty array of distinct keys to hold together. * @param modifiers - Platform- or provider-defined modifier names held for this keystroke; omitted means none. * @returns Whether the provider successfully executed the requested operation. */ - pressKey(key: string, modifiers?: string[]): Promise + pressKey(key: string | string[], modifiers?: string[]): Promise /** * Send one key-down or key-up event at the current input focus. * Does not send the opposite event. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index cbaa419951..f5b3452dc3 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -683,6 +683,8 @@ describe('input actions', () => { ['fractional scroll', ({ window }) => window.scroll({ x: 0, y: 0, deltaLinesX: 0.5, deltaLinesY: 0 })], ['negative drag point', ({ window }) => window.drag([0, 0], [-1, 3])], ['empty key', ({ app }) => app.pressKey('')], + ['empty chord', ({ app }) => app.pressKey([])], + ['empty chord member', ({ app }) => app.pressKey(['w', ''])], ] it.each<[string, (surfaces: Surfaces) => Promise]>([ ['getState windowId: Too small: expected number to be >0', ({ app }) => app.getWindow(0)], @@ -708,7 +710,8 @@ describe('input actions', () => { expect(f.dispatch).toHaveBeenCalledTimes(calls) }) - it.each<[string, string[] | undefined, string[]]>([ + it.each<[string | string[], string[] | undefined, string[]]>([ + [['w', 'd'], undefined, []], ['+', undefined, []], ['plus', [], []], ['+', ['command'], ['command']], diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 0b9c1b29f6..a39b9145d4 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -78,7 +78,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 33f3636750..8f608ec54e 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -1032,8 +1032,20 @@ "maximum": 4294967295 }, "key": { - "type": "string", - "minLength": 1 + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] }, "modifiers": { "type": "array", diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 587436227a..017e5b103b 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -80,7 +80,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index dd88fa8d9e..ef58a12bd2 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -80,7 +80,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From ee8f1d8dabf08bd2bb63667d0f578c0e7cc1708b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sat, 7 Nov 2026 12:24:27 +0800 Subject: [PATCH 066/149] fix(ui-subagent): prepare child catalogs on retry --- apps/web/tests/subagent-conversation.e2e.ts | 11 ++++++++++- packages/client/ui-subagent/README.i18n.yaml | 4 ++-- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- .../ui-subagent/src/client/SubagentHeaderLineage.tsx | 6 +++--- .../ui-subagent/tests/conversation-ui.client.spec.tsx | 6 ++++-- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 71993bbe52..018f5e3f18 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -365,7 +365,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) - it('discovers cold descendants one level at a time without leaving the parent conversation', async () => { + it('retries a failed cold catalog and discovers descendants without reopening the menu', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-unread-catalog')) const reads = apiCalls.filter(path => path === '/api/session/projections').length const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') @@ -383,7 +383,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(apiCalls.filter(path => path === '/api/session/projections')).toHaveLength(reads) expect(observe).not.toHaveBeenCalled() const parentUrl = page.url() + await page.route('**/api/session/projections', route => route.abort('failed'), { times: 1 }) await trigger.click() + const retry = tree.getByRole('button', { name: 'Retry', exact: true }) + await retry.waitFor() + expect(await tree.getByRole('button', { name: /^(Expand|Collapse) /u }).count()).toBe(0) + expect(apiCalls.filter(path => path === '/api/session/projections')).toHaveLength(reads + 1) + expect(projections).not.toHaveBeenCalled() + await retry.click() + await retry.waitFor({ state: 'detached' }) const expand = page.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }) await expand.waitFor() await expect.poll(() => projections.mock.calls.map(([request]) => request.sessionId)).toEqual([parentId, childId, oneShotId]) @@ -408,6 +416,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.keyboard.press('Escape') await page.locator('[data-subagent-panel]').waitFor({ state: 'detached' }) } finally { + await page.unroute('**/api/session/projections') observe.mockRestore() projections.mockRestore() } diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index ae80fd109c..e4ce63e820 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -30,8 +30,8 @@ en: c158f12dcaed87b7 zh: e5d315e7b1fb4fa9 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation: - en: c7a6ed23ce7ec331 - zh: 92b60359a490b270 + en: 08735d4cc7569cfa + zh: 517309ca6a540adf /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens: en: dcbc58d4cad47fa8 zh: e962a8cbdfd4a914 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 325dde5777..357453d99e 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -65,7 +65,7 @@ The header lineage renderer reads `subagentCatalog` only from `projectionsBySess Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. CSS anchors keep the panel attached to its trigger within layout-viewport size limits. Browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Internal scrolling, hover behavior, pinned opening, and focus restoration remain independent. -Explicit menu activation and branch expansion call `prepareCatalog` to read the current catalog and its direct children. Ancestor-title clicks navigate to that Session. Failed-read retry buttons call `refreshProjection`; the Session object layer deduplicates reads and refreshes previously requested projections after reconnect. +Explicit menu activation, branch expansion, and failed-read retries call `prepareCatalog` to read the current catalog and its direct children. Ancestor-title clicks navigate to that Session. The Session object layer deduplicates reads and refreshes previously requested projections after reconnect. Breadcrumb addresses derive from the Provider-bound Session address, loaded parent catalogs, or already-listed subagent lineage, including never-selected ancestors. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index fea6f5d76c..5498652e1b 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -65,7 +65,7 @@ kind: "package-reference" 每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。CSS 锚点让面板跟随触发器,并按布局视口限制尺寸。浏览器缩放和平移时面板随页面移动,因此可以超出视觉视口。内部滚动、悬停行为、点击固定展开和焦点恢复保持独立。 -显式激活菜单或展开分支时,`prepareCatalog` 读取当前目录及其直接子节点。点击祖先标题会导航到该 Session。失败读取的重试按钮调用 `refreshProjection`;Session 对象层合并重复读取,并在重连后刷新之前请求过的投影。 +显式激活菜单、展开分支或重试失败读取时,`prepareCatalog` 读取当前目录及其直接子节点。点击祖先标题会导航到该 Session。Session 对象层合并重复读取,并在重连后刷新之前请求过的投影。 面包屑地址从 Provider 所绑定的 Session 地址、已加载的 parent 目录或已列出的 subagent 谱系推导,也包括从未选中过的祖先。 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index ab2b6e6a4b..cffac90f99 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -29,7 +29,7 @@ export type SubagentCatalogInjected = { openChild: (address: SubagentAddress) => void openChildAside: (address: SubagentAddress) => void refreshProjection: (parentSessionId: SessionId) => void - /** Read this catalog and its direct children after explicit activation. */ + /** Read this catalog and its direct children after explicit activation or retry. */ prepareCatalog: (parentSessionId: SessionId) => Promise } @@ -498,7 +498,7 @@ type CatalogDropdownProps = CatalogDropdownSharedProps & ( function CatalogDropdown(props: CatalogDropdownProps) { const { rootSessionId, currentSessionId, displayTitle, openTitle, variant, - useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, prepareCatalog, t, hasSharedTasks = false, + useSessions, useSessionStatus, openChild, openChildAside, prepareCatalog, t, hasSharedTasks = false, } = props const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined const projections = useSessions(state => state.projectionsBySession) @@ -812,7 +812,7 @@ function CatalogDropdown(props: CatalogDropdownProps) { level={1} openChild={openChild} openChildAside={openChildAside} - refreshProjection={refreshProjection} + refreshProjection={prepareLevel} toggleBranch={toggleBranch} closeCatalog={() => { changeOpen(false) }} t={t} diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index ea189bcd8e..e3234cfef1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1297,7 +1297,8 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('status')).toBeNull() expect(input.refreshProjection).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: '重试' })) - expect(input.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD) + expect(input.prepareCatalog.mock.calls).toEqual([[PARENT], [CHILD]]) + expect(input.refreshProjection).not.toHaveBeenCalled() expect(input.openChild).not.toHaveBeenCalled() state = { ...state, projectionsBySession: { ...state.projectionsBySession, [CHILD]: { state: 'ready', values: { subagentCatalog: [] }, error: null }, @@ -1638,7 +1639,8 @@ describe('SubagentHeaderLineage', () => { hoverCatalog(screen.getByRole('button', { name: /0 个子智能体/ })) expect(screen.getByText('index down')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /重试/ })) - expect(failed.refreshProjection).toHaveBeenCalledWith(PARENT) + expect(failed.prepareCatalog).toHaveBeenCalledExactlyOnceWith(PARENT) + expect(failed.refreshProjection).not.toHaveBeenCalled() }) it.each(['absent', 'idle', 'loading', 'ready'] as const)('does not expose header-only descendants when the projection read is %s', (state) => { From ef34a844de6dd855aacb49c1fdd215273cd0b114 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 12:56:23 +0800 Subject: [PATCH 067/149] fix(forge-panel): refuse commit runs off the first-parent line and harden review state - Shift-chosen commits must follow each other as first parents; the picker says why it keeps a run that leaves the line, and the Host refuses one with forgePanel/commits-not-linear for the Branch scope and change requests. - The Branch scope lists its newest maxCommits commits with --no-show-signature, so log.showSignature cannot corrupt the listing. - Viewed marks merge with the stored entry on each write and follow the storage event, so tabs of one Session keep each other's marks. - .gitattributes patterns follow Git's wildmatch rules, checked against git check-attr; change digests include file mode lines. - GitHub commit pagination refuses a repeated cursor or a changed count; commits validates the repository before its host like the other reads. - The commit list is read once per head, base, or refresh; the lens menu hides the count of the disabled Tests lens. --- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/forge.md | 15 ++- docs/subsystems/forge.zh.md | 15 ++- .../client-ui-forge/README.i18n.yaml | 12 +- .../experimental/client-ui-forge/README.md | 6 +- .../experimental/client-ui-forge/README.zh.md | 6 +- .../client-ui-forge/src/client/ChangesTab.tsx | 12 +- .../src/client/CommitMenu.module.css | 9 ++ .../client-ui-forge/src/client/CommitMenu.tsx | 58 ++++++--- .../src/client/ReviewControls.tsx | 14 +- .../client-ui-forge/src/client/locales.ts | 32 +++-- .../src/client/viewed-files.ts | 71 +++++++--- .../client-ui-forge/src/commit-range.ts | 27 ++++ .../experimental/client-ui-forge/src/diff.ts | 12 +- .../experimental/client-ui-forge/src/index.ts | 20 +-- .../client-ui-forge/src/layers.ts | 17 ++- .../client-ui-forge/src/local-diff.ts | 32 +++-- .../experimental/client-ui-forge/src/types.ts | 17 ++- .../client-ui-forge/src/wildmatch.ts | 121 ++++++++++++++++++ .../tests/changes-tab.client.spec.tsx | 29 ++++- .../tests/controller.host.spec.ts | 28 +++- .../client-ui-forge/tests/diff.host.spec.ts | 12 ++ .../tests/local-diff.host.spec.ts | 59 ++++++++- .../tests/review-controls.client.spec.tsx | 40 +++++- .../tests/viewed-files.client.spec.ts | 42 ++++++ .../tests/wildmatch.host.spec.ts | 59 +++++++++ .../client-ui-forge/tsconfig.client.json | 1 + .../client-ui-forge/tsconfig.host.json | 2 + .../forge-github/README.i18n.yaml | 4 +- packages/experimental/forge-github/README.md | 2 +- .../experimental/forge-github/README.zh.md | 2 +- .../experimental/forge-github/src/commits.ts | 18 +-- .../experimental/forge-github/src/provider.ts | 3 +- .../forge-github/tests/provider.spec.ts | 25 +++- .../extensions/tool-cordis/src/api-catalog.ts | 8 +- 36 files changed, 690 insertions(+), 148 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/commit-range.ts create mode 100644 packages/experimental/client-ui-forge/src/wildmatch.ts create mode 100644 packages/experimental/client-ui-forge/tests/wildmatch.host.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 435668a4da..d85683e33e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1081,7 +1081,7 @@ export interface CotTranslationPreferences { ## `@deepseek-ai/dsh-experimental-client-ui-forge` - `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` -- `source`: [`packages/experimental/client-ui-forge/src/types.ts:374`](../packages/experimental/client-ui-forge/src/types.ts) +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:381`](../packages/experimental/client-ui-forge/src/types.ts) ```ts config-catalog /** Host settings of the forge panel Remote. */ @@ -1109,7 +1109,7 @@ export interface Config { maxUntrackedFiles?: number /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ maxCachedDiffBytes?: number - /** Most commits of the `branch` scope listed, oldest first. */ + /** Most commits of the `branch` scope listed; a longer branch lists its newest ones. */ maxCommits?: number /** * Globs of files in the `test` layer, which the changes view can hide, beside the built-in rules and checked before diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c5713d0173..ceab76dfec 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1083,7 +1083,7 @@ export interface CotTranslationPreferences { ## `@deepseek-ai/dsh-experimental-client-ui-forge` - `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` -- `source`: [`packages/experimental/client-ui-forge/src/types.ts:374`](../packages/experimental/client-ui-forge/src/types.ts) +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:381`](../packages/experimental/client-ui-forge/src/types.ts) ```ts config-catalog /** Host settings of the forge panel Remote. */ @@ -1111,7 +1111,7 @@ export interface Config { maxUntrackedFiles?: number /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ maxCachedDiffBytes?: number - /** Most commits of the `branch` scope listed, oldest first. */ + /** Most commits of the `branch` scope listed; a longer branch lists its newest ones. */ maxCommits?: number /** * Globs of files in the `test` layer, which the changes view can hide, beside the built-in rules and checked before diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 12407f64f4..b5156c101f 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -771,8 +771,15 @@ interface ForgeChangeRequestDiff { ``` ```ts type-equiv -/** Commits of the `branch` scope, which the changes view can show one or several of. */ -type ForgeLocalCommits = ForgeCommitList +/** Commits of the `branch` scope at one HEAD commit, which the changes view can show one or several of. */ +interface ForgeLocalCommits { + /** HEAD commit the list was read at. */ + readonly headSha: string + /** Commits oldest first, ending with HEAD's commit. */ + readonly commits: readonly ForgeCommit[] + /** Whether the branch has more commits than `maxCommits`; the listed ones are the newest. */ + readonly truncated: boolean +} ``` ```ts type-equiv @@ -1084,6 +1091,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch * chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without * a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; + * `forgePanel/commits-not-linear` when a chosen commit's first parent is not the commit listed before it; * `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before * any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @@ -1095,7 +1103,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @param session - Session whose checkout is read. * @param base - the base ref of the `branch` scope, or null for its automatic fork point. * @param signal - request cancellation. - * @returns at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. + * @returns the newest `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not * exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`. */ @@ -1155,6 +1163,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head. * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; * `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order; + * `forgePanel/commits-not-linear` when a chosen commit's first parent is not the commit listed before it; * `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again; * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; * `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index e8c5566df0..2ca42cde71 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -771,8 +771,15 @@ interface ForgeChangeRequestDiff { ``` ```ts type-equiv -/** Commits of the `branch` scope, which the changes view can show one or several of. */ -type ForgeLocalCommits = ForgeCommitList +/** Commits of the `branch` scope at one HEAD commit, which the changes view can show one or several of. */ +interface ForgeLocalCommits { + /** HEAD commit the list was read at. */ + readonly headSha: string + /** Commits oldest first, ending with HEAD's commit. */ + readonly commits: readonly ForgeCommit[] + /** Whether the branch has more commits than `maxCommits`; the listed ones are the newest. */ + readonly truncated: boolean +} ``` ```ts type-equiv @@ -1084,6 +1091,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch * chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without * a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; + * `forgePanel/commits-not-linear` when a chosen commit's first parent is not the commit listed before it; * `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before * any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @@ -1095,7 +1103,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @param session - Session whose checkout is read. * @param base - the base ref of the `branch` scope, or null for its automatic fork point. * @param signal - request cancellation. - * @returns at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. + * @returns the newest `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not * exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`. */ @@ -1155,6 +1163,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current * @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head. * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; * `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order; + * `forgePanel/commits-not-linear` when a chosen commit's first parent is not the commit listed before it; * `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again; * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; * `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 0ced27cb60..5b25741184 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: bc552fc8409a6101 - zh: 7462332ce44e56e0 + en: 05ee6d9ba0b8d2c7 + zh: 98c988e8f957511f /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 37dc1481037fac8a - zh: 910bd56856fe2896 + en: 7495fe1e0d0ee643 + zh: f820a5790ae6e2a7 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b @@ -30,8 +30,8 @@ en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 2f96a33996a89344 - zh: a3ed868a4c2747c9 + en: 5e29ac36aad15d02 + zh: e46339bb637a69ef /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 36832b09d6..27ff8a01c3 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -40,7 +40,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Local changes card** — whenever the `branch` scope changes files, independently of bound change requests, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, which is always the default base below, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered when the code host repository is recognized and no open change request (including drafts) has the checked-out branch as its head, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. -- **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first; a click shows one commit and a Shift click the commits from the last one clicked; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and a chosen commit that is gone offers Show all commits. +- **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. @@ -69,7 +69,7 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, and hunks, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point with `log --topo-order --reverse`, at most `maxCommits`. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. @@ -141,7 +141,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. -- **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, 250 for GitHub, and a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. +- **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. - **Review page** — deferred: a left-navigation page that lists the user's change requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 6dc7618038..4aca1d2c38 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -40,7 +40,7 @@ kind: "package-reference" - **本地变更卡片**——`branch` 范围有文件变更时始终显示,不受已关联变更请求影响。变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`,始终是下文的默认基准)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库、且没有以当前检出分支为源分支的打开中变更请求(含草稿)时,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 -- **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在时提供“查看全部提交”。 +- **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 @@ -69,7 +69,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态与 hunk 组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --topo-order --reverse` 列出分叉点之后的提交,最多 `maxCommits` 个。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 @@ -141,7 +141,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 -- **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为 250 个),每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 +- **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 - **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 5c46b6f850..f57bb937bb 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -165,6 +165,7 @@ const FAILURE: Partial> = { 'forgePanel/diff-too-large': 'diff.tooLarge', 'forgePanel/filter-config-failed': 'diff.filterConfigFailed', 'forgePanel/commits-missing': 'commits.missing', + 'forgePanel/commits-not-linear': 'commits.notLinear', } /** Failures of a chosen base branch, which offer the automatic fork point instead of a retry. */ @@ -520,8 +521,10 @@ export function ChangesTab({ const pending = entry !== undefined && entry.ref !== null && entry.update === null const head = entry?.snapshot?.headSha ?? (pending ? null : '') const version = state === null ? null : source.kind === 'local' ? `${scope}\n${JSON.stringify(state.local)}` : head - // The commits change with the head of a change request, and with the local branch or its base. - const commitsVersion = source.kind === 'local' ? `${baseRef ?? ''}\n${JSON.stringify(state?.local ?? null)}` : head ?? '' + const headCommit = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.commit : null + // The commits change with the head of a change request, with the local HEAD, branch, or base, and on a refresh. + const commitsVersion = `${attempt}\n${source.kind === 'local' + ? `${baseRef ?? ''}\n${headCommit ?? ''}\n${JSON.stringify(state?.local ?? null)}` : head ?? ''}` const dataKey = source.kind === 'local' ? JSON.stringify(localScope) : `change\n${JSON.stringify(changeCommits)}` const [loaded, setLoaded] = useState<{ readonly key: string; readonly data: ChangesData } | null>(null) const [failure, setFailure] = useState<{ readonly key: string; readonly code: string } | null>(null) @@ -606,7 +609,7 @@ export function ChangesTab({ const retry = baseFailure === undefined && (message === undefined || failed === 'forgePanel/filter-config-failed') const automaticBase = baseFailure !== undefined ? ( - ) : failed === 'forgePanel/commits-missing' && ( + ) : (failed === 'forgePanel/commits-missing' || failed === 'forgePanel/commits-not-linear') && ( ) @@ -851,7 +854,8 @@ export function ChangesTab({ const treeShown = allFiles.length > 0 && treePosition !== 'hidden' const lensProps = { lens, counts: lensCounts, hideTests, choose: (next: ChangesLens) => { actions.lensed(tab.id, next) }, t } const commitMenu = source.kind === 'local' && scope !== 'branch' ? null : { actions.committed(tab.id, next) }} t={t} /> const toolbarStyle: CSSProperties & { '--forge-tree-width': string } = { '--forge-tree-width': `${treeWidth}px` } return ( diff --git a/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css b/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css index 34159de448..5cf60040eb 100644 --- a/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css +++ b/packages/experimental/client-ui-forge/src/client/CommitMenu.module.css @@ -128,3 +128,12 @@ font-size: 11px; line-height: 16px; } + +/* Why a Shift click left the shown commits unchanged; outside the scrolled rows so it stays in view. */ +.rejected { + padding: 8px 12px; + border-top: 0.5px solid var(--dsw-alias-border-l3); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} diff --git a/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx b/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx index 58e7702b75..17ba7fbd89 100644 --- a/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx +++ b/packages/experimental/client-ui-forge/src/client/CommitMenu.tsx @@ -1,6 +1,7 @@ /** * Commit picker of the changes header: every commit, or one commit, or with Shift a run of consecutive commits of a - * change request or of the local `branch` scope, listed oldest first like the code host's commit list. + * change request or of the local `branch` scope, listed oldest first like the code host's commit list. A run is + * accepted only when each of its commits has the one listed before it as its first parent. */ import { useEffect, useRef, useState, type ReactNode } from 'react' @@ -11,7 +12,8 @@ import { import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ForgeCommitList } from '@deepseek-ai/dsh-experimental-forge/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import type { ForgeCommitRange } from '../types.ts' +import { rangeStart } from '../commit-range.ts' +import type { ForgeCommitRange, ForgeLocalCommits } from '../types.ts' import { formatAgo } from './format.ts' import type { NS } from './locales.ts' import css from './CommitMenu.module.css' @@ -26,8 +28,10 @@ export interface CommitMenuProps extends PropsLocale { /** Whether the owning tab is visible; a hidden tab keeps the menu closed. */ readonly visible: boolean /** Read the commits; null means the source cannot show single commits. `signal` aborts the read. */ - readonly list: (signal: AbortSignal) => Promise> - /** Changes when the commits may have changed, such as a new head commit; the next opening reads them again. */ + readonly list: (signal: AbortSignal) => Promise> + /** Which commits a truncated list keeps: a change request lists its oldest ones, the local branch its newest ones. */ + readonly kept: 'oldest' | 'newest' + /** Changes when the commits may have changed, such as a new head commit or a refresh; the commits are read once per value. */ readonly version: string /** Show consecutive commits, or every commit for null. */ readonly choose: (commits: ForgeCommitRange | null) => void @@ -51,39 +55,54 @@ export function commitsLabel(chosen: ForgeCommitRange | null, t: PropsLocale } | null>(null) + const [rejected, setRejected] = useState(false) + const [listing, setListing] = useState<{ + readonly key: string + readonly result: RemoteResult + } | null>(null) const shift = useRef(false) const anchor = useRef(chosen?.from ?? null) const loaded = useRef(null) const shown = visible && open - const current = listing?.version === version ? listing.result : null - // The list is read once per version, which tells whether the source has commits to pick, and again on each opening. + const key = `${version}\n${String(attempt)}` + const current = listing?.key === key ? listing.result : null + // The list is read once per version, which also tells whether the source has commits to pick, and again on a retry. useEffect(() => { - if (!visible || (!shown && loaded.current === version)) return + if (!visible || loaded.current === key) return const controller = new AbortController() void list(controller.signal).then((result) => { if (controller.signal.aborted) return - loaded.current = version - setListing({ version, result }) + loaded.current = key + setListing({ key, result }) }) return () => { controller.abort() } - }, [visible, shown, version, attempt, list]) + }, [visible, key, list]) if (current?.ok === true && current.value === null) return null const commits = current?.ok === true && current.value !== null ? current.value : null - const pick = (sha: string | null): void => { + const close = (): void => { setOpen(false) + setRejected(false) + } + const pick = (sha: string | null): void => { if (sha === null || commits === null) { + close() anchor.current = null if (chosen !== null) choose(null) return } const order = commits.commits.map(commit => commit.sha) const start = shift.current && anchor.current !== null && order.includes(anchor.current) ? anchor.current : sha - if (!shift.current || start === sha) anchor.current = sha const [from, to] = order.indexOf(start) <= order.indexOf(sha) ? [start, sha] : [sha, start] + // Two ends cannot name a run that leaves the first-parent line, so the menu stays open and says why. + if (rangeStart(commits.commits, { from, to }) === 'not-linear') { + setRejected(true) + return + } + close() + if (!shift.current || start === sha) anchor.current = sha if (from !== chosen?.from || to !== chosen.to) choose({ from, to }) } const inChosen = (index: number): boolean => { @@ -126,7 +145,11 @@ export function CommitMenu({ chosen, visible, list, version, choose, t }: Commit ) })} - {commits.truncated &&
{t('commits.truncated', { count: commits.commits.length })}
} + {commits.truncated && ( +
+ {t(kept === 'oldest' ? 'commits.truncatedOldest' : 'commits.truncatedNewest', { count: commits.commits.length })} +
+ )} } @@ -139,12 +162,12 @@ export function CommitMenu({ chosen, visible, list, version, choose, t }: Commit align="end" className={css.anchor} listClassName={css.menu} - onClose={() => { setOpen(false) }} + onClose={close} anchor={( @@ -157,6 +180,7 @@ export function CommitMenu({ chosen, visible, list, version, choose, t }: Commit onKeyDownCapture={(event) => { shift.current = event.shiftKey }}> {body} + {rejected &&
{t('commits.notLinear')}
} ) } diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx index ba68601ede..735c0148c4 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx @@ -82,11 +82,15 @@ export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): */ export function LensMenu({ lens, counts, hideTests, choose, visible, t }: LensProps & { readonly visible: boolean }): ReactNode { const [open, setOpen] = useState(false) - const items: MenuEntry[] = LENSES.map(item => ({ - id: item, - disabled: item === 'test' && hideTests, - label: {t(LENS_LABEL[item])}{counts[item]}, - })) + const items: MenuEntry[] = LENSES.map((item) => { + // Like the segment, a disabled lens shows no count, since hidden test files are not counted. + const disabled = item === 'test' && hideTests + return { + id: item, + disabled, + label: {t(LENS_LABEL[item])}{disabled ? '' : counts[item]}, + } + }) return ( { setOpen(false) }} diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 9804e0d655..81cb739802 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -199,9 +199,11 @@ export const zh = { 'commits.shiftHint': '按住 Shift 选择连续多个提交', 'commits.loading': '正在读取提交…', 'commits.failed': '无法读取提交', - 'commits.truncated': '只列出了最早的 {count} 个提交', + 'commits.truncatedOldest': '只列出了最早的 {count} 个提交', + 'commits.truncatedNewest': '只列出了最新的 {count} 个提交', 'commits.aria': '查看的提交:{commits}', 'commits.missing': '所选的提交已不在其中', + 'commits.notLinear': '这些提交不在同一条提交链上', 'commits.showAll': '查看全部提交', 'diff.noMatchingFiles': '没有匹配的文件', 'diff.refresh': '刷新变更', @@ -507,9 +509,11 @@ export const en: Record = { 'commits.shiftHint': 'Hold Shift to select consecutive commits', 'commits.loading': 'Loading commits…', 'commits.failed': 'Could not load commits', - 'commits.truncated': 'Only the earliest {count} commits are listed', + 'commits.truncatedOldest': 'Only the earliest {count} commits are listed', + 'commits.truncatedNewest': 'Only the latest {count} commits are listed', 'commits.aria': 'Commits shown: {commits}', 'commits.missing': 'The selected commits are no longer included', + 'commits.notLinear': 'These commits are not on one line of history', 'commits.showAll': 'Show all commits', 'diff.noMatchingFiles': 'No matching files', 'diff.refresh': 'Refresh changes', @@ -819,9 +823,11 @@ export const ja: Record = { 'commits.shiftHint': 'Shift を押しながら連続するコミットを選択', 'commits.loading': 'コミットを読み込み中…', 'commits.failed': 'コミットを読み込めませんでした', - 'commits.truncated': '最初の {count} 件のコミットのみ表示しています', + 'commits.truncatedOldest': '最初の {count} 件のコミットのみ表示しています', + 'commits.truncatedNewest': '最新の {count} 件のコミットのみ表示しています', 'commits.aria': '表示中のコミット:{commits}', 'commits.missing': '選択したコミットは含まれなくなりました', + 'commits.notLinear': 'これらのコミットは 1 本の履歴上にありません', 'commits.showAll': 'すべてのコミットを表示', 'diff.noMatchingFiles': '一致するファイルがありません', 'diff.refresh': '変更を更新', @@ -1124,9 +1130,11 @@ export const es: Record = { 'commits.shiftHint': 'Mantén Mayús para seleccionar commits consecutivos', 'commits.loading': 'Cargando commits…', 'commits.failed': 'No se pudieron cargar los commits', - 'commits.truncated': 'Solo se muestran los {count} commits más antiguos', + 'commits.truncatedOldest': 'Solo se muestran los {count} commits más antiguos', + 'commits.truncatedNewest': 'Solo se muestran los {count} commits más recientes', 'commits.aria': 'Commits mostrados: {commits}', 'commits.missing': 'Los commits seleccionados ya no están incluidos', + 'commits.notLinear': 'Estos commits no están en una misma línea del historial', 'commits.showAll': 'Ver todos los commits', 'diff.noMatchingFiles': 'No hay archivos coincidentes', 'diff.refresh': 'Actualizar cambios', @@ -1429,9 +1437,11 @@ export const fr: Record = { 'commits.shiftHint': 'Maintenez Maj pour sélectionner des commits consécutifs', 'commits.loading': 'Chargement des commits…', 'commits.failed': 'Impossible de charger les commits', - 'commits.truncated': 'Seuls les {count} commits les plus anciens sont listés', + 'commits.truncatedOldest': 'Seuls les {count} commits les plus anciens sont listés', + 'commits.truncatedNewest': 'Seuls les {count} commits les plus récents sont listés', 'commits.aria': 'Commits affichés : {commits}', 'commits.missing': 'Les commits sélectionnés ne sont plus inclus', + 'commits.notLinear': 'Ces commits ne sont pas sur une même ligne d’historique', 'commits.showAll': 'Afficher tous les commits', 'diff.noMatchingFiles': 'Aucun fichier correspondant', 'diff.refresh': 'Actualiser les modifications', @@ -1734,9 +1744,11 @@ export const de: Record = { 'commits.shiftHint': 'Mit gedrückter Umschalttaste aufeinanderfolgende Commits wählen', 'commits.loading': 'Commits werden geladen…', 'commits.failed': 'Commits konnten nicht geladen werden', - 'commits.truncated': 'Nur die ältesten {count} Commits werden aufgeführt', + 'commits.truncatedOldest': 'Nur die ältesten {count} Commits werden aufgeführt', + 'commits.truncatedNewest': 'Nur die neuesten {count} Commits werden aufgeführt', 'commits.aria': 'Angezeigte Commits: {commits}', 'commits.missing': 'Die gewählten Commits sind nicht mehr enthalten', + 'commits.notLinear': 'Diese Commits liegen nicht auf einer Verlaufslinie', 'commits.showAll': 'Alle Commits anzeigen', 'diff.noMatchingFiles': 'Keine passenden Dateien', 'diff.refresh': 'Änderungen aktualisieren', @@ -2039,9 +2051,11 @@ export const ru: Record = { 'commits.shiftHint': 'Удерживайте Shift, чтобы выбрать несколько коммитов подряд', 'commits.loading': 'Загрузка коммитов…', 'commits.failed': 'Не удалось загрузить коммиты', - 'commits.truncated': 'Показаны только первые {count} коммитов', + 'commits.truncatedOldest': 'Показаны только первые {count} коммитов', + 'commits.truncatedNewest': 'Показаны только последние {count} коммитов', 'commits.aria': 'Показанные коммиты: {commits}', 'commits.missing': 'Выбранные коммиты больше не входят в список', + 'commits.notLinear': 'Эти коммиты не лежат на одной линии истории', 'commits.showAll': 'Показать все коммиты', 'diff.noMatchingFiles': 'Нет подходящих файлов', 'diff.refresh': 'Обновить изменения', @@ -2344,9 +2358,11 @@ export const pt: Record = { 'commits.shiftHint': 'Segure Shift para selecionar commits consecutivos', 'commits.loading': 'Carregando commits…', 'commits.failed': 'Não foi possível carregar os commits', - 'commits.truncated': 'Apenas os {count} commits mais antigos estão listados', + 'commits.truncatedOldest': 'Apenas os {count} commits mais antigos estão listados', + 'commits.truncatedNewest': 'Apenas os {count} commits mais recentes estão listados', 'commits.aria': 'Commits exibidos: {commits}', 'commits.missing': 'Os commits selecionados não estão mais incluídos', + 'commits.notLinear': 'Esses commits não estão em uma mesma linha do histórico', 'commits.showAll': 'Ver todos os commits', 'diff.noMatchingFiles': 'Nenhum arquivo correspondente', 'diff.refresh': 'Atualizar alterações', diff --git a/packages/experimental/client-ui-forge/src/client/viewed-files.ts b/packages/experimental/client-ui-forge/src/client/viewed-files.ts index a603a26a3e..cb07032b79 100644 --- a/packages/experimental/client-ui-forge/src/client/viewed-files.ts +++ b/packages/experimental/client-ui-forge/src/client/viewed-files.ts @@ -2,7 +2,8 @@ * Browser-persisted viewed marks of changed files. Each Session keeps one storage entry that maps a diff source, * such as a change request or a local scope with its base ref and chosen commits, to the digest of each file's * change when the reader marked it viewed. A mark whose digest differs from the file's current change reports the - * file as changed since it was viewed. + * file as changed since it was viewed. Several browser tabs of one Session share the entry: each mark rewrites the + * entry as storage holds it, and marks saved by another tab reach this one through the `storage` event. */ import { z } from 'zod' @@ -20,6 +21,8 @@ const source = z.object({ at: z.number(), files: z.record(z.string(), z.string() const saved = z.record(z.string(), source) const index = z.array(z.string()) +type Sources = Readonly>> + /** Viewed state of one file in the diff a reader sees. */ export type ViewedState = 'viewed' | 'changed' | 'unviewed' @@ -40,13 +43,15 @@ export function viewedStateOf(marks: ViewedMarks, path: string, digest: string): return marked === undefined ? 'unviewed' : marked === digest ? 'viewed' : 'changed' } -/** One Session's viewed marks; unreadable storage leaves the marks empty and refuses later writes. */ +/** + * One Session's viewed marks. Unreadable storage shows no marks and refuses writes while it stays unreadable. While + * anything subscribes, marks another tab saves replace the ones shown. + */ export class ViewedFiles { - private sources: Record> = {} + private sources: Sources private readonly listeners = new Set<() => void>() private readonly snapshots = new Map() private readonly key: string - private readonly readable: boolean /** * @param session - storage identity of the Session, such as its id. @@ -54,14 +59,7 @@ export class ViewedFiles { */ constructor(private readonly session: string, private readonly now: () => number = Date.now) { this.key = `${PREFIX}.${JSON.stringify(session)}` - let readable = true - try { - const raw = localStorage.getItem(this.key) - if (raw !== null) this.sources = saved.parse(JSON.parse(raw)) - } catch (_error) { - readable = false - } - this.readable = readable + this.sources = this.load() ?? {} } /** @@ -85,8 +83,16 @@ export class ViewedFiles { * @returns unsubscribe. */ readonly subscribe = (listener: () => void): (() => void) => { + // Storage events that arrived while nothing listened are lost, so the first subscriber reads storage again. + if (this.listeners.size === 0) { + window.addEventListener('storage', this.stored) + this.reload() + } this.listeners.add(listener) - return () => { this.listeners.delete(listener) } + return () => { + this.listeners.delete(listener) + if (this.listeners.size === 0) window.removeEventListener('storage', this.stored) + } } /** @@ -99,11 +105,13 @@ export class ViewedFiles { */ mark(key: string, files: readonly { readonly path: string; readonly digest: string }[], path: string, viewed: boolean): boolean { const file = files.find(item => item.path === path) - if (!this.readable || file === undefined) return false + // Another tab of the Session may have saved marks since this one last read them; the write keeps theirs. + const current = this.load() + if (current === undefined || file === undefined) return false const present = new Set(files.map(item => item.path)) - const kept = Object.entries(this.sources[key]?.files ?? {}).filter(([marked]) => marked !== path && present.has(marked)) + const kept = Object.entries(current[key]?.files ?? {}).filter(([marked]) => marked !== path && present.has(marked)) const sources = { - ...this.sources, + ...current, [key]: { at: this.now(), files: Object.fromEntries(viewed ? [...kept, [path, file.digest]] : kept) }, } const recent = Object.entries(sources).sort(([, left], [, right]) => right.at - left.at).slice(0, MAX_SOURCES) @@ -113,13 +121,38 @@ export class ViewedFiles { } catch (_error) { return false } - this.sources = next - this.snapshots.clear() + this.replace(next) this.remember() - for (const listener of this.listeners) listener() return true } + /** The saved marks of this Session, or undefined when storage cannot be read or holds an entry it cannot parse. */ + private load(): Sources | undefined { + try { + const raw = localStorage.getItem(this.key) + return raw === null ? {} : saved.parse(JSON.parse(raw)) + } catch (_error) { + return undefined + } + } + + /** Show the saved marks when they differ from the shown ones. */ + private reload(): void { + const sources = this.load() ?? {} + if (JSON.stringify(sources) !== JSON.stringify(this.sources)) this.replace(sources) + } + + private replace(sources: Sources): void { + this.sources = sources + this.snapshots.clear() + for (const listener of this.listeners) listener() + } + + /** Another document changed this Session's entry, or with a null key cleared the whole storage. */ + private readonly stored = (event: StorageEvent): void => { + if (event.key === this.key || event.key === null) this.reload() + } + /** Move this Session to the front of the index and drop the marks of Sessions beyond {@link MAX_SESSIONS}. */ private remember(): void { const key = `${PREFIX}.sessions` diff --git a/packages/experimental/client-ui-forge/src/commit-range.ts b/packages/experimental/client-ui-forge/src/commit-range.ts new file mode 100644 index 0000000000..b2624e1b11 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/commit-range.ts @@ -0,0 +1,27 @@ +/** + * Checks of a chosen run of commits against a commit list, shared by the Host, which reads the run's changes, and the + * commit picker, which refuses a run before asking for them. + */ + +import type { ForgeCommit } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgeCommitRange } from './types.ts' + +/** + * Find the first commit of a chosen run in a commit list. Two ends describe the run only when each commit after the + * first has the commit listed before it as its first parent, so the changes from the first commit's first parent to the + * last commit are exactly the run's changes. + * @param commits - listed commits, oldest first. + * @param range - the chosen run. + * @returns the first chosen commit; `missing` when an end is not listed or `to` is listed before `from`; `not-linear` + * when a commit of the run does not have the commit listed before it as its first parent. + */ +export function rangeStart(commits: readonly ForgeCommit[], range: ForgeCommitRange): ForgeCommit | 'missing' | 'not-linear' { + const first = commits.findIndex(commit => commit.sha === range.from) + const last = commits.findIndex(commit => commit.sha === range.to) + const start = commits[first] + if (start === undefined || last < first) return 'missing' + for (let index = first + 1; index <= last; index++) { + if (commits[index]?.parent !== commits[index - 1]?.sha) return 'not-linear' + } + return start +} diff --git a/packages/experimental/client-ui-forge/src/diff.ts b/packages/experimental/client-ui-forge/src/diff.ts index 4ec3d62b71..02c75bf06b 100644 --- a/packages/experimental/client-ui-forge/src/diff.ts +++ b/packages/experimental/client-ui-forge/src/diff.ts @@ -50,18 +50,20 @@ export function parseDiff(patch: string): ParsedDiffFile[] { // Each header matches its parsed section only when every section starts with a Git header. const header = binary && headers.length === parsed.length ? String(headers[index]) : '' const blobs = INDEX_LINE.exec(header)?.[1] ?? '' + // A mode change, such as `chmod +x`, shows only in the `old mode` and `new mode` header lines. + const modes = `${section.oldMode ?? ''}..${section.newMode ?? ''}` return [{ path, previousPath, status, binary, oversized: false, untracked: false, additions, deletions, hunks, - digest: changeDigest(path, previousPath, status, hunks, blobs), + digest: changeDigest(path, previousPath, status, hunks, blobs, modes), }] }) } -/** Digest of one change: its paths, status, hunks, and the identity of content that hunks do not show. */ +/** Digest of one change: its paths, status, hunks, file modes, and the identity of content that hunks do not show. */ function changeDigest( - path: string, previousPath: string | null, status: ForgeDiffStatus, hunks: readonly ForgeDiffHunk[], content: string, + path: string, previousPath: string | null, status: ForgeDiffStatus, hunks: readonly ForgeDiffHunk[], content: string, modes: string, ): string { - return createHash('sha256').update(JSON.stringify([path, previousPath, status, hunks, content])).digest('hex').slice(0, 32) + return createHash('sha256').update(JSON.stringify([path, previousPath, status, hunks, content, modes])).digest('hex').slice(0, 32) } /** @@ -103,7 +105,7 @@ function addedFile(path: string, hunks: readonly ForgeDiffHunk[], { binary, over }): ParsedDiffFile { return { path, previousPath: null, status: 'added', binary, oversized, untracked: true, - additions: hunks[0]?.newLines ?? 0, deletions: 0, hunks, digest: changeDigest(path, null, 'added', hunks, content), + additions: hunks[0]?.newLines ?? 0, deletions: 0, hunks, digest: changeDigest(path, null, 'added', hunks, content, ''), } } diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 3dc9e37608..708eb757f4 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -23,9 +23,10 @@ import { gitIn, inspectCheckout, listBranches } from './git.ts' import type { Inspection } from './git.ts' import { parseDiff } from './diff.ts' import { - aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, DiffTooLarge, FilterConfigError, forkPoint, localCommits, localDiff, - localText, NoForkPoint, + aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, CommitsNotLinear, DiffTooLarge, FilterConfigError, forkPoint, + localCommits, localDiff, localText, NoForkPoint, } from './local-diff.ts' +import { rangeStart } from './commit-range.ts' import { ATTRIBUTES_PATH, FileLayers } from './layers.ts' import { missingWorktree, SessionPanel } from './panel.ts' import { PatchCache } from './patch-cache.ts' @@ -286,6 +287,7 @@ export class ForgePanelController extends TypertRemoteService { * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch * chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without * a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; + * `forgePanel/commits-not-linear` when a chosen commit's first parent is not the commit listed before it; * `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before * any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @@ -307,7 +309,7 @@ export class ForgePanelController extends TypertRemoteService { * @param session - Session whose checkout is read. * @param base - the base ref of the `branch` scope, or null for its automatic fork point. * @param signal - request cancellation. - * @returns at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. + * @returns the newest `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD's commit. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not * exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`. */ @@ -395,6 +397,7 @@ export class ForgePanelController extends TypertRemoteService { * @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head. * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; * `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order; + * `forgePanel/commits-not-linear` when a chosen commit's first parent is not the commit listed before it; * `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again; * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; * `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host @@ -675,12 +678,10 @@ export class ForgePanelController extends TypertRemoteService { const list = await this.home.forge.commits(ref, signal) if (list === undefined) throw new RemoteError('forgePanel/diff-failed', 'The code host provider does not read commits', {}) if ('code' in list) throw readError(list) - const first = list.commits.findIndex(commit => commit.sha === commits.from) - const last = list.commits.findIndex(commit => commit.sha === commits.to) - if (first === -1 || last < first) { - throw new RemoteError('forgePanel/commits-missing', 'The chosen commits are no longer in the change request', {}) - } - const parent = list.commits[first]?.parent ?? null + const start = rangeStart(list.commits, commits) + if (start === 'missing') throw new RemoteError('forgePanel/commits-missing', 'The chosen commits are no longer in the change request', {}) + if (start === 'not-linear') throw new RemoteError('forgePanel/commits-not-linear', 'The chosen commits are not one run of first parents', {}) + const parent = start.parent if (parent === null) throw new RemoteError('forgePanel/diff-failed', 'The first chosen commit has no parent to compare with', {}) const patch = await this.home.forge.compare(ref, parent, commits.to, signal) if (typeof patch !== 'string') throw readError(patch) @@ -842,6 +843,7 @@ async function diffing(reading: Promise): Promise { if (error instanceof NoForkPoint) throw new RemoteError('forgePanel/no-fork-point', error.message, {}, { cause: error }) if (error instanceof BaseMissing) throw new RemoteError('forgePanel/base-missing', error.message, {}, { cause: error }) if (error instanceof CommitsMissing) throw new RemoteError('forgePanel/commits-missing', error.message, {}, { cause: error }) + if (error instanceof CommitsNotLinear) throw new RemoteError('forgePanel/commits-not-linear', error.message, {}, { cause: error }) throw error } } diff --git a/packages/experimental/client-ui-forge/src/layers.ts b/packages/experimental/client-ui-forge/src/layers.ts index c7ea40940e..8b6eb79a93 100644 --- a/packages/experimental/client-ui-forge/src/layers.ts +++ b/packages/experimental/client-ui-forge/src/layers.ts @@ -8,6 +8,7 @@ import picomatch from 'picomatch' import type { ParsedDiffFile } from './diff.ts' import type { ForgeDiffFile, ForgeFileLayer } from './types.ts' +import { wildmatch } from './wildmatch.ts' /** Path of the attributes file read beside each diff, relative to the repository root. */ export const ATTRIBUTES_PATH = '.gitattributes' @@ -81,9 +82,10 @@ function matcherOf(globs: readonly string[]): Matcher { * an earlier one. `dsh-review-layer=` names `test`, `interface`, or `implementation`; another value, * `-dsh-review-layer`, and `!dsh-review-layer` leave the layer unspecified. `linguist-generated` and `=true` set * the generated attribute, `-linguist-generated` and `=false` unset it, and `!linguist-generated` returns it to - * unspecified. A pattern without `/` matches a file name at any depth, and one with `/` matches from the repository - * root. Macro definitions, quoted patterns, and patterns ending in `/`, which name directories and so never match a - * file, are skipped. + * unspecified. Patterns follow Git's wildmatch rules, as {@link wildmatch} describes: a pattern without `/` matches a + * file name at any depth, and one with `/` matches the path from the repository root. Macro definitions, quoted + * patterns, negated patterns, which Git ignores in attributes, and patterns ending in `/`, which name directories and + * so never match a file, are skipped. * @param attributes - the text of the root `.gitattributes`, empty without one. * @returns the attributes of a repository-relative path. */ @@ -97,7 +99,7 @@ export function attributesOf(attributes: string): (path: string) => PathAttribut for (const raw of attributes.split(/\r?\n/)) { const [pattern, ...fields] = raw.trim().split(/\s+/) if (pattern === undefined || pattern === '' || pattern.startsWith('#') || pattern.startsWith('[attr]') - || pattern.startsWith('"') || pattern.endsWith('/')) continue + || pattern.startsWith('"') || pattern.startsWith('!') || pattern.endsWith('/')) continue let layer: ForgeFileLayer | null | undefined let generated: boolean | null | undefined for (const field of fields) { @@ -109,9 +111,10 @@ export function attributesOf(attributes: string): (path: string) => PathAttribut layer = isLayer(value) ? value : null } else if (field === `-${LAYER_ATTRIBUTE}` || field === `!${LAYER_ATTRIBUTE}`) layer = null } - if (layer === undefined && generated === undefined) continue - const anchored = pattern.replace(/^\//, '') - rules.push({ match: picomatch(anchored, { dot: true, basename: !pattern.includes('/') }), layer, generated }) + const test = wildmatch(pattern.replace(/^\//, '')) + if (test === undefined || (layer === undefined && generated === undefined)) continue + const match: Matcher = pattern.includes('/') ? test : path => test(path.slice(path.lastIndexOf('/') + 1)) + rules.push({ match, layer, generated }) } return (path) => { let layer: ForgeFileLayer | null | undefined diff --git a/packages/experimental/client-ui-forge/src/local-diff.ts b/packages/experimental/client-ui-forge/src/local-diff.ts index c17c443d13..88496f040c 100644 --- a/packages/experimental/client-ui-forge/src/local-diff.ts +++ b/packages/experimental/client-ui-forge/src/local-diff.ts @@ -10,6 +10,7 @@ import type { Context } from '@deepseek-ai/cordis' import { FsError } from '@deepseek-ai/dsh-fs' import type { Session } from '@deepseek-ai/dsh-session' import { assertNever } from '@deepseek-ai/dsh-util-values' +import { rangeStart } from './commit-range.ts' import { decodeText, oversizedUntrackedFile, parseDiff, parseNumstat, untrackedFile } from './diff.ts' import type { ParsedDiffFile } from './diff.ts' import { gitIn, GitOutputTooLarge } from './git.ts' @@ -65,6 +66,11 @@ export class CommitsMissing extends Error { override name = 'CommitsMissing' } +/** Thrown when chosen commits of the `branch` scope are not a run in which each commit's first parent is the one before it. */ +export class CommitsNotLinear extends Error { + override name = 'CommitsNotLinear' +} + /** A full SHA-1 or SHA-256 object name, the only form of a chosen commit Git receives. */ const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ @@ -325,18 +331,23 @@ export async function aheadOf(git: Git, branch: string, commit: string): Promise * @param git - runner bound to the checkout, whose output limit bounds the listing. * @param fork - fork point of the `branch` scope. * @param limit - most commits listed. - * @returns the oldest `limit` commits and HEAD's commit. + * @returns the newest `limit` commits, ending with HEAD's commit, and HEAD's commit. * @throws an Error when Git fails or prints more than the runner's output limit. */ export async function localCommits(git: Git, fork: ForkPoint, limit: number): Promise { const head = (await git('head', ['rev-parse', '--verify', 'HEAD'])).stdout.trim() - const all = await branchCommits(git, fork) - return { headSha: head, commits: all.slice(0, limit), truncated: all.length > limit } + const listed = await branchCommits(git, fork, limit + 1) + return { headSha: head, commits: listed.slice(-limit), truncated: listed.length > limit } } -/** Every commit between the fork point and HEAD, parents before children. */ -async function branchCommits(git: Git, fork: ForkPoint): Promise { - const output = await git('log', ['log', '--topo-order', '--reverse', LOG_FORMAT, `${fork.commit}..HEAD`, '--']) +/** + * Commits between the fork point and HEAD, parents before children. + * @param limit - when given, only the newest `limit` commits in topological order. + */ +async function branchCommits(git: Git, fork: ForkPoint, limit?: number): Promise { + // A configured `log.showSignature` would print verification text before the fields and run the verifying program. + const count = limit === undefined ? [] : [`--max-count=${String(limit)}`] + const output = await git('log', ['log', '--no-show-signature', '--topo-order', '--reverse', ...count, LOG_FORMAT, `${fork.commit}..HEAD`, '--']) return output.stdout.split('\x1e').flatMap((record): ForgeCommit[] => { const fields = record.replace(/^\n/, '').split('\0') // The text after the last record holds no fields. @@ -349,12 +360,9 @@ async function branchCommits(git: Git, fork: ForkPoint): Promise /** The committed changes of consecutive commits of the `branch` scope, from the first commit's first parent to the last. */ async function commitsDiff(git: Git, fork: ForkPoint, chosen: ForgeCommitRange, layers: FileLayers): Promise { if (!COMMIT.test(chosen.from) || !COMMIT.test(chosen.to)) throw new CommitsMissing('The chosen commits are not full object names') - const commits = await branchCommits(git, fork) - const first = commits.findIndex(commit => commit.sha === chosen.from) - const start = commits[first] - if (start === undefined || commits.findIndex(commit => commit.sha === chosen.to) < first) { - throw new CommitsMissing('The chosen commits are no longer on the branch') - } + const start = rangeStart(await branchCommits(git, fork), chosen) + if (start === 'missing') throw new CommitsMissing('The chosen commits are no longer on the branch') + if (start === 'not-linear') throw new CommitsNotLinear('The chosen commits are not one run of first parents') // A first commit without a parent starts an unrelated history that HEAD merged; it adds every file. const parent = start.parent ?? await emptyTree(git) const overrides = await diffFilterOverrides(git) diff --git a/packages/experimental/client-ui-forge/src/types.ts b/packages/experimental/client-ui-forge/src/types.ts index 2a425cd2db..11dc7549e3 100644 --- a/packages/experimental/client-ui-forge/src/types.ts +++ b/packages/experimental/client-ui-forge/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe panel state, the worktree request record, Remote error codes, and Host configuration of the forge panel. */ import type {} from '@deepseek-ai/dsh-typert-protocol' -import type { ChangeRequestSnapshot, ForgeCommitList, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ChangeRequestSnapshot, ForgeCommit, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge/types' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session/types' /** Git facts for the checkout that contains the Session's current directory. */ @@ -347,8 +347,15 @@ export interface ForgeChangeRequestDiff { readonly fileUrls: Readonly> } -/** Commits of the `branch` scope, which the changes view can show one or several of. */ -export type ForgeLocalCommits = ForgeCommitList +/** Commits of the `branch` scope at one HEAD commit, which the changes view can show one or several of. */ +export interface ForgeLocalCommits { + /** HEAD commit the list was read at. */ + readonly headSha: string + /** Commits oldest first, ending with HEAD's commit. */ + readonly commits: readonly ForgeCommit[] + /** Whether the branch has more commits than `maxCommits`; the listed ones are the newest. */ + readonly truncated: boolean +} /** One file the `branch` scope changes, without its hunks. */ export interface ForgeChangedFile { @@ -395,7 +402,7 @@ export interface Config { maxUntrackedFiles?: number /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ maxCachedDiffBytes?: number - /** Most commits of the `branch` scope listed, oldest first. */ + /** Most commits of the `branch` scope listed; a longer branch lists its newest ones. */ maxCommits?: number /** * Globs of files in the `test` layer, which the changes view can hide, beside the built-in rules and checked before @@ -459,6 +466,8 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'forgePanel/diff-failed': Record /** A chosen commit is no longer among the listed commits of the branch or change request, or the commits are out of order. */ 'forgePanel/commits-missing': Record + /** A chosen commit after the first lacks the commit listed before it as its first parent, so two ends cannot name the run. */ + 'forgePanel/commits-not-linear': Record } } diff --git a/packages/experimental/client-ui-forge/src/wildmatch.ts b/packages/experimental/client-ui-forge/src/wildmatch.ts new file mode 100644 index 0000000000..bed56ef8c5 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/wildmatch.ts @@ -0,0 +1,121 @@ +/** + * Patterns of `.gitattributes` lines, matched as Git's wildmatch matches them: `*` and `?` stay within one path + * segment, `**` between slashes or at either end crosses segments, `[...]` sets take `!` or `^` for negation, ranges, + * and POSIX classes, `\` makes the next character literal, and every other character, including `(`, `{`, and `+`, + * is literal. Matching is case-sensitive, as in Git unless `core.ignoreCase` is set, and compares characters where Git + * compares UTF-8 bytes, so `?` and ranges differ from Git only for characters outside ASCII. + */ + +/** ASCII members of each POSIX class wildmatch accepts, written as regular expression class ranges. */ +const CLASSES: Readonly> = { + alnum: 'a-zA-Z0-9', + alpha: 'a-zA-Z', + blank: ' \\t', + cntrl: '\\x00-\\x1f\\x7f', + digit: '0-9', + graph: '\\x21-\\x7e', + lower: 'a-z', + print: '\\x20-\\x7e', + punct: '\\x21-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\x7e', + space: '\\t-\\r ', + upper: 'A-Z', + xdigit: '0-9a-fA-F', +} + +/** Write one character literally in a regular expression, inside or outside a class. */ +function literal(char: string): string { + return /[\\^$.*+?()[\]{}|/-]/.test(char) ? `\\${char}` : char +} + +/** + * Translate a bracket expression that starts at `pattern[start]`, which is `[`. + * @returns the regular expression class and the index of the closing `]`, or undefined for an unterminated set or an + * unknown class, which wildmatch treats as matching nothing. + */ +function bracket(pattern: string, start: number): { readonly source: string; readonly end: number } | undefined { + let index = start + 1 + const negated = pattern[index] === '!' || pattern[index] === '^' + if (negated) index += 1 + const members: string[] = [] + // The first character, even `]`, is a member, so the set closes at a later `]`. + for (let first = true; first || pattern[index] !== ']'; first = false, index += 1) { + let char = pattern[index] + if (char === undefined) return undefined + if (char === '[' && pattern[index + 1] === ':') { + // A class runs to the next `]`, which must follow `:`; otherwise the `[` is a member. + const close = pattern.indexOf(']', index + 2) + if (close === -1) return undefined + if (close > index + 2 && pattern[close - 1] === ':') { + const range = CLASSES[pattern.slice(index + 2, close - 1)] + if (range === undefined) return undefined + members.push(range) + index = close + continue + } + } + if (char === '\\') { + index += 1 + char = pattern[index] + if (char === undefined) return undefined + } + const to = pattern[index + 2] + if (pattern[index + 1] === '-' && to !== undefined && to !== ']') { + index += 2 + let end = to + if (end === '\\') { + index += 1 + end = pattern[index] ?? '' + if (end === '') return undefined + } + // A range whose end precedes its start matches only its start. + members.push(char <= end ? `${literal(char)}-${literal(end)}` : literal(char)) + continue + } + members.push(literal(char)) + } + const set = members.join('') + // A set never matches `/`. + return { source: negated ? `[^/${set}]` : `(?!/)[${set}]`, end: index } +} + +/** + * Compile one `.gitattributes` pattern, without the leading `/` that anchors it, into a test of a path. + * @param pattern - the pattern as written, which the caller has checked is not quoted, negated, or a directory. + * @returns a test of the text the pattern applies to, or undefined for a pattern that can match nothing. + */ +export function wildmatch(pattern: string): ((text: string) => boolean) | undefined { + let source = '' + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern.charAt(index) + if (char === '\\') { + const next = pattern[index + 1] + if (next === undefined) return undefined + source += literal(next) + index += 1 + } else if (char === '?') { + source += '[^/]' + } else if (char === '*') { + let last = index + while (pattern[last + 1] === '*') last += 1 + const after = pattern[last + 1] + const crossing = last > index && (index === 0 || pattern[index - 1] === '/') && (after === undefined || after === '/') + if (crossing && after === '/') { + // `**/` also matches no directory at all. + source += '(?:.*/)?' + index = last + 1 + } else { + source += crossing ? '.*' : '[^/]*' + index = last + } + } else if (char === '[') { + const set = bracket(pattern, index) + if (set === undefined) return undefined + source += set.source + index = set.end + } else { + source += literal(char) + } + } + const expression = new RegExp(`^${source}$`, 's') + return text => expression.test(text) +} diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index 97caf876a1..a4b277e34c 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -557,24 +557,34 @@ describe('ChangesTab local reads', () => { expect(m.faces.localDiff).toHaveBeenCalledTimes(3) }) - it('reads again on refresh, on the page refresh command, and when the branch summary moves', async () => { + it('reads changes and commits again on refresh, on the page refresh command, and when the branch summary moves', async () => { const m = mount() await settle() expect(m.faces.localDiff).toHaveBeenCalledTimes(1) + expect(m.faces.localCommits).toHaveBeenCalledTimes(1) fireEvent.click(m.view.getByRole('button', { name: en['diff.refresh'] })) await settle() expect(m.faces.localDiff).toHaveBeenCalledTimes(2) + expect(m.faces.localCommits).toHaveBeenCalledTimes(2) const commands = m.tabActions.bindCommands.mock.calls[0]?.[0] act(() => { commands?.refresh?.() }) await settle() expect(m.faces.localDiff).toHaveBeenCalledTimes(3) + expect(m.faces.localCommits).toHaveBeenCalledTimes(3) act(() => { m.panel.set({ state: { ...LOCAL_STATE, readiness: null }, disconnected: false }) }) await settle() expect(m.faces.localDiff).toHaveBeenCalledTimes(3) + expect(m.faces.localCommits).toHaveBeenCalledTimes(3) act(() => { m.panel.set({ state: { ...LOCAL_STATE, local: { base: BASE, files: [] } }, disconnected: false }) }) await settle() expect(m.faces.localDiff).toHaveBeenCalledTimes(4) + expect(m.faces.localCommits).toHaveBeenCalledTimes(4) + // A commit of the staged changes moves HEAD without changing the summary against the fork point. + act(() => { m.panel.set({ state: ready({ local: { base: BASE, files: [] } }, { commit: 'f'.repeat(40) }), disconnected: false }) }) + await settle() + expect(m.faces.localDiff).toHaveBeenCalledTimes(4) + expect(m.faces.localCommits).toHaveBeenCalledTimes(5) m.view.unmount() expect(m.unbind).toHaveBeenCalledOnce() @@ -1590,10 +1600,13 @@ describe('ChangesTab commits', () => { expect((m.view.getByRole('button', { name: en['review.submit'] }) as HTMLButtonElement).disabled).toBe(true) }) - it('lists the commits of the branch scope only, and offers every commit when chosen ones are gone', async () => { + it('lists the commits of the branch scope only, and offers every commit when chosen ones are gone or not one run', async () => { const m = mount({ setup: (faces) => { - faces.localDiff.mockImplementation(async scope => scope.kind === 'branch' && scope.commits !== null - ? { ok: false, error: new RemoteError('forgePanel/commits-missing', 'gone', {}) } : ok(LOCAL)) + faces.localDiff.mockImplementation(async (scope) => { + if (scope.kind !== 'branch' || scope.commits === null) return ok(LOCAL) + const code = scope.commits.from === one.from ? 'forgePanel/commits-missing' : 'forgePanel/commits-not-linear' + return { ok: false, error: new RemoteError(code, 'refused', {}) } + }) } }) await settle() expect(m.faces.localCommits).toHaveBeenCalledWith(null, expect.any(AbortSignal)) @@ -1605,6 +1618,14 @@ describe('ChangesTab commits', () => { expect(m.view.getByText(en['commits.missing'])).toBeDefined() fireEvent.click(m.view.getByRole('button', { name: en['commits.showAll'] })) expect(m.store.getSnapshot().byTab[TAB]?.commits).toBeNull() + await settle() + fireEvent.click(m.view.getByRole('button', { name: 'Commits shown: All commits' })) + await settle() + fireEvent.click(within(document.body).getByRole('menuitem', { name: /^3333333/ })) + await settle() + expect(m.view.getByText(en['commits.notLinear'])).toBeDefined() + fireEvent.click(m.view.getByRole('button', { name: en['commits.showAll'] })) + expect(m.store.getSnapshot().byTab[TAB]?.commits).toBeNull() fireEvent.click(m.view.getByRole('button', { name: 'Branch' })) fireEvent.click(within(document.body).getByRole('menuitem', { name: en['diff.scope.staged'] })) diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index e7d48e4b77..51f6ac0d66 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -549,19 +549,31 @@ describe('forgePanel local changes', () => { const listed = await opened.ctx.forgePanel.localCommits(opened.session, null, signal) expect(listed).toMatchObject({ truncated: true, headSha: await git(repo, 'rev-parse', 'HEAD') }) - expect(listed.commits.map(commit => commit.title)).toEqual(['one', 'two']) + expect(listed.commits.map(commit => commit.title)).toEqual(['two', 'three']) await expect(opened.ctx.forgePanel.localCommits(opened.session, 'refs/heads/topic', signal)) .resolves.toMatchObject({ truncated: false, commits: [{ title: 'three' }] }) - const [one, two] = listed.commits.map(commit => commit.sha) + const [two, three] = listed.commits.map(commit => commit.sha) const chosen = await opened.ctx.forgePanel.localDiff(opened.session, - { kind: 'branch', base: null, commits: { from: one ?? '', to: two ?? '' } }, signal) - expect(chosen.files.map(file => file.path)).toEqual(['one.txt', 'two.txt']) + { kind: 'branch', base: null, commits: { from: two ?? '', to: three ?? '' } }, signal) + expect(chosen.files.map(file => file.path)).toEqual(['three.txt', 'two.txt']) await expect(opened.ctx.forgePanel.localText(opened.session, - { kind: 'branch', base: null, commits: { from: one ?? '', to: one ?? '' } }, 'one.txt', signal)).resolves.toBe('one\n') + { kind: 'branch', base: null, commits: { from: two ?? '', to: two ?? '' } }, 'two.txt', signal)).resolves.toBe('two\n') await expect(opened.ctx.forgePanel.localDiff(opened.session, - { kind: 'branch', base: 'refs/heads/topic', commits: { from: one ?? '', to: two ?? '' } }, signal)) + { kind: 'branch', base: 'refs/heads/topic', commits: { from: two ?? '', to: three ?? '' } }, signal)) .rejects.toMatchObject({ code: 'forgePanel/commits-missing' }) await expect(opened.ctx.forgePanel.localCommits(opened.session, 'refs/heads/gone', signal)).rejects.toMatchObject({ code: 'forgePanel/base-missing' }) + // A merged side branch lists its commit beside the branch's own, so a run across them is refused. + await git(repo, 'checkout', '--quiet', '-b', 'side', 'origin/main') + await writeFile(join(repo, 'side.txt'), 'side\n') + await git(repo, 'add', 'side.txt') + await git(repo, 'commit', '--quiet', '-m', 'side') + await git(repo, 'checkout', '--quiet', '-') + await git(repo, 'merge', '--quiet', '--no-ff', '--no-edit', 'side') + const order = (await git(repo, 'log', '--topo-order', '--reverse', '--format=%H %P', 'origin/main..HEAD')).split('\n').map(line => line.split(' ')) + const leaving = order.findIndex(([, parent], index) => index > 0 && parent !== order[index - 1]?.[0]) + await expect(opened.ctx.forgePanel.localDiff(opened.session, + { kind: 'branch', base: null, commits: { from: order[leaving - 1]?.[0] ?? '', to: order[leaving]?.[0] ?? '' } }, signal)) + .rejects.toMatchObject({ code: 'forgePanel/commits-not-linear' }) const unrelated = await project() const lonely = await open(unrelated.repo) @@ -737,6 +749,10 @@ describe('forgePanel change request diffs', () => { await expect(read(1, 1)).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'The first chosen commit has no parent to compare with' }) await expect(read(3, 2)).rejects.toMatchObject({ code: 'forgePanel/commits-missing' }) await expect(read(4, 3)).rejects.toMatchObject({ code: 'forgePanel/commits-missing' }) + // A commit of another branch listed between two others leaves the run of first parents. + opened.provider.commitLists.set(7, { ...list, commits: [commit(1, null), commit(2, sha(1)), commit(5, sha(1)), commit(3, sha(2))] }) + await expect(read(2, 5)).rejects.toMatchObject({ code: 'forgePanel/commits-not-linear' }) + opened.provider.commitLists.set(7, list) await expect(read(3, 3)).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: `no comparison ${sha(2)}..${sha(3)}` }) opened.provider.comparisons.set(`${sha(2)}..${sha(3)}`, { code: 'too-large', message: 'too many files' }) await expect(read(3, 3)).rejects.toMatchObject({ code: 'forgePanel/diff-too-large' }) diff --git a/packages/experimental/client-ui-forge/tests/diff.host.spec.ts b/packages/experimental/client-ui-forge/tests/diff.host.spec.ts index 598109e93e..3f88cc7edc 100644 --- a/packages/experimental/client-ui-forge/tests/diff.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/diff.host.spec.ts @@ -94,6 +94,18 @@ describe('parseDiff', () => { expect(parseDiff(PATCH.replace('index bdc955b..8352675', 'index bdc955b..0000001'))[0]?.digest).not.toBe(digests[0]) }) + it('digests a mode change, which adds no hunk, apart from the same text change without it', () => { + const text = ['diff --git a/run.sh b/run.sh', 'index 1111111..2222222 100644', '--- a/run.sh', '+++ b/run.sh', '@@ -1 +1 @@', '-a', '+b', ''].join('\n') + const executable = text.replace('index 1111111..2222222 100644', 'old mode 100644\nnew mode 100755\nindex 1111111..2222222') + const [plain] = parseDiff(text) + const [moved] = parseDiff(executable) + expect(moved?.hunks).toEqual(plain?.hunks) + expect(moved?.digest).not.toBe(plain?.digest) + expect(parseDiff(executable)[0]?.digest).toBe(moved?.digest) + const modeOnly = ['diff --git a/run.sh b/run.sh', 'old mode 100644', 'new mode 100755', ''].join('\n') + expect(parseDiff(modeOnly)[0]?.digest).not.toBe(parseDiff(modeOnly.replace('100755', '100700'))[0]?.digest) + }) + it('digests a binary change without its blob names when a section does not start with a Git header', () => { const binary = 'diff --git a/x.png b/x.png\nindex 1111111..2222222 100644\nBinary files a/x.png and b/x.png differ\n' const withLeader = `--- a/y\n+++ b/y\n@@ -1 +1 @@\n-a\n+b\n${binary}` diff --git a/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts b/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts index 7c7f74b9c6..79e7653bfc 100644 --- a/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/local-diff.host.spec.ts @@ -7,8 +7,8 @@ import { FsError } from '@deepseek-ai/dsh-fs' import { gitIn, inspectCheckout } from '../src/git.ts' import type { Inspection } from '../src/git.ts' import { - aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, DiffTooLarge, forkPoint, localChanges, localCommits, localDiff, - localText, NoForkPoint, + aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, CommitsNotLinear, DiffTooLarge, forkPoint, localChanges, + localCommits, localDiff, localText, NoForkPoint, } from '../src/local-diff.ts' import type { DiffConfig, ForkPoint } from '../src/local-diff.ts' import { FileLayers } from '../src/layers.ts' @@ -351,7 +351,7 @@ describe('commits of the branch scope', () => { return { repo, point: { commit: fork, label: 'origin/main', worktree: false }, shas } } - it('lists the commits between the fork point and HEAD oldest first, bounded by the limit', async () => { + it('lists the commits between the fork point and HEAD oldest first, keeping the newest ones over the limit', async () => { const { repo, point, shas } = await committed() const ctx = await context(repo) const runner = gitIn(ctx, CONFIG, plainSession(repo), repo, signal) @@ -364,7 +364,31 @@ describe('commits of the branch scope', () => { expect(listed.commits.map(commit => commit.sha)).toEqual(shas) expect(listed.commits.map(commit => commit.parent)).toEqual([point.commit, ...shas.slice(0, -1)]) expect(Date.parse(listed.commits[0]?.authoredAt ?? '')).not.toBeNaN() - await expect(localCommits(runner, point, 2)).resolves.toMatchObject({ truncated: true, commits: [{ title: 'committed' }, { title: 'staged' }] }) + await expect(localCommits(runner, point, 2)).resolves.toMatchObject({ truncated: true, commits: [{ title: 'second' }, { title: 'third' }] }) + await expect(localCommits(runner, point, 4)).resolves.toMatchObject({ truncated: false, commits: { length: 4 } }) + }) + + it('lists signed commits while the repository asks Git to show signatures', async () => { + const { repo, point, shas } = await committed() + const head = shas.at(-1) ?? '' + // A signature Git cannot verify still makes `log.showSignature` print verification text before each signed commit. + const tree = await git(repo, 'rev-parse', 'HEAD^{tree}') + const signed = join(repo, '.git', 'signed-commit') + await writeFile(signed, [ + `tree ${tree}`, `parent ${head}`, 'author Forge fixture 1700000000 +0000', + 'committer Forge fixture 1700000000 +0000', + 'gpgsig -----BEGIN SSH SIGNATURE-----', ' AAAA', ' -----END SSH SIGNATURE-----', '', 'signed', '', + ].join('\n')) + const sha = await git(repo, 'hash-object', '-t', 'commit', '-w', signed) + await git(repo, 'reset', '--quiet', '--soft', sha) + await git(repo, 'config', 'log.showSignature', 'true') + await git(repo, 'config', 'gpg.ssh.program', join(repo, 'missing-ssh-keygen')) + const ctx = await context(repo) + + const listed = await localCommits(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), point, 10) + expect(listed.commits.map(commit => [commit.sha, commit.title])).toEqual([ + ...shas.map((commit, index) => [commit, ['committed', 'staged', 'second', 'third'][index]]), [sha, 'signed'], + ]) }) it('reads the committed changes of consecutive commits with the attributes of the last one, without working tree changes', async () => { @@ -402,6 +426,33 @@ describe('commits of the branch scope', () => { expect(diff.files.map(file => [file.path, file.status])).toEqual([['orphan.txt', 'added']]) }) + it('refuses a run of commits whose first parents leave it, and reads one that follows them', async () => { + const { repo, fork } = await forked(await directory()) + await git(repo, 'commit', '--quiet', '-m', 'staged') + await git(repo, 'checkout', '--quiet', '-b', 'side', fork) + for (const name of ['side-one', 'side-two']) { + await writeFile(join(repo, `${name}.txt`), `${name}\n`) + await git(repo, 'add', `${name}.txt`) + await git(repo, 'commit', '--quiet', '-m', name) + } + await git(repo, 'checkout', '--quiet', 'feat') + await git(repo, 'merge', '--quiet', '--no-ff', '--no-edit', 'side') + const point: ForkPoint = { commit: fork, label: 'origin/main', worktree: false } + const ctx = await context(repo) + const listed = (await localCommits(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), point, 10)).commits + const read = (from: string, to: string): Promise<{ readonly files: readonly { readonly path: string }[] }> => + localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'branch', base: null, commits: { from, to } }, LAYERS, signal) + // Two branches from the fork point list their commits one after the other, so one pair of neighbours is unrelated. + const leaving = listed.findIndex((commit, index) => index > 0 && commit.parent !== listed[index - 1]?.sha) + const before = listed[leaving - 1]?.sha ?? '' + const after = listed[leaving]?.sha ?? '' + + expect(leaving).toBeGreaterThan(0) + await expect(read(before, after)).rejects.toBeInstanceOf(CommitsNotLinear) + const side = listed.filter(commit => commit.title.startsWith('side-')) + expect((await read(side[0]?.sha ?? '', side[1]?.sha ?? '')).files.map(file => file.path)).toEqual(['side-one.txt', 'side-two.txt']) + }) + it('refuses commits that are not full object names, not on the branch, or out of order', async () => { const { repo, point, shas } = await committed() const ctx = await context(repo) diff --git a/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx index 6f535ed954..c1ffebcbe4 100644 --- a/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx @@ -86,6 +86,14 @@ describe('LensMenu', () => { expect(within(document.body).queryByRole('menu')).toBeNull() }) + it('disables the test lens without its count while test files hide, as the segments do', () => { + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Review layer: All' })) + const tests = within(document.body).getByRole('menuitem', { name: 'Tests' }) + expect(tests.textContent).toBe('Tests') + expect((tests as HTMLButtonElement).disabled).toBe(true) + }) + it('keeps its menu closed while the tab is hidden', () => { const view = render() fireEvent.click(view.getByRole('button', { name: 'Review layer: All' })) @@ -116,7 +124,7 @@ describe('CommitMenu', () => { function mount(props: Partial = {}) { const list = props.list ?? vi.fn(async () => ({ ok: true, value: COMMITS })) const choose = vi.fn() - const all: CommitMenuProps = { chosen: null, visible: true, version: 'v1', t, ...props, list, choose } + const all: CommitMenuProps = { chosen: null, visible: true, kept: 'oldest', version: 'v1', t, ...props, list, choose } const view = render() const rerender = (next: Partial): void => { view.rerender() } const rows = (): string[] => within(document.body).queryAllByRole('menuitem').map(row => row.textContent ?? '') @@ -130,7 +138,7 @@ describe('CommitMenu', () => { expect(commitsLabel({ from: sha(1), to: sha(2) }, t)).toBe('1111111…2222222') }) - it('reads the commits once per version, again on each opening, and chooses one or a Shift run of them', async () => { + it('reads the commits once per version, keeps them across openings, and chooses one or a Shift run of them', async () => { const m = mount() await settle() expect(m.list).toHaveBeenCalledTimes(1) @@ -140,7 +148,7 @@ describe('CommitMenu', () => { const trigger = m.view.getByRole('button', { name: 'Commits shown: All commits' }) fireEvent.click(trigger) await settle() - expect(m.list).toHaveBeenCalledTimes(2) + expect(m.list).toHaveBeenCalledTimes(1) const rows = m.rows() expect(rows[0]).toBe('All commits3 commits') expect(rows.slice(1).map((row, index) => row.startsWith(`${sha(index + 1).slice(0, 7)}commit ${index + 1}`) && row.endsWith(' d ago'))).toEqual([true, true, true]) @@ -173,9 +181,31 @@ describe('CommitMenu', () => { fireEvent.click(within(document.body).getByRole('menuitem', { name: /^All commits/ })) expect(m.choose).toHaveBeenLastCalledWith(null) + expect(m.list).toHaveBeenCalledTimes(1) m.rerender({ chosen: null, version: 'v2' }) await settle() - expect(m.list).toHaveBeenCalledTimes(7) + expect(m.list).toHaveBeenCalledTimes(2) + }) + + it('keeps its menu open and says why for a Shift run that leaves the first-parent line', async () => { + // The third commit sits on another branch from the first one, after the second in the list. + const commits = COMMITS.commits.map(commit => commit.sha === sha(3) ? { ...commit, parent: sha(1) } : commit) + const m = mount({ list: async () => ({ ok: true, value: { ...COMMITS, commits } }) }) + fireEvent.click(m.view.getByRole('button', { name: 'Commits shown: All commits' })) + await settle() + fireEvent.click(m.row(2)) + expect(m.choose).toHaveBeenLastCalledWith({ from: sha(2), to: sha(2) }) + m.rerender({ chosen: { from: sha(2), to: sha(2) } }) + fireEvent.click(m.view.getByRole('button', { name: 'Commits shown: 2222222' })) + await settle() + + fireEvent.click(m.row(3), { shiftKey: true }) + expect(m.choose).toHaveBeenCalledTimes(1) + expect(within(document.body).getByRole('menu')).toBeDefined() + expect(within(document.body).getByRole('status').textContent).toBe(en['commits.notLinear']) + fireEvent.click(m.row(1), { shiftKey: true }) + expect(m.choose).toHaveBeenLastCalledWith({ from: sha(1), to: sha(2) }) + expect(within(document.body).queryByRole('status')).toBeNull() }) it('starts a Shift run at the clicked commit without an earlier choice, and ignores choosing every commit again', async () => { @@ -205,6 +235,8 @@ describe('CommitMenu', () => { expect(m.rows()).toEqual(['All commits0 commits']) expect(within(document.body).queryByText(en['commits.shiftHint'])).toBeNull() expect(within(document.body).getByText('Only the earliest 0 commits are listed')).toBeDefined() + m.rerender({ kept: 'newest' }) + expect(within(document.body).getByText('Only the latest 0 commits are listed')).toBeDefined() fireEvent.keyDown(within(document.body).getByRole('menu'), { key: 'Escape' }) result = { ok: true, value: null } diff --git a/packages/experimental/client-ui-forge/tests/viewed-files.client.spec.ts b/packages/experimental/client-ui-forge/tests/viewed-files.client.spec.ts index 6a409bc7fe..4ebce40bbb 100644 --- a/packages/experimental/client-ui-forge/tests/viewed-files.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/viewed-files.client.spec.ts @@ -37,6 +37,48 @@ describe('ViewedFiles', () => { expect(listener).toHaveBeenCalledTimes(2) }) + it('keeps the marks another tab of the Session saved, and shows them as that tab saves them', () => { + const first = new ViewedFiles('s1') + const second = new ViewedFiles('s1') + expect(first.mark('pr', FILES, 'a.ts', true)).toBe(true) + expect(second.mark('pr', FILES, 'b.ts', true)).toBe(true) + expect([...new ViewedFiles('s1').marksOf('pr').keys()].sort()).toEqual(['a.ts', 'b.ts']) + + // The first subscriber reads what the other tab saved while nothing listened. + const listener = vi.fn() + const unsubscribe = first.subscribe(listener) + expect([...first.marksOf('pr').keys()].sort()).toEqual(['a.ts', 'b.ts']) + const stored = (key: string | null): void => { window.dispatchEvent(new StorageEvent('storage', { key })) } + second.mark('pr', FILES, 'a.ts', false) + stored('dsh.forge-viewed.v1."s2"') + expect(listener).not.toHaveBeenCalled() + stored('dsh.forge-viewed.v1."s1"') + expect(listener).toHaveBeenCalledTimes(1) + expect([...first.marksOf('pr').keys()]).toEqual(['b.ts']) + stored('dsh.forge-viewed.v1."s1"') + expect(listener).toHaveBeenCalledTimes(1) + localStorage.clear() + stored(null) + expect(first.marksOf('pr').size).toBe(0) + expect(listener).toHaveBeenCalledTimes(2) + + // The listener on storage stays while another subscriber remains. + const other = vi.fn() + const unsubscribeOther = first.subscribe(other) + unsubscribe() + second.mark('pr', FILES, 'a.ts', true) + stored('dsh.forge-viewed.v1."s1"') + expect(other).toHaveBeenCalledTimes(1) + localStorage.setItem('dsh.forge-viewed.v1."s1"', '{') + stored('dsh.forge-viewed.v1."s1"') + expect(first.marksOf('pr').size).toBe(0) + unsubscribeOther() + second.mark('pr', FILES, 'b.ts', true) + stored('dsh.forge-viewed.v1."s1"') + expect(other).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenCalledTimes(2) + }) + it('drops the marks of paths the diff no longer has and refuses a path it does not have', () => { const viewed = new ViewedFiles('s1') viewed.mark('pr', FILES, 'a.ts', true) diff --git a/packages/experimental/client-ui-forge/tests/wildmatch.host.spec.ts b/packages/experimental/client-ui-forge/tests/wildmatch.host.spec.ts new file mode 100644 index 0000000000..c2c9dce587 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/wildmatch.host.spec.ts @@ -0,0 +1,59 @@ +/** `.gitattributes` patterns: each pattern applies to the same paths as `git check-attr` applies it to. */ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { attributesOf } from '../src/layers.ts' +import { git, scratch } from './harness.ts' + +/** Patterns whose meaning differs between Git's wildmatch and shell or glob libraries, or that Git refuses. */ +const PATTERNS = [ + 'src/(group)/**', 'foo.{js,ts}', '*.ts', '/root.ts', 'src/*.ts', '**/fixtures/*.json', 'a/**/b.txt', 'docs/**', 'a**b.txt', + 'src/**x/*.ts', '***/deep.txt', '?.md', '[abc].txt', '[!abc].txt', '[^abc].txt', '[a-c]x.txt', '[z-a].txt', '[!z-a].txt', + '[]].txt', '[]-a]y.txt', '[a-]x.txt', '[-a]w.txt', '[a-c-e]v.txt', '[\\]]u.txt', '[[:digit:]].txt', '[[:alpha:][:digit:]]z.txt', + '[[:digit:]-z]t.txt', '[[:]x.txt', '[[:foo:]].txt', '[[:digit:]', '[ab', '\\*.txt', '\\[x].txt', 'end\\', 'a+b.txt', 'c$.txt', + 'x|y.txt', '\\!bang.txt', '!neg.txt', 'src/[[:upper:]]*.ts', 'src/[!a]*.ts', '[[:space:]]s.txt', '[[:punct:]]p.txt', + '[a-\\c]r.txt', 'x[[:digit', 'y[\\', 'q[a-\\', +] + +const PATHS = [ + 'src/(group)/a.ts', 'src/group/a.ts', 'foo.{js,ts}', 'foo.js', 'a.ts', 'x/y/a.ts', 'root.ts', 'x/root.ts', 'src/a.ts', 'src/x/a.ts', + 'fixtures/a.json', 'x/fixtures/a.json', 'x/fixtures/y/a.json', 'a/b.txt', 'a/x/y/b.txt', 'docs/a/b.txt', 'docs', 'aXb.txt', 'aX/b.txt', + 'src/abx/a.ts', 'src/a/x/a.ts', 'deep.txt', 'x/y/deep.txt', 'q.md', 'qq.md', 'a.txt', 'd.txt', 'ax.txt', 'dx.txt', ']y.txt', '^y.txt', + 'ay.txt', 'by.txt', ']u.txt', 'ex.txt', '-x.txt', '-w.txt', 'aw.txt', 'av.txt', '-v.txt', 'dv.txt', 'ev.txt', '].txt', '5.txt', + 'z.txt', '5z.txt', 'zz.txt', '5t.txt', '-t.txt', 'zt.txt', '[x.txt', ':x.txt', '[[:digit:]', '[ab', 'ab', '*.txt', 'x.txt', + '[x].txt', 'end', 'a+b.txt', 'aab.txt', 'c$.txt', 'x|y.txt', 'x.txt', '!bang.txt', 'neg.txt', '!neg.txt', 'src/Ab.ts', 'src/ab.ts', + 'src/bb.ts', ' s.txt', 'ss.txt', '.p.txt', 'pp.txt', 'x/.p.txt', 'br.txt', 'cr.txt', 'dr.txt', 'x[[:digit', 'y[', 'q[a-', +] + +describe('attributesOf patterns', () => { + let repo = '' + let remove = async (): Promise => {} + + beforeAll(async () => { + ({ path: repo, remove } = await scratch('dsh-forge-wildmatch-')) + await git(repo, 'init', '--quiet') + }) + + afterAll(async () => { await remove() }) + + /** The paths `git check-attr` reports in the layer a one-line `.gitattributes` names. */ + async function gitMatches(pattern: string): Promise { + await writeFile(join(repo, '.gitattributes'), `${pattern} dsh-review-layer=test\n`) + const output = await git(repo, '-c', `core.attributesFile=${join(repo, 'none')}`, '-c', 'core.ignoreCase=false', + 'check-attr', '-z', 'dsh-review-layer', '--', ...PATHS) + const fields = output.split('\0') + const matched: string[] = [] + for (let index = 0; index + 2 < fields.length; index += 3) { + if (fields[index + 2] === 'test') matched.push(fields[index] ?? '') + } + return matched + } + + it('applies each pattern to the paths git check-attr applies it to', async () => { + for (const pattern of PATTERNS) { + const read = attributesOf(`${pattern} dsh-review-layer=test\n`) + const ours = PATHS.filter(path => read(path).layer === 'test') + expect({ pattern, matched: ours }).toEqual({ pattern, matched: await gitMatches(pattern) }) + } + }) +}) diff --git a/packages/experimental/client-ui-forge/tsconfig.client.json b/packages/experimental/client-ui-forge/tsconfig.client.json index 6b1f10f81f..6bc548094f 100644 --- a/packages/experimental/client-ui-forge/tsconfig.client.json +++ b/packages/experimental/client-ui-forge/tsconfig.client.json @@ -7,6 +7,7 @@ }, "include": [ "src/client", + "src/commit-range.ts", "src/css-modules.d.ts", "src/types.ts" ], diff --git a/packages/experimental/client-ui-forge/tsconfig.host.json b/packages/experimental/client-ui-forge/tsconfig.host.json index 65f55fefac..a6c004f078 100644 --- a/packages/experimental/client-ui-forge/tsconfig.host.json +++ b/packages/experimental/client-ui-forge/tsconfig.host.json @@ -7,6 +7,7 @@ }, "files": [ "src/index.ts", + "src/commit-range.ts", "src/diff.ts", "src/git.ts", "src/local-diff.ts", @@ -16,6 +17,7 @@ "src/patch-cache.ts", "src/states.ts", "src/types.ts", + "src/wildmatch.ts", "src/worktree.ts" ], "references": [ diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 5fbff3d367..c329bf236f 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 8c1de4f9461d9160 - zh: 08b08d71e32b55a1 + en: 0f8041e0380036c9 + zh: 63cbdc82c9f438d4 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: f98e77171cba809f zh: 1b330cb342d69efe diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 35109f62b5..84a261269a 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -46,7 +46,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready --repo //`. Merging calls `PUT repos///pulls//merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`. -`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head of the first one, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. +`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index f1ef4f6c35..205a8f0439 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready --repo //`。合并经 `gh api` 调用 `PUT repos///pulls//merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。 -`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 必须与第一页相同,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 +`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 diff --git a/packages/experimental/forge-github/src/commits.ts b/packages/experimental/forge-github/src/commits.ts index 24a0d830b0..e00c101571 100644 --- a/packages/experimental/forge-github/src/commits.ts +++ b/packages/experimental/forge-github/src/commits.ts @@ -50,7 +50,8 @@ export type GraphqlReader = (args: readonly string[], signal: AbortSignal) => Pr /** * List a pull request's commits oldest first, at most `limit`, reading pages until the limit or the last commit. - * Every page must report the head of the first page, so the list never mixes two pushes. + * Every page must report the head and commit count of the first page, so the list never mixes two pushes, and a cursor + * no earlier page returned, so the listing ends. * @param read - runs one GraphQL request. * @param ref - validated pull request address. * @param limit - most commits listed. @@ -65,7 +66,8 @@ export async function listCommits( ): Promise { const { host, owner, name } = checkedRepository(ref) const commits: ForgeCommit[] = [] - let head: string | undefined + const cursors = new Set() + let first: { readonly head: string; readonly total: number } | undefined let cursor: string | null = null for (;;) { const body = await read([ @@ -78,10 +80,11 @@ export async function listCommits( if (!parsed.success) return { code: 'unavailable', message: 'unexpected GitHub response to the commit listing' } const pull = parsed.data.data.repository?.pullRequest if (pull == null) return { code: 'not-found', message: `pull request ${ref.number} was not found` } - if (head !== undefined && pull.headRefOid !== head) { + const { headRefOid: head, commits: { totalCount: total, pageInfo: { hasNextPage, endCursor } } } = pull + first ??= { head, total } + if (head !== first.head || total !== first.total) { return { code: 'unavailable', message: 'The pull request head changed while its commits were read' } } - head = pull.headRefOid for (const { commit } of pull.commits.nodes) { commits.push({ sha: commit.oid, @@ -91,10 +94,9 @@ export async function listCommits( authoredAt: commit.authoredDate, }) } - const { hasNextPage, endCursor } = pull.commits.pageInfo - if (!hasNextPage || endCursor === null || commits.length >= limit) { - return { headSha: head, commits, truncated: pull.commits.totalCount > commits.length } - } + if (!hasNextPage || commits.length >= limit) return { headSha: head, commits, truncated: total > commits.length } + if (endCursor === null || cursors.has(endCursor)) return { code: 'unavailable', message: 'GitHub commit pagination did not advance' } + cursors.add(endCursor) cursor = endCursor } } diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index 8a7939a7d4..67e963e3d9 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -193,7 +193,8 @@ export class GitHubProvider implements ForgeProvider { /** List the pull request's commits through GraphQL, at most `maxCommits`, all read at one head. */ async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise { - const failure = this.hostFailure(ref.repository.host) + const { host } = checkedRepository(ref) + const failure = this.hostFailure(host) if (failure !== undefined) return failure return await listCommits(this.graphql, ref, this.limits.maxCommits, signal) } diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index d1c2326291..1993c8cee6 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -583,10 +583,26 @@ describe('GitHub provider commits', () => { expect(bounded.calls[0]).toEqual(expect.arrayContaining(['first=2'])) }) - it('refuses a listing whose head moves between pages, a missing pull request, and an unexpected response', async () => { + it('refuses a listing whose head or count moves between pages, or whose pages do not advance', async () => { const moved = fakeGh(args => args.includes('cursor=page-1') ? page([node(3)], null, sha(4)) : page([node(1)], 'page-1')) await expect(provider(moved.run).commits(pull(7), signal)) .resolves.toEqual({ code: 'unavailable', message: 'The pull request head changed while its commits were read' }) + const recounted = fakeGh(args => args.includes('cursor=page-1') ? page([node(3)], null, sha(3), 4) : page([node(1)], 'page-1')) + await expect(provider(recounted.run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'The pull request head changed while its commits were read' }) + // A page that repeats the cursor it was read after would be requested again forever. + const stuck = fakeGh(args => page(args.includes('cursor=page-1') ? [] : [node(1)], 'page-1')) + await expect(provider(stuck.run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'GitHub commit pagination did not advance' }) + expect(stuck.calls).toHaveLength(2) + const endless = fakeGh(() => output({ data: { repository: { pullRequest: { + headRefOid: sha(3), commits: { totalCount: 3, pageInfo: { hasNextPage: true, endCursor: null }, nodes: [node(1)] }, + } } } })) + await expect(provider(endless.run).commits(pull(7), signal)) + .resolves.toEqual({ code: 'unavailable', message: 'GitHub commit pagination did not advance' }) + }) + + it('refuses a missing pull request and an unexpected response', async () => { await expect(provider(fakeGh(() => output({ data: { repository: { pullRequest: null } } })).run).commits(pull(7), signal)) .resolves.toEqual({ code: 'not-found', message: 'pull request 7 was not found' }) await expect(provider(fakeGh(() => output({ data: { repository: null } })).run).commits(pull(7), signal)) @@ -632,5 +648,12 @@ it('rejects readiness, actions, and content reads for an unconfigured host witho await expect(subject.fileText(target, 'a'.repeat(40), 'file.txt', signal)).resolves.toEqual({ code: 'unavailable', message }) await expect(subject.commits(target, signal)).resolves.toEqual({ code: 'unavailable', message }) await expect(subject.compare(target, 'a'.repeat(40), 'b'.repeat(40), signal)).resolves.toEqual({ code: 'unavailable', message }) + // A repository that cannot name a request is refused before its host, as every read refuses it. + const unsafe = pull(7, { ...demo, host: 'unconfigured.example', owner: 'oc/to' }) + const invalid = 'forge-github: invalid repository "oc/to/demo"' + await expect(subject.diff(unsafe, 'a'.repeat(40), signal)).rejects.toThrow(invalid) + await expect(subject.fileText(unsafe, 'a'.repeat(40), 'file.txt', signal)).rejects.toThrow(invalid) + await expect(subject.commits(unsafe, signal)).rejects.toThrow(invalid) + await expect(subject.compare(unsafe, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow(invalid) expect(gh.calls).toEqual([]) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 919c486d18..de37043f87 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1389,13 +1389,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Read the changes one scope covers in the checkout of the Session\'s current directory, with Git using the Session\'s sandbox policy.', parameters: [{ name: 'session', description: 'Session whose checkout is read.' }, { name: 'scope', description: 'which changes to read.' }, { name: 'signal', description: 'request cancellation.' }], returns: 'the files, the base of the `branch` scope, and the untracked files left out.', - throws: ['`forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails.'], + throws: ['`forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch chosen for the `branch` scope does not exist; `forgePanel/no-fork-point` for the `branch` scope without a fork point; `forgePanel/commits-missing` when chosen commits are not between the fork point and HEAD in order; `forgePanel/commits-not-linear` when a chosen commit\'s first parent is not the commit listed before it; `forgePanel/filter-config-failed` when filter configuration cannot be read completely, before any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails.'], }, { signature: '@Remote async localCommits(session: Session, base: string | null, signal: AbortSignal): Promise', description: 'List the commits of the `branch` scope of the Session\'s current checkout, which the changes view can show one or several of instead of the whole scope.', parameters: [{ name: 'session', description: 'Session whose checkout is read.' }, { name: 'base', description: 'the base ref of the `branch` scope, or null for its automatic fork point.' }, { name: 'signal', description: 'request cancellation.' }], - returns: 'at most `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD\'s commit.', + returns: 'the newest `maxCommits` commits between the fork point and HEAD, oldest first, and HEAD\'s commit.', throws: ['`forgePanel/not-a-checkout` outside a checkout; `forgePanel/base-missing` when the base branch does not exist; `forgePanel/no-fork-point` without a fork point; the Git error when Git fails or the listing exceeds `maxDiffBytes`.'], }, { @@ -1424,7 +1424,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Read the changes of a change request of a Session as its code host computes them, with each file\'s layer following the root `.gitattributes` of the commit the diff ends at. Without `commits` the diff covers the change request at the head commit of its latest status; a patch already read at the same head commit and base branch is reused from memory with its attributes, for any Session, until `maxCachedDiffBytes` evicts it. With `commits`, it covers those commits, from the first one\'s first parent to the last, and is read every time.', parameters: [{ name: 'session', description: 'Session the change request is bound to.' }, { name: 'url', description: 'binding URL of the change request.' }, { name: 'commits', description: 'consecutive commits of the change request, or null for all of it.' }, { name: 'signal', description: 'request cancellation.' }], returns: 'the files, the commit they end at, each file\'s web page, and the commits the checkout has beyond the head.', - throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/commits-missing` when chosen commits are not among the change request\'s commits in order; `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again; `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host does not provide the commits, the diff, or an existing `.gitattributes` otherwise.'], + throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/commits-missing` when chosen commits are not among the change request\'s commits in order; `forgePanel/commits-not-linear` when a chosen commit\'s first parent is not the commit listed before it; `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again; `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host does not provide the commits, the diff, or an existing `.gitattributes` otherwise.'], }, { signature: '@Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise', @@ -6703,7 +6703,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeLocalCommits', - declaration: 'export type ForgeLocalCommits = ForgeCommitList;', + declaration: 'export interface ForgeLocalCommits {\n readonly headSha: string;\n readonly commits: readonly ForgeCommit[];\n readonly truncated: boolean;\n}', }, { name: 'ForgeLocalDiff', From 6d6fc8968600f86f327568195097f73bd9bd5f5e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sat, 7 Nov 2026 13:05:01 +0800 Subject: [PATCH 068/149] test: cover macOS CUA channel policy forwarding on every platform --- .../cua-macos/tests/provider-channel.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/experimental/cua-macos/tests/provider-channel.spec.ts diff --git a/packages/experimental/cua-macos/tests/provider-channel.spec.ts b/packages/experimental/cua-macos/tests/provider-channel.spec.ts new file mode 100644 index 0000000000..02d9fc0fe6 --- /dev/null +++ b/packages/experimental/cua-macos/tests/provider-channel.spec.ts @@ -0,0 +1,36 @@ +/** Provider configuration reaches the native transport without opening a macOS connection. */ +import { resolve } from 'node:path' +import { PassThrough } from 'node:stream' +import { Context } from '@deepseek-ai/cordis' +import { installCua } from '@deepseek-ai/dsh-cua' +import type { NativeChannel } from '@deepseek-ai/dsh-cua/native-client' +import { expect, it, onTestFinished, vi } from 'vitest' +import { apply, Config } from '../src/index.ts' +import { createSharedNativeChannel } from '../src/shared-native.ts' + +vi.mock('@deepseek-ai/dsh-cua', async importOriginal => ({ + ...await importOriginal(), + installCua: vi.fn(), +})) +vi.mock('../src/shared-native.ts', () => ({ createSharedNativeChannel: vi.fn() })) + +it.each([false, true])('forwards systemPointerHover=%s and native limits to each channel', async (systemPointerHover) => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const config = Config({ helperPath: resolve('fixture-helper'), systemPointerHover }) + const channel: NativeChannel = { + stdin: new PassThrough(), stdout: new PassThrough(), done: Promise.resolve(), + close() {}, waitForClose: async () => {}, + } + onTestFinished(() => { channel.stdin.destroy(); channel.stdout.destroy() }) + vi.mocked(installCua).mockClear() + vi.mocked(createSharedNativeChannel).mockReset().mockReturnValue(channel) + + await apply(ctx, config) + expect(installCua).toHaveBeenCalledTimes(1) + const backend = vi.mocked(installCua).mock.calls[0]![2] + const owner = { subprocess: { spawn: vi.fn() } } + const native = { ...config, helperPath: resolve('fixture-helper'), cwd: process.cwd() } + expect(backend.createChannel(owner, native)).toBe(channel) + expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native, systemPointerHover) +}) From 5a0c6c10f8163c57d0935ac791bbe5894ad59b41 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 13:08:42 +0800 Subject: [PATCH 069/149] fix(cua-macos): validate physical key chords before dispatch --- ...05-cua-scoped-surfaces-and-input.i18n.yaml | 8 ++-- ...026-11-05-cua-scoped-surfaces-and-input.md | 4 +- ...-11-05-cua-scoped-surfaces-and-input.zh.md | 4 +- .../cua-native-platform/guide.i18n.yaml | 4 +- .../v0.2.2-rc.1/cua-native-platform/guide.md | 2 +- .../cua-native-platform/guide.zh.md | 2 +- .../experimental/cua-macos/README.i18n.yaml | 4 +- packages/experimental/cua-macos/README.md | 4 +- packages/experimental/cua-macos/README.zh.md | 4 +- .../cua-macos/native/README.i18n.yaml | 8 ++-- .../experimental/cua-macos/native/README.md | 4 +- .../cua-macos/native/README.zh.md | 4 +- .../Sources/CuaNative/KeyboardInput.swift | 31 ++++++++----- .../Sources/CuaNative/RequestAdmission.swift | 10 ++-- .../cua-macos/native/protocol.schema.json | 3 +- .../native/tests/capabilities_e2e.py | 13 +++--- .../tests/swift/KeyboardChordTests.swift | 46 ++++++++++++++++--- .../native/tests/swift/ProtocolTests.swift | 12 +++++ .../experimental/cua-macos/src/guidance.ts | 1 + .../cua-macos/tests/guidance.spec.ts | 8 +++- packages/experimental/cua/README.i18n.yaml | 2 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/src/guidance.ts | 2 +- packages/experimental/cua/src/protocol.ts | 5 +- packages/experimental/cua/src/types.ts | 2 +- .../experimental/cua/tests/protocol.spec.ts | 7 +++ .../cua-native/system-prompt.expected.md | 2 +- .../cua-native/tool-schemas.expected.json | 3 +- .../session/cua-ptc/system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- 30 files changed, 137 insertions(+), 68 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml index f3a0d8f1d3..adad612610 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml @@ -9,11 +9,11 @@ en: f72db0b79aa02fb5 zh: b91c1f177dc93ab4 /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/decision: - en: ec115e11f96ffe60 - zh: c1fe9eb2866196d0 + en: 74f97ec371e61b82 + zh: a8a5cefc1e06bb20 /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/alternatives-considered: - en: 32c970ae153e0f6c - zh: 8431be0a10ffed15 + en: 2113f17a49e16670 + zh: 1df04d9736a3ad4f /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/consequences: en: 5c6ec60b77f1bff0 zh: e1ae568ef3501b7b diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md index 609e5bf901..655172fb19 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md @@ -14,7 +14,7 @@ Application and window objects share accessibility operations, but only an expli Provider startup capabilities are static. Platform, supported methods, application-level accessibility, keyboard scopes, browser support, and coordinate space are fixed when the provider is installed. The native `getCapabilities` RPC reports runtime permissions and session lock only; `installCua` merges the two layers for the model-visible result. The neutral RPC is a common operation representation, not a capability inventory; provider guidance names the actions its platform supports. -Keyboard input keeps keys and modifiers separate: `pressKey(key, modifiers?)` presses and releases one key while holding the listed modifiers, and its keyboard modifier list is a plain string array defined by the provider. `sendKeyEvent(key, direction)` sends exactly one key-down or key-up without an implicit counterpart. A caller can therefore hold a modifier or split a gesture; neither method packs a chord string, and the shared schema does not enumerate platform key names. +Keyboard input keeps keys and modifiers separate: `pressKey(key, modifiers?)` presses one key or, on macOS, several physical keys in order and releases them in reverse while holding the listed modifiers, and its keyboard modifier list is a plain string array defined by the provider. `sendKeyEvent(key, direction)` sends exactly one key-down or key-up without an implicit counterpart. A caller can therefore hold a modifier or split a gesture; neither method packs a chord string, and the shared schema does not enumerate platform key names. Within one API instance, the object facade keeps one current accessibility snapshot per application handle, shared by every application and window object for that handle, including repeated selections of the same application. `getWindow` reads its initial tree silently; `getApp` does the same when the provider reports application-level accessibility. A later `getAccessibilityTree` read invalidates the previous snapshot before dispatch. The facade automatically attaches the latest snapshot ID to element operations; callers use element IDs from the latest tree and do not assume an older ID still identifies the same control. Element IDs are opaque provider strings. Only an explicit `CuaWindow` captures screenshots or sends pointer input. The native helper retains its own application and optional-window snapshots behind that facade; the native layer and the facade snapshot remain separate. The external runtime continues to own variable persistence and completion serialization; the facade adds no evaluator. The [native object decision](2026-09-17-experimental-cua-object-api.md) owns evaluator independence, and the [shared helper decision](2026-10-09-cua-shared-native-helper.md) owns connection lifetime. @@ -24,7 +24,7 @@ Within one API instance, the object facade keeps one current accessibility snaps **An application-specific branch in the shared base** would let window objects inherit decisions and capability checks that apply only to applications. Scope factories that inject their own requirements keep the shared operations independent of which scope uses them. -**A packed chord string** requires parsing key names, separators, and plus escaping together, and it pushes platform alias normalization into the shared layer. Keystroke and direction are separate semantics: `pressKey` describes one complete keystroke, while `sendKeyEvent` describes one explicit direction. Separate key and modifier values keep those semantics without a parser. +**A packed chord string** requires parsing key names, separators, and plus escaping together, and it pushes platform alias normalization into the shared layer. Keystroke and direction are separate semantics: `pressKey` describes a complete press and release, while `sendKeyEvent` describes one explicit direction. Separate key and modifier values keep those semantics without a parser. **Platform keyboard key enumerations in the shared schema** would couple the portable operation schema to one provider's key names and require a schema change for each platform. Provider-defined strings let the provider interpret its own names while the shared schema stays neutral. diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md index 081f02986e..495ba2d399 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md @@ -14,7 +14,7 @@ Status: implemented Provider 的启动能力是静态的。平台、支持的方法、应用级辅助功能、键盘作用域、浏览器支持和坐标空间在安装 provider 时固定。原生 `getCapabilities` RPC 只报告运行时权限和会话锁定状态;`installCua` 为模型可见结果合并两层。中立的 RPC 是通用操作表示,不是能力清单;provider 指导文本说明其平台支持的动作。 -键盘输入把按键与修饰键分开:`pressKey(key, modifiers?)` 在按住所列修饰键时按下并释放一个键,其键盘修饰键列表是 provider 定义的普通字符串数组。`sendKeyEvent(key, direction)` 只发送一次按下或抬起,不隐式发送相反事件。因此调用方可以保持修饰键或拆分手势;两个方法都不接受组合键字符串,共享 schema 也不枚举平台按键名。 +键盘输入把按键与修饰键分开:`pressKey(key, modifiers?)` 在按住所列修饰键时按下并释放指定的键。macOS 也接受多个物理键,依次按下并逆序释放。修饰键名称由提供方定义,以字符串数组传入。`sendKeyEvent(key, direction)` 只发送一次按下或抬起,不隐式发送相反事件。因此调用方可以保持修饰键或拆分手势;两个方法都不接受组合键字符串,共享 schema 也不枚举平台按键名。 在单个 API 实例内,对象门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享这份快照,包括对同一应用的重复选择。`getWindow` 静默执行首次树读取;当 provider 报告应用级辅助功能时,`getApp` 也静默读取。之后的 `getAccessibilityTree` 读取会在分派前使上一份快照失效。门面会为元素操作自动附加最新快照 ID;调用方应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。元素 ID 是 provider 定义的不透明字符串。只有显式 `CuaWindow` 可以截图或发送指针输入。原生 helper 在该门面之后保留自己的应用快照和可选窗口快照;原生层与门面快照相互独立。外部运行时继续负责变量持久化和最终表达式序列化;门面不加入 evaluator。[原生对象决定](2026-09-17-experimental-cua-object-api.zh.md)负责 evaluator 独立性,[共享 helper 决定](2026-10-09-cua-shared-native-helper.zh.md)负责连接生命期。 @@ -24,7 +24,7 @@ Provider 的启动能力是静态的。平台、支持的方法、应用级辅 **在共享基类中加入应用专属分支**会让窗口对象继承只适用于应用的决定和能力检查。由各作用域工厂注入自己的要求,可以让共享操作不依赖使用它的作用域。 -**打包的组合键字符串**需要一起解析按键名、分隔符和加号转义,并把平台别名归一化推入共享层。击键与方向是两种语义:`pressKey` 描述一次完整击键,`sendKeyEvent` 描述一个显式方向。分开的按键和修饰键值让这些语义无需解析器。 +**打包的组合键字符串**需要一起解析按键名、分隔符和加号转义,并把平台别名归一化推入共享层。击键与方向是两种语义:`pressKey` 在一次调用中完成按下和释放,`sendKeyEvent` 描述一个显式方向。分开的按键和修饰键值让这些语义无需解析器。 **在共享 schema 中枚举平台键盘按键**会把可移植的操作 schema 绑定到某个 provider 的按键名,并让每个平台都修改 schema。由 provider 定义字符串,平台可以解释自己的名称,共享 schema 保持中立。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml index a76d4de320..c3d5791588 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml @@ -12,5 +12,5 @@ en: e5d1027faa55adba zh: 4b88ad6a70977fb3 /native-cua-application-and-window-api/migration: - en: b09e3dde32391786 - zh: f702e1e25b260b29 + en: 4cf8b18af0d2bb79 + zh: 7f490fd9785deffe diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md index 3179dae0d9..363e627e66 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md @@ -18,6 +18,6 @@ Providers supply startup capabilities alongside their platform guidance. The nat 1. After `cua.getApp(selector)`, select the target from `app.getWindows()` with `app.getWindow(windowId)`. Use `window.getScreenshot()`, `window.click()`, `window.scroll()`, and `window.drag()`. Replace `hover` with `window.mouseMove()`. On macOS, `window.click(id)` sends mouse events at the element center. Switch to `invoke(id)` for the default accessibility action, or `performAction(id, "AXShowMenu")` to request an advertised menu action. 2. Replace `getAXState()` with `getAccessibilityTree()`. Replace `getAXStateAndScreenshot()` with separate tree and screenshot reads. Selection reads the initial tree silently for a window, and for an app when `appLevelAccessibility` is true. Read the tree explicitly to display it. 3. Pass the opaque string ID from the latest tree wherever you passed a numeric element index. In raw requests use `elementId` with `snapshotId`. Replace `performSecondaryAction` with `listActions(id)` and `performAction(id, exactName, argument?)`, or use `invoke(id)` for the default action. `setValue` accepts the provider’s supported JSON values. On macOS, `performAction(id, 'AXRaise')` performs the advertised raise action. Call `app.activate()` first when the application must become active. -4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`: macOS supports complete keystrokes and rejects directional events with `NOT_IMPLEMENTED`. +4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`: macOS supports complete keystrokes and rejects directional events with `NOT_IMPLEMENTED`. If your script handles unknown macOS key names as `NATIVE_ERROR`, handle `INVALID_ARGUMENT` instead. Correct the key name before retrying. 5. Read `dispatched` from action results, then observe the application to verify the outcome. `invoke` returns `dispatched: false` when the element has no default action. Remove reads of the old `verified` field and of `pid` on selected objects or window records. Use `listApps()` or running `findApps()` matches when you need a PID. 6. For a custom provider, replace `CuaBackend.description` with `capabilities` and pass `guidance` and `permissionParameters` to `installCua`. Rebuild any custom `helperPath` against the [operation schema](../../../../packages/experimental/cua/src/protocol.ts) and [tagged permission response](../../../../packages/experimental/cua/src/access-types.ts). Restart the provider, inspect its advertised methods and keyboard scopes, and open the Computer Use permission page to verify its platform controls. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md index f0a4a905e0..272e4a96d4 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md @@ -18,6 +18,6 @@ CUA 脚本在指定窗口上截图和操作鼠标。App 和 Window 对象共享 1. 调用 `cua.getApp(selector)` 后,从 `app.getWindows()` 返回的列表选定目标,再调用 `app.getWindow(windowId)`。在窗口上调用 `getScreenshot()`、`click()`、`scroll()` 和 `drag()`,将原有的 `hover` 调用改为 `window.mouseMove()`。在 macOS 上,`window.click(id)` 在元素中心发送鼠标事件。需要执行默认辅助功能动作时,改用 `invoke(id)`。需要打开控件菜单且控件声明了 `AXShowMenu` 时,调用 `performAction(id, "AXShowMenu")`。 2. 将 `getAXState()` 改为 `getAccessibilityTree()`,将 `getAXStateAndScreenshot()` 拆成读取树和截图两次调用。选择窗口时会静默读取初始树。选择应用时,只有 `appLevelAccessibility` 为真才读取。要显示树,请显式读取。 3. 将数字元素索引改为从最新树中取得的字符串 ID。直接发送请求时使用 `elementId` 和 `snapshotId`。原先调用 `performSecondaryAction` 的地方,先用 `listActions(id)` 取得完整动作名,再调用 `performAction(id, exactName, argument?)`。要执行默认动作,可调用 `invoke(id)`。`setValue` 接受提供方支持的 JSON 值。 在 macOS 上,`performAction(id, 'AXRaise')` 只执行元素声明的抬起窗口动作。需要把应用切到前台时先调用 `app.activate()`。 -4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。macOS 支持完整按键,单独发送按下或松开事件会返回 `NOT_IMPLEMENTED`。 +4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。macOS 支持完整按键,单独发送按下或松开事件会返回 `NOT_IMPLEMENTED`。如果脚本用 `NATIVE_ERROR` 识别 macOS 上的未知键名,请改为处理 `INVALID_ARGUMENT`,并在重试前修正键名。 5. 从动作结果读取 `dispatched`,再观察应用确认效果。元素没有默认动作时,`invoke` 返回 `dispatched: false`。移除读取旧字段 `verified` 的代码,以及对已选对象和窗口记录中 `pid` 的读取。需要 PID 时,使用 `listApps()` 或 `findApps()` 返回的运行中应用记录。 6. 自定义提供方需将 `CuaBackend.description` 改为 `capabilities`,并向 `installCua` 传入 `guidance` 和 `permissionParameters`。如配置了 `helperPath`,请按[操作协议](../../../../packages/experimental/cua/src/protocol.ts)和[带平台标记的权限响应](../../../../packages/experimental/cua/src/access-types.ts)重新构建 helper。重启提供方,检查它声明的方法和键盘输入范围,再打开 Computer Use 权限页确认平台控件。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index bcc12b8ecc..6a38002c4f 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: e3643640ef764471 - zh: 5d25e833cb78bf9b + en: 24ea893846cf0914 + zh: dca284e8e589274d /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 63e1c495d2..60d57c749d 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -79,7 +79,7 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-cua-macos) lists all fields. Native tool calls use `{method, params}`; the complete method-specific schema is exposed to the model. `getApp` returns the `appHandle` used by every app-targeted operation, and `app.getWindow(windowId)` returns the object that owns screenshots and pointer input. -`pressKey(key, modifiers?)` accepts one key or a nonempty array of distinct keys. `pressKey(['w', 'd'])` presses W then D and releases D then W. Pass modifier names separately, for example `pressKey('Tab', ['shift'])` or `pressKey('+')`; an omitted modifier array means none. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. On macOS, use `pressKey` on the App object. The shared `sendKeyEvent` operation returns `NOT_IMPLEMENTED` on this provider. +`pressKey(key, modifiers?)` accepts one key or a nonempty array of distinct physical keys. Aliases such as `Enter` and `Return` identify the same key. `pressKey(['w', 'd'])` presses W then D and releases D then W. Pass held modifiers separately, as in `pressKey('Tab', ['shift'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. In a multi-key chord, use `=` and pass Shift explicitly to apply it to the whole chord. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. On macOS, use `pressKey` on the App object. The shared `sendKeyEvent` operation returns `NOT_IMPLEMENTED` on this provider. Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot. @@ -221,7 +221,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 276f725cb3..cadefe0dd6 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -79,7 +79,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-cua-macos)列出全部字段。原生工具调用使用 `{method, params}`,模型可以看到每个方法的完整参数定义。`getApp` 返回每个面向应用的操作所需的 `appHandle`,`app.getWindow(windowId)` 返回负责截图和指针输入的对象。 -`pressKey(key, modifiers?)` 接受单个键名或由不同键名组成的非空数组。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。修饰键名称单独传入,例如 `pressKey('Tab', ['shift'])` 或 `pressKey('+')`;省略修饰键数组表示不按修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。在 macOS 上,通过 App 对象调用 `pressKey`。调用共享接口 `sendKeyEvent` 会收到 `NOT_IMPLEMENTED`。 +`pressKey(key, modifiers?)` 接受单个键名或由不同物理键组成的非空数组。`Enter` 和 `Return` 等别名指向同一个物理键。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。需要全程按住的修饰键单独传入,例如 `pressKey('Tab', ['shift'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。单独传入 `+` 或 `plus` 会发送 Shift 和等号。在多键组合中请使用 `=`,需要全程按住 Shift 时将其列入修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。在 macOS 上,通过 App 对象调用 `pressKey`。调用共享接口 `sendKeyEvent` 会收到 `NOT_IMPLEMENTED`。 Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 @@ -221,7 +221,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 6e9294f0ce..7382f3006b 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,11 +21,11 @@ en: 88a59904ca4ebedc zh: 14f2b666d7382145 /dsh-cua-native-helper/observation-and-input: - en: d19180b1a6db2e48 - zh: c6279db9957218eb + en: 51a70780a0672272 + zh: e7ca17dd40b887aa /dsh-cua-native-helper/verification: - en: 49f4cce2a4cc3114 - zh: e9d65c92ef35fe24 + en: ade5a2ccd3aeb1b4 + zh: 74c2b0bcb23fc4bc /dsh-cua-native-helper/further-exploration: en: 265e1927952ce6d7 zh: 7c35d9fd98b6e6c8 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index b94d053746..2437c220a4 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -103,7 +103,7 @@ Clicks send mouse-down and mouse-up events at the element center or the supplied Capture and pointer delivery read CG bounds before and after an exact-window AX frame query. Movement during the query returns WINDOW_CHANGED. Valid AX/CG frames must have equal outward-rounded integral point rectangles; a known mismatch returns WINDOW_CHANGED before mouse movement, priming or button delivery. The helper preserves both coordinate sources rather than substituting AX geometry for CG geometry. Missing or unusable AX geometry remains unknown, while AX communication, permission and invalid-element errors propagate. Capture without Accessibility permission retains its CG/SCK checks. Advertised AX actions bypass this pointer preflight; an AX action error does not trigger pointer input. These reads do not lock the window against later movement, and no geometry check activates or raises it. -`typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses its modifiers followed by the requested keys in order, then releases all pressed keys in reverse order, including on cancellation; these events do not use the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. +`typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses its modifiers followed by the requested keys in order, then releases all pressed keys in reverse order, including on cancellation. Keyboard events go directly to the target PID and bypass the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. The selected helper application needs macOS Accessibility permission for AX state and input, and Screen Recording permission for screenshots. Capability preflight and permission errors do not grant permissions. Capture refuses a window that moves or resizes while the image is being read. Unassociated system panels and accessory-only applications are not selectable. @@ -157,7 +157,7 @@ For text pacing comparisons, use `native/tests/text_input_benchmark.py` with `-- For an independently isolated Electron/Chromium check, run `python3 packages/experimental/cua-macos/native/tests/electron_e2e.py --electron /absolute/path/to/Electron.app/Contents/MacOS/Electron`. Omitting `--electron` uses an already installed desktop workspace runtime. The check never downloads Electron. Its renderer runs with Node integration disabled, context isolation and Chromium sandboxing enabled, private userData/sessionData/log directories, and a local fixture page. Renderer click/input events write independent observations through the main process; all test actions still use the native helper. AX polling waits for actual controls after Chromium accessibility activation. Native and Electron subprocess groups are owned and stopped before temporary files are removed. Optional artifacts include a fixture-only screenshot and private request transcript. -Run `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence` to verify a stopped application's discovery and launch, Unicode selection, multiline plain/HTML paste, middle/modifier gestures and keypad identities through an isolated AppKit receiver. Add `--click-only` to check click holds, AX routing and cancellation/timeout release in a background fixture without keyboard or clipboard input. It requires existing Accessibility/Screen Recording grants and records the receiver's actual state separately from dispatch results. +Run `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence` to verify a stopped application's discovery and launch, Unicode selection, multiline plain/HTML paste, middle/modifier gestures, overlapping W and D key events, and keypad identities through an isolated AppKit receiver. Add `--click-only` to check click holds, AX routing and cancellation/timeout release in a background fixture without keyboard or clipboard input. It requires existing Accessibility/Screen Recording grants and records the receiver's actual state separately from dispatch results. Run `python3 packages/experimental/cua-macos/native/tests/paste_e2e.py --artifacts /private/evidence` to verify that an owned AppKit app accepts a background Paste action without reading its data, reports `PASTE_NOT_CONSUMED`, then consumes the same paste in the foreground. The test requires an unlocked Mac and Accessibility permission, activates only its own two applications, and records received actions and text independently of helper replies. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 7ddde7d4f4..1224e0e6db 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -103,7 +103,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 截图和鼠标输入在精确窗口的 AX 边界查询前后读取 CG 边界,查询期间移动会返回 WINDOW_CHANGED。有效的 AX/CG 边界向外取整后必须一致;已知不一致会在移动、预备事件或按键发送前返回 WINDOW_CHANGED。helper 保留两个坐标来源,不用 AX 几何替换 CG 几何。缺失或无效的 AX 几何仍表示未知,AX 通信、权限和无效元素错误继续传递。无辅助功能权限的截图保留 CG/SCK 检查。声明的 AX 动作不经过鼠标预检,AX 动作失败也不改用鼠标输入。这些读取不会锁住窗口以阻止后续移动,几何检查不激活或抬起窗口。 -`typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和指定按键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 +`typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和指定按键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。键盘事件直接发给目标进程,绕过全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 所选 helper 应用需要 macOS 辅助功能权限以读取 AX 和输入,需要屏幕录制权限以截图。能力预检和权限错误不授予权限。截图会拒绝在读取图片期间移动或缩放的窗口。无法确认归属的系统面板及仅后台辅助类型的应用不可选择。 @@ -157,7 +157,7 @@ Blender 键盘检查使用 `python3 packages/experimental/cua-macos/native/tests 隔离的 Electron/Chromium 检查使用 `python3 packages/experimental/cua-macos/native/tests/electron_e2e.py --electron /absolute/path/to/Electron.app/Contents/MacOS/Electron`。省略 `--electron` 时使用桌面工作区已安装的运行时,检查不下载 Electron。渲染器禁用 Node 集成,启用上下文隔离和 Chromium 沙箱,使用独立 userData/sessionData/log 目录及本地 fixture 页面。渲染器的点击/输入事件通过主进程写出独立观察,测试动作仍通过原生 helper 执行。AX 轮询等待 Chromium 辅助功能启用后的真实控件。原生和 Electron 子进程组由测试持有,在删除临时文件前停止。可选产物包含仅有 fixture 的截图和私有请求记录。 -运行 `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence`,通过隔离的 AppKit 接收方验证未运行应用的发现和启动、Unicode 选区、多行纯文本/HTML 粘贴、中键/修饰键手势及数字键盘身份。需要已有辅助功能/屏幕录制权限,接收方实际状态与分派结果分开记录。 添加 `--click-only` 可在后台测试窗口中验证按住时长、AX 路由和取消、超时后的松开事件,不发送键盘或剪贴板输入。 +运行 `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence`,通过隔离的 AppKit 接收方验证未运行应用的发现和启动、Unicode 选区、多行纯文本/HTML 粘贴、带修饰键的中键手势、W 和 D 同时按住时的事件顺序及数字键盘键码。需要已有辅助功能/屏幕录制权限,接收方实际状态与分派结果分开记录。 添加 `--click-only` 可在后台测试窗口中验证按住时长、AX 路由和取消、超时后的松开事件,不发送键盘或剪贴板输入。 运行 `python3 packages/experimental/cua-macos/native/tests/paste_e2e.py --artifacts /private/evidence`,验证自有 AppKit 应用在后台接收 Paste 动作但不读取数据时返回 `PASTE_NOT_CONSUMED`,以及前台读取同一次粘贴后成功。测试要求 Mac 已解锁并已授予辅助功能权限,只激活自有的两个应用,并独立记录收到的动作和文本以核对 helper 回复。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift index 2dbf648ecf..3511256457 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift @@ -22,23 +22,32 @@ final class KeyboardInput { /// Prepare every event before dispatch. On cancellation, release only the keys this call pressed. func pressKey(_ keys: [String], modifiers: [String], to pid: Int32) throws { try checkCancellation() - var modifierNames = modifiers - let mainKeys = try keys.map { name -> Key in + var chord = try modifiers.map(Self.modifier) + var namesByCode: [CGKeyCode: String] = [:] + for name in keys { + let key: Key if name == "+" || name.lowercased() == "plus" { - modifierNames.append("shift") - return Key(keyCode: try Self.keyCode("="), flag: []) + guard keys.count == 1 else { + throw RequestParameters.invalid("For a multi-key chord, use = with an explicit shift modifier instead of \(name)") + } + chord.append(try Self.modifier("shift")) + key = Key(keyCode: try Self.keyCode("="), flag: []) + } else if let modifier = Self.namedModifier(name) { + key = modifier + } else { + key = Key(keyCode: try Self.keyCode(name), flag: []) } - if let modifier = Self.namedModifier(name) { return modifier } - return Key(keyCode: try Self.keyCode(name), flag: []) - } - guard Set(mainKeys.map(\.keyCode)).count == mainKeys.count else { - throw RequestParameters.invalid("A chord must contain distinct physical keys") + if let previous = namesByCode.updateValue(name, forKey: key.keyCode) { + throw RequestParameters.invalid("Keys '\(previous)' and '\(name)' name the same physical key") + } + chord.append(key) } var selected = Set() var flags: CGEventFlags = [] var events: [(down: CGEvent, up: CGEvent)] = [] let source = try Self.source() - for key in try modifierNames.map(Self.modifier) + mainKeys where selected.insert(key.keyCode).inserted { + for key in chord { + guard selected.insert(key.keyCode).inserted else { continue } let releasedFlags = flags flags.formUnion(key.flag) events.append(( @@ -243,7 +252,7 @@ final class KeyboardInput { case "m": return 46 case ".": return 47 case "`": return 50 - default: throw NativeFailure(code: "NATIVE_ERROR", message: "Unknown key name: \(key)") + default: throw RequestParameters.invalid("Unknown key name: \(key)") } } } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift index ef2b6dc101..0041e88b2a 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift @@ -37,13 +37,11 @@ struct RequestParameters: Sendable { } } func pressKeys() throws -> [String] { - if let name = values["key"]?.string { - guard !name.isEmpty else { throw Self.invalid("key must not be empty") } - return [name] + switch values["key"] { + case .string(let name) where !name.isEmpty: return [name] + case .array(let items) where !items.isEmpty: return try strings("key") + default: throw Self.invalid("key must be a nonempty string or a nonempty array of key names") } - let names = try strings("key") - guard !names.isEmpty else { throw Self.invalid("key must name at least one key") } - return names } func optionalString(_ key: String) throws -> String? { contains(key) ? try string(key) : nil } func optionalBoolean(_ key: String) throws -> Bool? { diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index aa6d201dcb..ce960cea7e 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -1006,7 +1006,8 @@ "minLength": 1 } } - ] + ], + "description": "One key name or distinct physical keys to press in order and release in reverse." }, "modifiers": { "type": "array", diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 95a94c97b6..f9c280a193 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -188,15 +188,14 @@ def main(): if not args.click_only: call("click", {**window, **point, "mouseButton": "middle", "modifiers": ["shift"]}) state = wait_for_state(state_file, lambda value: any(event["type"] == 26 and event.get("button") == 2 and event["shift"] for event in value["events"])) + count = len(state["events"]) + call("pressKey", {**owner, "key": ["w", "d"]}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) + chord = [(event["type"], event.get("keyCode")) for event in state["events"][count:]] + assert chord == [(10, 13), (10, 2), (11, 2), (11, 13)], chord call("drag", {**window, "fromX": point["x"], "fromY": point["y"], "toX": point["x"] + 80, "toY": point["y"] + 20, "durationMs": 200, "steps": 10, "mouseButton": "middle", "modifiers": ["shift"]}) state = wait_for_state(state_file, lambda value: any(event["type"] == 27 and event.get("button") == 2 and event["shift"] for event in value["events"])) - call("pressKey", {**owner, "key": ["w", "d"]}) - expected_chord = [(10, 13), (10, 2), (11, 2), (11, 13)] - state = wait_for_state(state_file, lambda value: any( - event["type"] == 11 and event.get("keyCode") == 13 for event in value["events"])) - assert [(event["type"], event["keyCode"]) for event in state["events"] - if event.get("keyCode") in (13, 2)] == expected_chord, state["events"] call("pressKey", {**owner, "key": "KP_0"}) call("pressKey", {**owner, "key": "KP_Enter"}) state = wait_for_state(state_file, lambda value: {82, 76}.issubset({event.get("keyCode") for event in value["events"]})) @@ -207,7 +206,7 @@ def main(): (args.artifacts / "capabilities.png").write_bytes(base64.b64decode(shot["data"])) (args.artifacts / "state.json").write_text(json.dumps(state, ensure_ascii=False, indent=2)) print("PASS: click holds and cancellation/timeout/disconnect release" if args.click_only else - "PASS: stopped-app launch, Unicode selection, plain/HTML paste, middle/modifier drag, keypad identity and click holds") + "PASS: stopped-app launch, Unicode selection, plain/HTML paste, middle/modifier drag, overlapping W+D events, keypad identity and click holds") finally: if pid is None and state_file.exists(): pid = json.loads(state_file.read_text())["pid"] diff --git a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift index ae98c891d6..6ca44cd59a 100644 --- a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift @@ -3,14 +3,19 @@ import CoreGraphics import Testing @testable import CuaNative -@Test func chordKeepsModifiersThroughBothLetterReleases() throws { +@Test(arguments: [ + (["w", "d"], ["shift"], [56, 13, 2, 2, 13, 56]), + (["shift", "w", "d"], [], [56, 13, 2, 2, 13, 56]), + (["a", "="], ["shift"], [56, 0, 24, 24, 0, 56]), +]) +func chordKeepsShiftThroughBothKeyReleases(_ keys: [String], _ modifiers: [String], _ codes: [Int64]) throws { var events: [CGEvent] = [] let input = KeyboardInput(post: { event, pid in #expect(pid == 42) events.append(event) }, textEventInterval: 0) - try input.pressKey(["w", "d"], modifiers: ["shift"], to: 42) - #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [56, 13, 2, 2, 13, 56]) + try input.pressKey(keys, modifiers: modifiers, to: 42) + #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == codes) #expect(events.map(\.type) == [.flagsChanged, .keyDown, .keyDown, .keyUp, .keyUp, .flagsChanged]) #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, true, true, false]) } @@ -29,8 +34,37 @@ func cancelledChordReleasesOnlyPressedKeys(_ cancelAfter: Int) { #expect(events.map(\.type) == Array(repeating: .keyDown, count: cancelAfter) + Array(repeating: .keyUp, count: cancelAfter)) } -@Test(arguments: [["w", "unknown"], ["w", "W"], ["Enter", "Return"]]) -func invalidChordPostsNoInput(_ keys: [String]) { +@Test(arguments: [ + (["w", "unknown"], "Unknown key name: unknown"), + (["w", "W"], "Keys 'w' and 'W' name the same physical key"), + (["Enter", "Return"], "Keys 'Enter' and 'Return' name the same physical key"), + (["a", "+"], "For a multi-key chord, use = with an explicit shift modifier instead of +"), + (["Plus", "w"], "For a multi-key chord, use = with an explicit shift modifier instead of Plus"), +]) +func invalidChordPostsNoInput(_ keys: [String], _ message: String) throws { let input = KeyboardInput(post: { _, _ in Issue.record("Invalid chord posted input") }, textEventInterval: 0) - #expect(throws: NativeFailure.self) { try input.pressKey(keys, modifiers: ["shift"], to: 42) } + do { + try input.pressKey(keys, modifiers: ["shift"], to: 42) + Issue.record("Invalid chord was accepted") + } catch let error as NativeFailure { + #expect(error.code == "INVALID_ARGUMENT") + #expect(error.message == message) + } +} + +@Test(arguments: ["+", "plus"]) +func plusAlonePressesShiftEquals(_ alias: String) throws { + var events: [CGEvent] = [] + let input = KeyboardInput(post: { event, _ in events.append(event) }, textEventInterval: 0) + try input.pressKey([alias], modifiers: [], to: 42) + #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [56, 24, 24, 56]) + #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, false]) +} + +@Test func standaloneShiftSetsItsFlagOnlyWhileHeld() throws { + var events: [CGEvent] = [] + let input = KeyboardInput(post: { event, _ in events.append(event) }, textEventInterval: 0) + try input.pressKey(["shift"], modifiers: [], to: 42) + #expect(events.map(\.type) == [.flagsChanged, .flagsChanged]) + #expect(events.map { $0.flags.contains(.maskShift) } == [true, false]) } diff --git a/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift b/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift index 84a9fec849..85fa37aaf0 100644 --- a/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift @@ -138,3 +138,15 @@ func rejectsMalformedApplicationIconPaths(_ value: String) throws { let request = try NativeRequest(data: Data("{\"id\":\"icons\",\"method\":\"applicationIcons\",\"params\":{\"paths\":\(value)}}".utf8)) #expect(throws: NativeFailure.self) { try RequestParameters(request).strings("paths") } } + +@Test func invalidPressKeyTypeNamesBothAcceptedForms() throws { + let request = try NativeRequest(data: Data(#"{"id":"call","method":"pressKey","params":{"key":7}}"#.utf8)) + let params = try RequestParameters(request) + do { + _ = try params.pressKeys() + Issue.record("Numeric key was admitted") + } catch let error as NativeFailure { + #expect(error.code == "INVALID_ARGUMENT") + #expect(error.message == "key must be a nonempty string or a nonempty array of key names") + } +} diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index f8779651a9..c7726f385b 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -13,6 +13,7 @@ const methodDetails = new Map([ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { + if (method === 'pressKey') return "- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed." const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index f3e530b7de..16fb39122a 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -27,6 +27,10 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(PLATFORM_GUIDANCE).toContain('accessibility tree operations are available at app or window level') expect(PLATFORM_GUIDANCE).toContain('Simulated keyboard input is available at app level') expect(PLATFORM_GUIDANCE).toContain('Simulated mouse input is available at window level') + const pressKey = GUIDANCE.split('\n').find(line => line.startsWith('- pressKey(')) + expect(pressKey).toContain('key: string | string[]') + expect(pressKey).toContain("pressKey(['w', 'd']) presses W then D and releases D then W") + expect(pressKey).toContain("Packed strings such as 'w+d'") const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove(')) for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'systemPointerHover']) expect(hover).toContain(term) const click = GUIDANCE.split('\n').find(line => line.startsWith('- click(')) @@ -89,7 +93,7 @@ it('preserves image formats and pointer modifier enums without restricting keybo for (const name of ['click', 'drag']) { expect(lines.find(line => line.startsWith(`- ${name}(`))).toContain("modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[]") } - expect(COMMON_GUIDANCE).toContain('pressKey(key: string | string[], modifiers?: string[])') + expect(COMMON_GUIDANCE).toContain('pressKey(key: string, modifiers?: string[])') expect(COMMON_GUIDANCE).toContain('Key and modifier names are platform-defined strings') }) @@ -130,7 +134,7 @@ it('documents facade initialization, emitted observations, and separate key argu expect(COMMON_GUIDANCE).toContain('host-provided bridge initialization once') expect(COMMON_GUIDANCE).toContain('not a preinstalled global') expect(COMMON_GUIDANCE).toContain('void await') - expect(COMMON_GUIDANCE).toContain("pressKey(['w', 'd']) presses W then D and releases D then W") + expect(COMMON_GUIDANCE).toContain('do not pack them into a chord string') expect(COMMON_GUIDANCE).toContain('perform a left-button double-click') }) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 0d16bc4b02..8587d8c7d5 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -21,7 +21,7 @@ en: d92d5948a7941e8d zh: d43491ae6521b87e /deepseek-ai-dsh-cua/understand-the-implementation: - en: e38d57709aa6d001 + en: 181158347d09c629 zh: 231b2dcaf826d386 /deepseek-ai-dsh-cua/further-exploration: en: c174fb5a00ea3681 diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index e6c0f0778f..9fb8f64eb7 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -91,7 +91,7 @@ One object facade ([src/api.ts](src/api.ts)) builds a shared `CuaSurface` and de Provider startup capabilities are fixed at installation: platform, methods, app-level accessibility, keyboard scopes, browser support, and coordinate space. The native `getCapabilities` RPC reports runtime permissions and session lock only; `installCua` merges both layers for the model-visible result. The raw RPC remains a common operation representation rather than a capability inventory, so provider guidance states the actions its platform supports. -Check `methods` and `keyboard` in the result of `await cua.getCapabilities()` for the supported operations and input scopes. `pressKey(key, modifiers?)` holds the listed modifiers while pressing the requested keys in order and releasing them in reverse. For example, `pressKey(['w', 'd'])` holds W through the D keystroke; `sendKeyEvent(key, direction)` sends exactly one key-down or key-up. `typeText` and `paste` deliver literal text at the provider's declared keyboard scope. Keyboard modifier names are plain strings that the provider interprets. +Check `methods` and `keyboard` in the result of `await cua.getCapabilities()` for the supported operations and input scopes. `pressKey(key, modifiers?)` holds the listed modifiers while pressing the requested keys in order and releasing them in reverse. For example, `pressKey(['w', 'd'])` holds W through the D keystroke. `sendKeyEvent(key, direction)` sends exactly one key-down or key-up. `typeText` and `paste` deliver literal text at the provider's declared keyboard scope. Keyboard modifier names are plain strings that the provider interprets. Platform providers import `CuaConfigFields` from `@deepseek-ai/dsh-cua/config` to extend the shared configuration. The root Host entry exports `installCua`, `CuaConfig`, and the Typert-discovered `CuaAccess` service; its `apply()` only marks the optional Client contribution. `/client` loads the permission UI through the existing module loader. Separate Host and Client compiler projects keep React, CSS, and browser registrations out of headless imports. `/api` owns the app/window API and its types; `/bridge`, `/native-client`, and `/protocol` retain runtime and platform consumers. `/types` exports only the named types required by generated Remote declarations. The Client uses package-local types. `/typert` supplies Host reflection and `/remote` supplies generated browser descriptors and codecs; permission authority remains on the Host. diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 0677be5433..745ccfe9be 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -43,6 +43,6 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. +- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.` diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 8488f32af3..42b545e66a 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -62,7 +62,10 @@ export const Request = z.discriminatedUnion('method', [ selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), - operation('pressKey', { ...app, windowId: id.optional(), key: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]), modifiers: z.array(z.string()).optional() }), + operation('pressKey', { ...app, windowId: id.optional(), + key: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]) + .describe('One key name or distinct physical keys to press in order and release in reverse.'), + modifiers: z.array(z.string()).optional() }), operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), z.object({ method: z.literal('scroll'), params: z.union([ z.object({ ...absoluteCoordinate, diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index 875418d805..da1c1eaf56 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -162,7 +162,7 @@ export interface CuaSurface { /** * Press the keys in order at the current focus, then release them in reverse order. * Rejects when the provider does not support keyboard input at this scope. - * @param key - One provider-defined key name or a nonempty array of distinct keys to hold together. + * @param key - One provider-defined key name or a nonempty array of distinct physical keys to hold together. * @param modifiers - Platform- or provider-defined modifier names held for this keystroke; omitted means none. * @returns Whether the provider successfully executed the requested operation. */ diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index 50fb329c07..c3d69e309c 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -12,6 +12,8 @@ const rejectedRequests: Array<{ reason: string; request: JsonValue }> = [ { reason: 'numeric element id', request: { method: 'click', params: { appHandle: 'app', snapshotId: 'snapshot', elementId: 0 } } }, { reason: 'coordinate click without a window', request: { method: 'click', params: { appHandle: 'app', x: 1, y: 2 } } }, { reason: 'mixed element and coordinate click', request: { method: 'click', params: { appHandle: 'app', snapshotId: 'snapshot', elementId: '0', windowId: 23, x: 1, y: 2 } } }, + { reason: 'empty pressKey chord', request: { method: 'pressKey', params: { appHandle: 'app', key: [] } } }, + { reason: 'non-string pressKey chord member', request: { method: 'pressKey', params: { appHandle: 'app', key: ['w', 7] } } }, { reason: 'non-string pressKey modifier', request: { method: 'pressKey', params: { appHandle: 'app', key: 'a', modifiers: ['command', 7] } } }, { reason: 'sendKeyEvent modifiers', request: { method: 'sendKeyEvent', params: { appHandle: 'app', key: 'a', direction: 'down', modifiers: [] } } }, { reason: 'sendKeyEvent without direction', request: { method: 'sendKeyEvent', params: { appHandle: 'app', key: 'a' } } }, @@ -23,6 +25,11 @@ it.each(rejectedRequests)('rejects $reason at operation admission', ({ request } expect(Request.safeParse(request).success).toBe(false) }) +it('admits an ordered chord in a raw pressKey request', () => { + const request = { method: 'pressKey', params: { appHandle: 'app', key: ['w', 'd'] } } + expect(Request.parse(request)).toEqual(request) +}) + it('admits arbitrary provider-defined pressKey modifier names', () => { const request = { method: 'pressKey', params: { appHandle: 'app', key: 'PlatformKey', modifiers: ['platform-modifier', 'shift'] } } expect(Request.parse(request)).toEqual(request) diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index a39b9145d4..73107a5ee3 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -78,7 +78,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 8f608ec54e..3a94712d06 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -1045,7 +1045,8 @@ "minLength": 1 } } - ] + ], + "description": "One key name or distinct physical keys to press in order and release in reverse." }, "modifiers": { "type": "array", diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 017e5b103b..14f13a5a00 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -80,7 +80,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index ef58a12bd2..6e0914ac15 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -80,7 +80,7 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — press one key or hold distinct keys together. pressKey(['w', 'd']) presses W then D and releases D then W. Pass modifiers separately, as in pressKey('Tab', ['shift']). Key and modifier names are platform-defined strings. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From 89d374d5788c4822d86deed427d1db87c8483866 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 13:33:24 +0800 Subject: [PATCH 070/149] feat(cua-macos): hold physical keys for one run_code execution --- ...05-cua-scoped-surfaces-and-input.i18n.yaml | 4 +- ...026-11-05-cua-scoped-surfaces-and-input.md | 2 +- ...-11-05-cua-scoped-surfaces-and-input.zh.md | 2 +- .../cua-native-platform/guide.i18n.yaml | 4 +- .../v0.2.2-rc.1/cua-native-platform/guide.md | 2 +- .../cua-native-platform/guide.zh.md | 2 +- .../experimental/cua-macos/README.i18n.yaml | 4 +- packages/experimental/cua-macos/README.md | 7 +- packages/experimental/cua-macos/README.zh.md | 7 +- .../cua-macos/native/README.i18n.yaml | 8 +- .../experimental/cua-macos/native/README.md | 4 +- .../cua-macos/native/README.zh.md | 4 +- .../native/Sources/CuaNative/AXWorker.swift | 25 ++++-- .../native/Sources/CuaNative/HeldKeys.swift | 84 +++++++++++++++++++ .../Sources/CuaNative/KeyboardInput.swift | 42 ++++++++-- .../CuaNative/NativeProtocol.generated.swift | 3 +- .../Sources/CuaNative/RequestAdmission.swift | 2 +- .../CuaNative/RequestCoordinator.swift | 4 +- .../Sources/CuaNative/RequestScheduler.swift | 1 + .../cua-macos/native/generate-protocol.ts | 1 + .../cua-macos/native/protocol.schema.json | 38 ++++++++- .../native/tests/capabilities_e2e.py | 16 ++++ .../tests/swift/KeyboardChordTests.swift | 22 +++++ .../experimental/cua-macos/src/guidance.ts | 3 +- .../experimental/cua-macos/src/held-keys.ts | 42 ++++++++++ packages/experimental/cua-macos/src/index.ts | 5 +- .../cua-macos/tests/guidance.spec.ts | 5 +- .../tests/provider-lifecycle.spec.ts | 84 +++++++++++++++++-- packages/experimental/cua/src/index.ts | 4 + .../experimental/cua/src/native-client.ts | 23 ++++- packages/experimental/cua/src/protocol.ts | 2 +- packages/experimental/cua/src/types.ts | 2 +- .../cua/tests/native-client.spec.ts | 12 +++ snapshots/session/cua-native/session.v4.jsonl | 2 +- .../cua-native/system-prompt.expected.md | 3 +- .../session/cua-ptc/system-prompt.expected.md | 3 +- .../system-prompt.expected.md | 3 +- 37 files changed, 428 insertions(+), 53 deletions(-) create mode 100644 packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift create mode 100644 packages/experimental/cua-macos/src/held-keys.ts diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml index adad612610..716f07d599 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml @@ -15,5 +15,5 @@ en: 2113f17a49e16670 zh: 1df04d9736a3ad4f /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/consequences: - en: 5c6ec60b77f1bff0 - zh: e1ae568ef3501b7b + en: c4b12ca80865e670 + zh: 396426fad0105eaa diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md index 655172fb19..6b4fe604d5 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md @@ -34,6 +34,6 @@ Within one API instance, the object facade keeps one current accessibility snaps Element IDs belong to the application's current accessibility snapshot. Reading one window replaces the facade’s current snapshot for that application, so callers observe again and confirm the IDs they use. The new read does not guarantee that an old string is rejected or that an ID changes; callers must not assume an older ID still identifies the same control. Pointer input and screenshots require an explicit window object; callers enumerate windows first. -Keyboard behavior follows the provider's supported scopes. A caller that uses `sendKeyEvent` owns the down/up pairing, because the API never sends the opposite event. Keyboard modifier names are provider-defined, so the same string may have different support across providers. +Keyboard behavior follows the provider's supported scopes. A caller that uses `sendKeyEvent` owns the down/up pairing, during execution. macOS releases that run’s remaining keys when the program ends or the user pauses it. Keyboard modifier names are provider-defined, so the same string may have different support across providers. Provider guidance owns the list of supported actions. Adding a method to the shared schema does not claim that every provider implements it, and the merged `getCapabilities` result remains the runtime source for permissions and session lock. diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md index 495ba2d399..d03c3b4723 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md @@ -34,6 +34,6 @@ Provider 的启动能力是静态的。平台、支持的方法、应用级辅 元素 ID 属于应用当前的辅助功能快照。读取一个窗口会替换门面对该应用的当前快照,因此调用方需要重新观察并确认要使用的 ID。新的读取不保证拒绝旧字符串,也不保证 ID 会改变;调用方不得假定旧 ID 仍指向同一控件。指针输入和截图都要求显式的窗口对象;调用方先枚举窗口。 -键盘行为遵循 provider 支持的作用域。使用 `sendKeyEvent` 的调用方负责按下与抬起的配对,因为 API 从不发送相反事件。键盘修饰键名称由 provider 定义,同一字符串在不同 provider 上可能支持不同。 +键盘行为遵循 provider 支持的作用域。使用 `sendKeyEvent` 的调用方负责按下与抬起的配对,在 macOS 上,程序结束或用户暂停时会释放本次执行仍按着的键。键盘修饰键名称由 provider 定义,同一字符串在不同 provider 上可能支持不同。 Provider 指导文本负责支持动作的清单。把方法加入共享 schema 并不表示每个 provider 都已实现它;合并后的 `getCapabilities` 结果仍是权限和会话锁定状态的运行时来源。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml index c3d5791588..09553adc47 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml @@ -12,5 +12,5 @@ en: e5d1027faa55adba zh: 4b88ad6a70977fb3 /native-cua-application-and-window-api/migration: - en: 4cf8b18af0d2bb79 - zh: 7f490fd9785deffe + en: a0640d4cd1f2e84e + zh: be8d5b5f9d14caf8 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md index 363e627e66..8f5b092012 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md @@ -18,6 +18,6 @@ Providers supply startup capabilities alongside their platform guidance. The nat 1. After `cua.getApp(selector)`, select the target from `app.getWindows()` with `app.getWindow(windowId)`. Use `window.getScreenshot()`, `window.click()`, `window.scroll()`, and `window.drag()`. Replace `hover` with `window.mouseMove()`. On macOS, `window.click(id)` sends mouse events at the element center. Switch to `invoke(id)` for the default accessibility action, or `performAction(id, "AXShowMenu")` to request an advertised menu action. 2. Replace `getAXState()` with `getAccessibilityTree()`. Replace `getAXStateAndScreenshot()` with separate tree and screenshot reads. Selection reads the initial tree silently for a window, and for an app when `appLevelAccessibility` is true. Read the tree explicitly to display it. 3. Pass the opaque string ID from the latest tree wherever you passed a numeric element index. In raw requests use `elementId` with `snapshotId`. Replace `performSecondaryAction` with `listActions(id)` and `performAction(id, exactName, argument?)`, or use `invoke(id)` for the default action. `setValue` accepts the provider’s supported JSON values. On macOS, `performAction(id, 'AXRaise')` performs the advertised raise action. Call `app.activate()` first when the application must become active. -4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`: macOS supports complete keystrokes and rejects directional events with `NOT_IMPLEMENTED`. If your script handles unknown macOS key names as `NATIVE_ERROR`, handle `INVALID_ARGUMENT` instead. Correct the key name before retrying. +4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`. On macOS, call it inside one `run_code` to hold a key across tool calls. Returning, throwing or pausing releases any remaining keys. If your script handles unknown macOS key names as `NATIVE_ERROR`, handle `INVALID_ARGUMENT` instead. Correct the key name before retrying. 5. Read `dispatched` from action results, then observe the application to verify the outcome. `invoke` returns `dispatched: false` when the element has no default action. Remove reads of the old `verified` field and of `pid` on selected objects or window records. Use `listApps()` or running `findApps()` matches when you need a PID. 6. For a custom provider, replace `CuaBackend.description` with `capabilities` and pass `guidance` and `permissionParameters` to `installCua`. Rebuild any custom `helperPath` against the [operation schema](../../../../packages/experimental/cua/src/protocol.ts) and [tagged permission response](../../../../packages/experimental/cua/src/access-types.ts). Restart the provider, inspect its advertised methods and keyboard scopes, and open the Computer Use permission page to verify its platform controls. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md index 272e4a96d4..7580de0836 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md @@ -18,6 +18,6 @@ CUA 脚本在指定窗口上截图和操作鼠标。App 和 Window 对象共享 1. 调用 `cua.getApp(selector)` 后,从 `app.getWindows()` 返回的列表选定目标,再调用 `app.getWindow(windowId)`。在窗口上调用 `getScreenshot()`、`click()`、`scroll()` 和 `drag()`,将原有的 `hover` 调用改为 `window.mouseMove()`。在 macOS 上,`window.click(id)` 在元素中心发送鼠标事件。需要执行默认辅助功能动作时,改用 `invoke(id)`。需要打开控件菜单且控件声明了 `AXShowMenu` 时,调用 `performAction(id, "AXShowMenu")`。 2. 将 `getAXState()` 改为 `getAccessibilityTree()`,将 `getAXStateAndScreenshot()` 拆成读取树和截图两次调用。选择窗口时会静默读取初始树。选择应用时,只有 `appLevelAccessibility` 为真才读取。要显示树,请显式读取。 3. 将数字元素索引改为从最新树中取得的字符串 ID。直接发送请求时使用 `elementId` 和 `snapshotId`。原先调用 `performSecondaryAction` 的地方,先用 `listActions(id)` 取得完整动作名,再调用 `performAction(id, exactName, argument?)`。要执行默认动作,可调用 `invoke(id)`。`setValue` 接受提供方支持的 JSON 值。 在 macOS 上,`performAction(id, 'AXRaise')` 只执行元素声明的抬起窗口动作。需要把应用切到前台时先调用 `app.activate()`。 -4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。macOS 支持完整按键,单独发送按下或松开事件会返回 `NOT_IMPLEMENTED`。如果脚本用 `NATIVE_ERROR` 识别 macOS 上的未知键名,请改为处理 `INVALID_ARGUMENT`,并在重试前修正键名。 +4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。在 macOS 上,同一次 `run_code` 中可调用此方法跨工具调用保持按键。执行返回、异常退出或用户暂停时会释放仍按着的键。如果脚本用 `NATIVE_ERROR` 识别 macOS 上的未知键名,请改为处理 `INVALID_ARGUMENT`,并在重试前修正键名。 5. 从动作结果读取 `dispatched`,再观察应用确认效果。元素没有默认动作时,`invoke` 返回 `dispatched: false`。移除读取旧字段 `verified` 的代码,以及对已选对象和窗口记录中 `pid` 的读取。需要 PID 时,使用 `listApps()` 或 `findApps()` 返回的运行中应用记录。 6. 自定义提供方需将 `CuaBackend.description` 改为 `capabilities`,并向 `installCua` 传入 `guidance` 和 `permissionParameters`。如配置了 `helperPath`,请按[操作协议](../../../../packages/experimental/cua/src/protocol.ts)和[带平台标记的权限响应](../../../../packages/experimental/cua/src/access-types.ts)重新构建 helper。重启提供方,检查它声明的方法和键盘输入范围,再打开 Computer Use 权限页确认平台控件。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 6a38002c4f..1abb4f6399 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: 24ea893846cf0914 - zh: dca284e8e589274d + en: f4206e2cb725c917 + zh: 37f0b04e9e74c193 /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 60d57c749d..042cc4fa94 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -79,7 +79,9 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-cua-macos) lists all fields. Native tool calls use `{method, params}`; the complete method-specific schema is exposed to the model. `getApp` returns the `appHandle` used by every app-targeted operation, and `app.getWindow(windowId)` returns the object that owns screenshots and pointer input. -`pressKey(key, modifiers?)` accepts one key or a nonempty array of distinct physical keys. Aliases such as `Enter` and `Return` identify the same key. `pressKey(['w', 'd'])` presses W then D and releases D then W. Pass held modifiers separately, as in `pressKey('Tab', ['shift'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. In a multi-key chord, use `=` and pass Shift explicitly to apply it to the whole chord. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. On macOS, use `pressKey` on the App object. The shared `sendKeyEvent` operation returns `NOT_IMPLEMENTED` on this provider. +`pressKey(key, modifiers?)` accepts one key or a nonempty array of distinct physical keys. Aliases such as `Enter` and `Return` identify the same key. `pressKey(['w', 'd'])` presses W then D and releases D then W. Pass held modifiers separately, as in `pressKey('Tab', ['shift'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. In a multi-key chord, use `=` and pass Shift explicitly to apply it to the whole chord. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. Use the App object's `pressKey` for a short press. + +For a long press, call `app.sendKeyEvent('w', 'down')` inside `run_code`. Each successful down remains active across subsequent tool calls in that execution. Choose when to send each up: after W down, D down and D up, W stays down. When the program returns, exits with an error, or the user pauses it, the helper releases the remaining keys at the processes that received their downs. A later execution cannot release an earlier execution's keys. Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot. @@ -221,7 +223,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index cadefe0dd6..0f6f4dad51 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -79,7 +79,9 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-cua-macos)列出全部字段。原生工具调用使用 `{method, params}`,模型可以看到每个方法的完整参数定义。`getApp` 返回每个面向应用的操作所需的 `appHandle`,`app.getWindow(windowId)` 返回负责截图和指针输入的对象。 -`pressKey(key, modifiers?)` 接受单个键名或由不同物理键组成的非空数组。`Enter` 和 `Return` 等别名指向同一个物理键。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。需要全程按住的修饰键单独传入,例如 `pressKey('Tab', ['shift'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。单独传入 `+` 或 `plus` 会发送 Shift 和等号。在多键组合中请使用 `=`,需要全程按住 Shift 时将其列入修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。在 macOS 上,通过 App 对象调用 `pressKey`。调用共享接口 `sendKeyEvent` 会收到 `NOT_IMPLEMENTED`。 +`pressKey(key, modifiers?)` 接受单个键名或由不同物理键组成的非空数组。`Enter` 和 `Return` 等别名指向同一个物理键。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。需要全程按住的修饰键单独传入,例如 `pressKey('Tab', ['shift'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。单独传入 `+` 或 `plus` 会发送 Shift 和等号。在多键组合中请使用 `=`,需要全程按住 Shift 时将其列入修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。短按使用 App 对象上的 `pressKey`。 + +长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。按下成功后,在同一次执行中调用其他工具时该键仍保持按下。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 向每个键原先的接收进程松开该次执行仍按着的键。后续执行不能释放前一次执行的键。 Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 @@ -221,7 +223,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 7382f3006b..2b78969668 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -18,11 +18,11 @@ en: 1435672513328c05 zh: 11e7a1e672410c10 /dsh-cua-native-helper/request-protocol: - en: 88a59904ca4ebedc - zh: 14f2b666d7382145 + en: 2a69d89b7fc95998 + zh: 084575bb31fb40dd /dsh-cua-native-helper/observation-and-input: - en: 51a70780a0672272 - zh: e7ca17dd40b887aa + en: 851614a21ebfeb65 + zh: ff868b14dc77adb8 /dsh-cua-native-helper/verification: en: ade5a2ccd3aeb1b4 zh: 74c2b0bcb23fc4bc diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index 2437c220a4..b3074d6ce9 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -78,7 +78,7 @@ Successful `activate` returns `dispatched` and `foregroundChanged`. A known chan | `getState` | `{appHandle, windowId, focusedWindowId, snapshotId, tree, elements, windows, truncated}`. `windowId` is null for an app-wide observation. Elements include the opaque string `elementId`, role, title, value, description, identifier, bounds, actions, and enabled state. Element bounds are null or [x, y, width, height] in screen points; window input uses local points. These structured element diagnostics are retained by native checks and omitted by the model tool projection. Unavailable optional descriptive properties are null, and an unavailable action list is empty; permission, communication and window-discovery failures still reject the observation. | | `listActions` | Provider-defined action names for one element as a string array; an element may advertise none. | | `show` | `{dispatched: boolean, windowId, foregroundChanged}` records restoration dispatch. `windowId` is null when no window was selected; `foregroundChanged` is null when the sampled observation was unavailable. | -| `invoke`, `performAction`, `mouseMove`, `click`, `setValue`, `selectText`, `typeText`, `paste`, `pressKey`, `scroll`, `drag` | `{dispatched: boolean}` records native dispatch; `invoke` reports false when no applicable default action exists. `mouseMove` also reports `hoverRoute` and `systemPointerMoved`. Read fresh state to verify the intended application change. | +| `invoke`, `performAction`, `mouseMove`, `click`, `setValue`, `selectText`, `typeText`, `paste`, `pressKey`, `sendKeyEvent`, `scroll`, `drag` | `{dispatched: boolean}` records native dispatch; `invoke` reports false when no applicable default action exists. `mouseMove` also reports `hoverRoute` and `systemPointerMoved`. Read fresh state to verify the intended application change. | | `screenshot` | `{mimeType: "image/png", data, pixelWidth, pixelHeight, bounds, coordinateSpace: "window-points", windowId}`. PNG pixel dimensions can differ from window point dimensions on Retina displays. | Every app-targeted operation requires `appHandle` from `getApp` in the same helper. The helper pins the PID and kernel process start time, and invalidates the handle and all of its snapshots after process exit or PID reuse. Calling `getApp` can acquire the replacement process without reactivating old handles. A random per-helper namespace prevents handles from a terminated helper matching those of its replacement. Repeated acquisition of the same live process shares its handle. A post-launch inspection failure invalidates the pending handle and preserves the original error. It does not terminate a process already started; launchApp cannot reuse the failed handle. @@ -105,6 +105,8 @@ Capture and pointer delivery read CG bounds before and after an exact-window AX `typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses its modifiers followed by the requested keys in order, then releases all pressed keys in reverse order, including on cancellation. Keyboard events go directly to the target PID and bypass the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. +The Host associates directional key events with one `run_code` execution. On down, the helper retains the selected application, recipient PID and process start time, physical key code, and matching up event. Explicit up and cleanup verify the original process identity. On return, error, or user pause, the Host waits for any in-flight native request and asks the helper to release only that execution’s remaining keys. Disconnect cleanup also releases keys owned by the closed connection. + The selected helper application needs macOS Accessibility permission for AX state and input, and Screen Recording permission for screenshots. Capability preflight and permission errors do not grant permissions. Capture refuses a window that moves or resizes while the image is being read. Unassociated system panels and accessory-only applications are not selectable. Host-only permission controls are `permissionStatus {}`, `requestPermission` and `openPermissionSettings {permission}`, where permission is `accessibility` or `screenRecording`. Requests carry localized `instruction`, `backLabel`, `finderLabel`, `unavailableMessage`, `settingsMessage`, and an integer `pollIntervalMs` of 100–2147483647. The native guide substitutes its application name for `{application}`. Status reports permission observations, process diagnostics, and `launchApplication` metadata. These operations remain absent from the model tool and `getCapabilities.methods`. The helper dynamically resolves `responsibility_get_pid_responsible_for_pid` with the signature used by [Chromium](https://chromium.googlesource.com/chromium/src/+/refs/tags/134.0.6998.28/base/process/process_info_mac.mm), capturing the responsible bundle before readiness and falling back to its own bundle when unavailable. Parent paths do not determine authorization ownership. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 1224e0e6db..6035ffaee2 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -78,7 +78,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 | `getState` | `{appHandle, windowId, focusedWindowId, snapshotId, tree, elements, windows, truncated}`。应用级观察的 `windowId` 为 null。元素包含不透明字符串 `elementId`、role、title、value、description、identifier、bounds、actions 和 enabled 状态。元素边界为 null 或屏幕 point 单位的 [x, y, width, height],窗口输入使用局部 point。这些结构化元素诊断供原生检查保留,模型工具投影会移除它们。不可用的可选描述属性为 null,无法取得的动作列表为空;权限、通信和窗口发现失败仍会拒绝观察。 | | `listActions` | 一个元素由 provider 定义的动作名数组;元素可以不声明任何动作。 | | `show` | `{dispatched: boolean, windowId, foregroundChanged}` 记录恢复分派。未选中窗口时 `windowId` 为 null;无法取得采样观察时 `foregroundChanged` 为 null。 | -| `invoke`, `performAction`, `mouseMove`, `click`, `setValue`, `selectText`, `typeText`, `paste`, `pressKey`, `scroll`, `drag` | `{dispatched: boolean}` 记录原生分派;`invoke` 在没有可用的默认动作时返回 false。`mouseMove` 还报告 `hoverRoute` 和 `systemPointerMoved`。需要读取新状态以核实预期的应用变化。 | +| `invoke`, `performAction`, `mouseMove`, `click`, `setValue`, `selectText`, `typeText`, `paste`, `pressKey`, `sendKeyEvent`, `scroll`, `drag` | `{dispatched: boolean}` 记录原生分派;`invoke` 在没有可用的默认动作时返回 false。`mouseMove` 还报告 `hoverRoute` 和 `systemPointerMoved`。需要读取新状态以核实预期的应用变化。 | | `screenshot` | `{mimeType: "image/png", data, pixelWidth, pixelHeight, bounds, coordinateSpace: "window-points", windowId}`。Retina 显示器上 PNG 像素尺寸可能不同于窗口 point 尺寸。 | 每个面向应用的操作都要求使用同一 helper 的 `getApp` 返回的 `appHandle`。helper 固定 PID 和内核进程启动时间,进程退出或 PID 被复用后使句柄及其全部快照失效。再次调用 `getApp` 可取得替代进程,不会重新激活旧句柄。随机的 helper 命名空间防止已终止 helper 的句柄与替代实例重合。重复获取同一存活进程会共享句柄。启动后检查失败会使待启动句柄失效,并保留原错误;已经启动的进程不会被终止,launchApp 也不能复用失败的句柄。 @@ -105,6 +105,8 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和指定按键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。键盘事件直接发给目标进程,绕过全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 +宿主为每次 `run_code` 的 `sendKeyEvent` 附上执行标识。按下时,helper 保存已选应用、接收进程的 PID 与启动时间、物理键码及对应的松开事件。显式松键或执行结束时会核对原进程身份。正常返回、异常退出或用户暂停时,宿主等待在途原生请求结束,再通知 helper 释放该执行持有的其余按键。连接断开时,helper 也会清理该连接持有的按键。 + 所选 helper 应用需要 macOS 辅助功能权限以读取 AX 和输入,需要屏幕录制权限以截图。能力预检和权限错误不授予权限。截图会拒绝在读取图片期间移动或缩放的窗口。无法确认归属的系统面板及仅后台辅助类型的应用不可选择。 仅供 Host 使用的权限操作为 `permissionStatus {}`、`requestPermission` 和 `openPermissionSettings {permission}`,其中 permission 为 `accessibility` 或 `screenRecording`。请求携带本地化的 `instruction`、`backLabel`、`finderLabel`、`unavailableMessage`、`settingsMessage`,以及 100–2147483647 的整数 `pollIntervalMs`。原生引导将 `{application}` 替换为自身应用名。状态返回权限观测、进程诊断和 `launchApplication` 信息。这些操作不出现在模型工具及 `getCapabilities.methods` 中。helper 动态解析 `responsibility_get_pid_responsible_for_pid`,函数签名与 [Chromium](https://chromium.googlesource.com/chromium/src/+/refs/tags/134.0.6998.28/base/process/process_info_mac.mm) 一致,在报告就绪前记录责任 bundle,无法获取时使用自身 bundle。父进程路径不决定授权归属。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index da1afe5a3d..80d820d0f1 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -81,13 +81,15 @@ actor AXWorker { private let systemPointerHover: Bool? private let sessionState: @Sendable () -> Bool? private var access: AXAccess? + private let heldKeys: HeldKeys private var keyboard: KeyboardInput? private var pointer: WindowPointerInput? private var hovers: [String: AXHover] = [:] private var closed = false - init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, systemPointerHover: Bool? = nil, + init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, systemPointerHover: Bool? = nil, heldKeys: HeldKeys = HeldKeys(), sessionState: @escaping @Sendable () -> Bool? = NativePlatform.sessionLocked) { + self.heldKeys = heldKeys self.cancellation = cancellation self.axTimeout = axTimeout self.textEventInterval = textEventInterval @@ -118,7 +120,7 @@ actor AXWorker { if let keyboard, let pointer { return (keyboard, pointer) } let poster = PIDEventPoster() let latch = cancellation - let key = KeyboardInput(post: poster.post, textEventInterval: textEventInterval, checkCancellation: { try latch.check() }) + let key = KeyboardInput(post: poster.post, textEventInterval: textEventInterval, held: heldKeys, checkCancellation: { try latch.check() }) let mouse = WindowPointerInput(poster: poster, checkCancellation: { try latch.check() }) keyboard = key; pointer = mouse return (key, mouse) @@ -208,8 +210,13 @@ actor AXWorker { trace: ScreenshotTrace? = nil) async throws -> DriverOutcome { try cancellation.check() guard !closed else { throw NativeFailure(code: "NATIVE_CLOSED", message: "Helper is closing") } - if method == "sendKeyEvent" { - throw NativeFailure(code: "NOT_IMPLEMENTED", message: "sendKeyEvent is not implemented on current platform.") + if method == "releaseHeldKeys" { + try keyboard?.releaseHeldKeys(run: params.string("runId")) + return .result(Self.actionResult) + } + if method == "sendKeyEvent", try params.string("direction") == "up" { + let dispatched = try inputs().0.keyUp(params.string("key"), app: params.string("appHandle"), run: params.string("runId")) + return .result(.object(["dispatched": .bool(dispatched)])) } let locked = observeSession() if method == "getCapabilities" { @@ -331,6 +338,9 @@ actor AXWorker { maxBytes: try params.integer("maxClipboardBytes", in: 1024...Int.max), readTimeout: .milliseconds(try params.integer("pasteReadTimeoutMs", in: 1...2147483647)), maxElements: try params.integer("maxElements", in: 1...10000), maxDepth: try params.integer("maxDepth", in: 1...100))) + case "sendKeyEvent": + let recipient = try ax().keyboardRecipient(identity, catalog: catalog) + try inputs().0.keyDown(params.string("key"), app: handle, run: params.string("runId"), recipient: recipient) case "typeText", "pressKey": let recipient = try ax().keyboardRecipient(identity, catalog: catalog) try NativePlatform.verify(recipient) @@ -496,5 +506,10 @@ actor AXWorker { static func mouseMoveResult(route: String, pointerMoved: Bool) -> JSONValue { .object(["dispatched": .bool(true), "hoverRoute": .string(route), "systemPointerMoved": .bool(pointerMoved)]) } - func shutdown() { closed = true; snapshots.removeAll(); observedWindowBounds.removeAll(); targets.removeAll(); hovers.removeAll(); access = nil; keyboard = nil; pointer = nil } + func shutdown() { + do { try keyboard?.releaseHeldKeys() } + catch { fputs("Could not release held keys on disconnect: \(error)\n", stderr) } + closed = true; snapshots.removeAll(); observedWindowBounds.removeAll(); targets.removeAll(); hovers.removeAll() + access = nil; keyboard = nil; pointer = nil + } } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift new file mode 100644 index 0000000000..47c34fbe2a --- /dev/null +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift @@ -0,0 +1,84 @@ +// All connections to the shared helper deliver to the same desktop processes. +import CoreGraphics +import Foundation + +/// The lock covers both the held-key records and their corresponding PID-targeted event posts. +/// Shutdown may release a connection's keys outside the ordinary request scheduler. +final class HeldKeys: @unchecked Sendable { + struct Owner: Equatable { + let connection: UUID + let run: String + let app: String + } + + private struct Press { + let owner: Owner + let recipient: ProcessIdentity + let code: CGKeyCode + let flag: CGEventFlags + let up: CGEvent + } + + private let lock = NSLock() + private var pressed: [Press] = [] + + func snapshot(pid: Int32) -> [CGKeyCode: CGEventFlags] { + lock.withLock { Dictionary(uniqueKeysWithValues: pressed.filter { $0.recipient.pid == pid }.map { ($0.code, $0.flag) }) } + } + + func down(owner: Owner, recipient: ProcessIdentity, code: CGKeyCode, flag: CGEventFlags, + down: CGEvent, up: CGEvent, post: (CGEvent, Int32) -> Void) throws { + try lock.withLock { + guard !pressed.contains(where: { $0.code == code && ($0.owner == owner || $0.recipient.pid == recipient.pid) }) else { + throw RequestParameters.invalid("Key is already held for this target") + } + pressed.append(Press(owner: owner, recipient: recipient, code: code, flag: flag, up: up)) + down.flags = flags(for: recipient) + post(down, recipient.pid) + } + } + + private func flags(for recipient: ProcessIdentity) -> CGEventFlags { + pressed.reduce(into: CGEventFlags()) { if $1.recipient == recipient { $0.formUnion($1.flag) } } + } + + /// An up always targets the process selected at down. Never post to a recycled PID. + private func release(at index: Int, post: (CGEvent, Int32) -> Void) throws -> Bool { + let key = pressed[index] + do { try NativePlatform.verify(key.recipient) } + catch let error as NativeFailure where error.code == "STALE_APP" || error.code == "APP_UNAVAILABLE" { + pressed.remove(at: index) + return false + } + pressed.remove(at: index) + key.up.flags = flags(for: key.recipient) + post(key.up, key.recipient.pid) + return true + } + + func up(owner: Owner, code: CGKeyCode, post: (CGEvent, Int32) -> Void) throws -> Bool { + try lock.withLock { + guard let index = pressed.firstIndex(where: { $0.owner == owner && $0.code == code }) else { + throw RequestParameters.invalid("This run has not pressed that key for this application") + } + return try release(at: index, post: post) + } + } + + func release(connection: UUID, run: String?, post: (CGEvent, Int32) -> Void) throws { + try lock.withLock { + defer { + // A closed connection cannot retry an unverifiable recipient. + if run == nil { pressed.removeAll { $0.owner.connection == connection } } + } + var firstError: Error? + for index in pressed.indices.reversed() { + let owner = pressed[index].owner + guard owner.connection == connection, run == nil || owner.run == run else { continue } + do { _ = try release(at: index, post: post) } + catch { firstError = firstError ?? error } + } + if let firstError { throw firstError } + } + } +} diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift index 3511256457..91572fbd5f 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift @@ -9,16 +9,39 @@ final class KeyboardInput { let flag: CGEventFlags } + private let connection = UUID() + private let held: HeldKeys private let post: (CGEvent, Int32) -> Void private let textEventInterval: Double private let checkCancellation: @Sendable () throws -> Void - init(post: @escaping (CGEvent, Int32) -> Void, textEventInterval: Double, checkCancellation: @escaping @Sendable () throws -> Void = {}) { + init(post: @escaping (CGEvent, Int32) -> Void, textEventInterval: Double, held: HeldKeys = HeldKeys(), checkCancellation: @escaping @Sendable () throws -> Void = {}) { + self.held = held self.post = post self.textEventInterval = textEventInterval self.checkCancellation = checkCancellation } + func keyDown(_ name: String, app: String, run: String, recipient: ProcessIdentity) throws { + let modifier = Self.namedModifier(name) + let code = try Self.keyCode(name) + let source = try Self.source() + let down = try Self.keyEvent(source: source, code: code, down: true, flags: []) + let up = try Self.keyEvent(source: source, code: code, down: false, flags: []) + try checkCancellation() + try NativePlatform.verify(recipient) + try held.down(owner: .init(connection: connection, run: run, app: app), recipient: recipient, + code: code, flag: modifier?.flag ?? [], down: down, up: up, post: post) + } + + func keyUp(_ name: String, app: String, run: String) throws -> Bool { + try held.up(owner: .init(connection: connection, run: run, app: app), code: Self.keyCode(name), post: post) + } + + func releaseHeldKeys(run: String? = nil) throws { + try held.release(connection: connection, run: run, post: post) + } + /// Prepare every event before dispatch. On cancellation, release only the keys this call pressed. func pressKey(_ keys: [String], modifiers: [String], to pid: Int32) throws { try checkCancellation() @@ -42,8 +65,12 @@ final class KeyboardInput { } chord.append(key) } - var selected = Set() - var flags: CGEventFlags = [] + let alreadyHeld = held.snapshot(pid: pid) + guard namesByCode.keys.allSatisfy({ alreadyHeld[$0] == nil }) else { + throw RequestParameters.invalid("Release the held key before using it in pressKey") + } + var selected = Set(alreadyHeld.keys) + var flags = alreadyHeld.values.reduce(CGEventFlags()) { $0.union($1) } var events: [(down: CGEvent, up: CGEvent)] = [] let source = try Self.source() for key in chord { @@ -77,6 +104,8 @@ final class KeyboardInput { func typeText(_ text: String, to pid: Int32) throws { try checkCancellation() let source = try Self.source() + let alreadyHeld = held.snapshot(pid: pid) + let flags = alreadyHeld.values.reduce(CGEventFlags()) { $0.union($1) } for scalar in text.replacingOccurrences(of: "\r\n", with: "\n").unicodeScalars { try checkCancellation() let code: CGKeyCode @@ -85,8 +114,11 @@ final class KeyboardInput { case 9: code = try Self.keyCode("Tab") default: code = 0 } - let down = try Self.keyEvent(source: source, code: code, down: true, flags: []) - let up = try Self.keyEvent(source: source, code: code, down: false, flags: []) + guard alreadyHeld[code] == nil else { + throw RequestParameters.invalid("Release the held physical key before typing text with its key code") + } + let down = try Self.keyEvent(source: source, code: code, down: true, flags: flags) + let up = try Self.keyEvent(source: source, code: code, down: false, flags: flags) if code == 0 { let units = Array(String(scalar).utf16) units.withUnsafeBufferPointer { buffer in diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift index 0da4e5cb96..57f20c732f 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift @@ -20,11 +20,12 @@ enum NativeProtocol { "performAction": ["action", "appHandle", "argument", "elementId", "snapshotId", "windowId"], "permissionStatus": [], "pressKey": ["appHandle", "key", "modifiers", "windowId"], + "releaseHeldKeys": ["runId"], "requestPermission": ["backLabel", "finderLabel", "instruction", "permission", "pollIntervalMs", "settingsMessage", "unavailableMessage"], "screenshot": ["appHandle", "windowId"], "scroll": ["appHandle", "deltaLinesX", "deltaLinesY", "snapshotId", "windowId", "x", "y"], "selectText": ["appHandle", "elementId", "prefix", "selectionType", "snapshotId", "suffix", "text", "windowId"], - "sendKeyEvent": ["appHandle", "direction", "key", "windowId"], + "sendKeyEvent": ["appHandle", "direction", "key", "runId", "windowId"], "setValue": ["appHandle", "elementId", "snapshotId", "value", "windowId"], "show": ["appHandle", "windowId"], "typeText": ["appHandle", "text", "windowId"], diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift index 0041e88b2a..6b69122d99 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift @@ -7,7 +7,7 @@ struct RequestParameters: Sendable { init(_ request: NativeRequest) throws { guard let values = request.params.object else { throw Self.invalid("params must be an object") } guard let allowed = Self.fields[request.method] else { throw NativeFailure(code: "UNSUPPORTED", message: "Unsupported native operation: \(request.method)") } - if ["typeText", "pressKey", "paste"].contains(request.method), values["windowId"] != nil { + if ["typeText", "pressKey", "sendKeyEvent", "paste"].contains(request.method), values["windowId"] != nil { throw Self.invalid("macOS keyboard input follows application focus; use the App object") } guard Set(values.keys).isSubset(of: allowed) else { throw Self.invalid("Unknown operation parameter") } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift index 000a69e848..737d560720 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift @@ -43,7 +43,7 @@ import Darwin self.onFatal = onFatal worker = AXWorker(cancellation: cancellation, axTimeout: configuration.axTimeoutSeconds, textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs, - systemPointerHover: configuration.systemPointerHover) + systemPointerHover: configuration.systemPointerHover, heldKeys: scheduler.heldKeys) input = JSONLInput(limit: configuration.maxRequestBytes, cancellation: cancellation, channel: channel) self.writer = writer } @@ -166,7 +166,7 @@ import Darwin let params = try RequestParameters(request) try cancellation.check() if request.method == "screenshot" { try capture.requireAvailable() } - let requiresCatalog = ["getApp", "listApps", "findApps", "typeText", "pressKey", "click"].contains(request.method) + let requiresCatalog = ["getApp", "listApps", "findApps", "typeText", "pressKey", "sendKeyEvent", "click"].contains(request.method) let catalog = requiresCatalog ? ApplicationCatalog.snapshot() : ApplicationCatalogSnapshot(applications: [], frontPID: nil) let result: JSONValue if request.method == "permissionStatus" { diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestScheduler.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestScheduler.swift index 54671b12d4..83807c8468 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestScheduler.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestScheduler.swift @@ -7,6 +7,7 @@ import Dispatch let id: UUID let continuation: CheckedContinuation } + let heldKeys = HeldKeys() private var owner: UUID? private var waiting: [Waiting] = [] diff --git a/packages/experimental/cua-macos/native/generate-protocol.ts b/packages/experimental/cua-macos/native/generate-protocol.ts index 398999cc2c..29ccc58344 100644 --- a/packages/experimental/cua-macos/native/generate-protocol.ts +++ b/packages/experimental/cua-macos/native/generate-protocol.ts @@ -10,6 +10,7 @@ import { PermissionGuideParameters, PermissionSettingsParameters } from '../src/ */ export function renderNativeProtocol(): { json: string; swift: string } { const { fields, enums, json } = nativeProtocol({ + sendKeyEvent: z.object({ runId: z.string().min(1) }), mouseMove: z.object({ allowSystemPointer: z.boolean().optional() }), requestPermission: PermissionGuideParameters, openPermissionSettings: PermissionSettingsParameters, }) diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index ce960cea7e..f8a9824752 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -1062,12 +1062,17 @@ "down", "up" ] + }, + "runId": { + "type": "string", + "minLength": 1 } }, "required": [ "appHandle", "key", - "direction" + "direction", + "runId" ], "additionalProperties": false } @@ -1304,6 +1309,37 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string", + "const": "releaseHeldKeys" + }, + "params": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "runId" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "method", + "params" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index f9c280a193..432f70e81f 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -193,6 +193,22 @@ def main(): state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) chord = [(event["type"], event.get("keyCode")) for event in state["events"][count:]] assert chord == [(10, 13), (10, 2), (11, 2), (11, 13)], chord + count = len(state["events"]) + def key(name, direction): + call("sendKeyEvent", {**owner, "runId": "held-input", "key": name, "direction": direction}) + key("w", "down") + key("d", "down") + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 2) + call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) + time.sleep(2) + key("d", "up") + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 3) + held = state["events"][count:] + assert [(event["type"], event["keyCode"]) for event in held] == [(10, 13), (10, 2), (11, 2)], held + assert held[2]["timestamp"] - held[1]["timestamp"] >= 2, held + call("releaseHeldKeys", {"runId": "held-input"}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) + assert [(e["type"], e["keyCode"]) for e in state["events"][count:]] == [(10, 13), (10, 2), (11, 2), (11, 13)] call("drag", {**window, "fromX": point["x"], "fromY": point["y"], "toX": point["x"] + 80, "toY": point["y"] + 20, "durationMs": 200, "steps": 10, "mouseButton": "middle", "modifiers": ["shift"]}) state = wait_for_state(state_file, lambda value: any(event["type"] == 27 and event.get("button") == 2 and event["shift"] for event in value["events"])) diff --git a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift index 6ca44cd59a..52cae6911e 100644 --- a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift @@ -1,5 +1,6 @@ // Observe the actual CoreGraphics events without sending keyboard input to the desktop. import CoreGraphics +import Darwin import Testing @testable import CuaNative @@ -68,3 +69,24 @@ func plusAlonePressesShiftEquals(_ alias: String) throws { #expect(events.map(\.type) == [.flagsChanged, .flagsChanged]) #expect(events.map { $0.flags.contains(.maskShift) } == [true, false]) } + +@Test func directionalReleaseUsesOriginalRecipientAndPreservesOtherRuns() throws { + let target = try NativePlatform.identity(getpid()) + let held = HeldKeys() + var events: [CGEvent] = [] + let input = KeyboardInput(post: { event, pid in + #expect(pid == target.pid) + events.append(event) + }, textEventInterval: 0, held: held) + try input.keyDown("shift", app: "editor", run: "one", recipient: target) + try input.keyDown("w", app: "editor", run: "two", recipient: target) + #expect(throws: NativeFailure.self) { try input.keyDown("W", app: "editor", run: "one", recipient: target) } + #expect(throws: NativeFailure.self) { try input.keyUp("w", app: "other", run: "two") } + #expect(throws: NativeFailure.self) { try input.pressKey(["w"], modifiers: [], to: target.pid) } + try input.pressKey(["d"], modifiers: ["shift"], to: target.pid) + try input.releaseHeldKeys(run: "one") + #expect(try input.keyUp("w", app: "editor", run: "two")) + try input.releaseHeldKeys(run: "two") + #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [56, 13, 2, 2, 56, 13]) + #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, true, false, false]) +} diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index c7726f385b..e750c4068b 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -13,7 +13,8 @@ const methodDetails = new Map([ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { - if (method === 'pressKey') return "- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed." + if (method === 'pressKey') return `- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/packages/experimental/cua-macos/src/held-keys.ts b/packages/experimental/cua-macos/src/held-keys.ts new file mode 100644 index 0000000000..30c1311da8 --- /dev/null +++ b/packages/experimental/cua-macos/src/held-keys.ts @@ -0,0 +1,42 @@ +/** Directional input belongs to the enclosing run_code, including while its worker calls other tools. */ +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { CuaBackend } from '@deepseek-ai/dsh-cua' +import { NativeError, type NativeClient } from '@deepseek-ai/dsh-cua/native-client' +import { RUN_CODE_NAME, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' + +/** + * Keep native key ownership alive until its program returns, throws, or is interrupted. + * @param ctx - Provider scope owning the tool execution listener. + * @returns Handler for authorized directional keyboard requests. + */ +export function installHeldKeys(ctx: Context): NonNullable { + const runs = new Map() + ctx.on('tools/execute', async (execution, next) => { + if (execution.name !== RUN_CODE_NAME) return next() + const run: { id: string; client?: NativeClient } = { id: randomUUID() } + runs.set(execution.token, run) + let cleanup: Promise | undefined + const release = () => { + runs.delete(execution.token) + if (run.client) cleanup ??= run.client.requestCleanup('releaseHeldKeys', { runId: run.id }) + return cleanup + } + // Pause may arrive while an unrelated subtool is still settling. + const onAbort = () => { void release()?.catch(() => undefined) } + execution.signal.addEventListener('abort', onAbort, { once: true }) + try { + return await next() + } finally { + execution.signal.removeEventListener('abort', onAbort) + await release() + } + }) + return (client, params, execution) => { + const run = execution.parent && runs.get(execution.parent) + if (!run) throw new NativeError('INVALID_ARGUMENT', 'Use sendKeyEvent inside an active run_code execution') + if (run.client && run.client !== client) throw new NativeError('NATIVE_CLOSED', 'The keyboard connection changed; start a new run_code') + run.client = client + return client.request('sendKeyEvent', { ...params, runId: run.id }, execution.signal, execution.callId) + } +} diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 4b07e9c8b7..8c089c9f8e 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -9,6 +9,7 @@ import { CuaConfigFields } from '@deepseek-ai/dsh-cua/config' import { createPermissionParameters } from './permission-guide.ts' import { nativeHostArchitecture, prepareNativeExecutable, type SourceSigning } from './native-build.ts' import { createSharedNativeChannel } from './shared-native.ts' +import { installHeldKeys } from './held-keys.ts' import { GUIDANCE } from './guidance.ts' export { GUIDANCE } from './guidance.ts' @@ -17,7 +18,7 @@ const CAPABILITIES: CuaStartupCapabilities = { methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', - 'selectText', 'setValue', 'show', 'typeText', + 'selectText', 'sendKeyEvent', 'setValue', 'show', 'typeText', ], appLevelAccessibility: true, keyboard: ['app'], @@ -65,7 +66,7 @@ export const Config = Schema.object({ */ export async function apply(ctx: Context, config: Config): Promise { const createChannel: NativeChannelFactory = (owner, native) => createSharedNativeChannel(owner, native, config.systemPointerHover) - const backend = { createChannel, guidance: GUIDANCE, capabilities: CAPABILITIES, + const backend = { createChannel, sendKeyEvent: installHeldKeys(ctx), guidance: GUIDANCE, capabilities: CAPABILITIES, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index 16fb39122a..54322e2316 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -28,6 +28,9 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(PLATFORM_GUIDANCE).toContain('Simulated keyboard input is available at app level') expect(PLATFORM_GUIDANCE).toContain('Simulated mouse input is available at window level') const pressKey = GUIDANCE.split('\n').find(line => line.startsWith('- pressKey(')) + expect(GUIDANCE).toContain("sendKeyEvent(key: string, direction: 'down' | 'up')") + expect(GUIDANCE).toContain('returns, throws, or the user pauses') + expect(COMMON_GUIDANCE).not.toContain('sendKeyEvent(') expect(pressKey).toContain('key: string | string[]') expect(pressKey).toContain("pressKey(['w', 'd']) presses W then D and releases D then W") expect(pressKey).toContain("Packed strings such as 'w+d'") @@ -139,5 +142,5 @@ it('documents facade initialization, emitted observations, and separate key argu }) it('omits stale names and unavailable platform operations', () => { - for (const stale of ['getAXState', 'disableDiffing', 'performSecondaryAction', 'hover(', 'keyChord', 'sendKeyEvent', 'window.typeText', 'window.pressKey', 'elementIndex', 'verified: false', 'browser tabs', 'not implemented']) expect(GUIDANCE).not.toContain(stale) + for (const stale of ['getAXState', 'disableDiffing', 'performSecondaryAction', 'hover(', 'keyChord', 'window.typeText', 'window.pressKey', 'elementIndex', 'verified: false', 'browser tabs', 'not implemented']) expect(GUIDANCE).not.toContain(stale) }) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 48b9eb99e3..194f7244fd 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -1,5 +1,5 @@ /** Native helper replacement is synchronized with Agent and provider disposal. */ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { executeModelToolFixture, executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -28,6 +28,7 @@ import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import { LlmAdapter, ToolCallId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import Subprocess from '@deepseek-ai/dsh-subprocess' +import SubprocessLocal from '@deepseek-ai/dsh-subprocess-local' import { expect, it, onTestFinished, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -38,6 +39,7 @@ import type { NativeBuildSpec } from '../src/native-build.ts' const native = vi.hoisted(() => ({ prepare: vi.fn<(spec: NativeBuildSpec) => Promise>(), architecture: vi.fn(() => 'arm64' as const), + cleanup: vi.fn(), request: vi.fn(), dispose: vi.fn(), instances: [] as Array<{ disposed: boolean }>, reportDiagnostic: undefined as ((message: string) => void) | undefined, })) @@ -54,6 +56,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ native.reportDiagnostic = args[3] native.instances.push(this) } + requestCleanup = native.cleanup request = native.request invalidate() { this.disposed = true } async dispose() { this.disposed = true; await native.dispose() } @@ -110,19 +113,25 @@ class ImageAdapter extends LlmAdapter { const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' const imageMetadata = { pixelWidth: 1, pixelHeight: 1, bounds: { x: 2, y: 3, width: 1, height: 1 }, coordinateSpace: 'window-points', windowId: 7 } -async function setup(model?: { provider: string; model: string }) { +async function setup(model?: { provider: string; model: string }, ptc = false) { + native.cleanup.mockReset().mockResolvedValue({ dispatched: true }) native.request.mockReset().mockResolvedValue([]) native.dispose.mockReset() native.instances.length = 0 native.reportDiagnostic = undefined const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) - await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestDependencies(ctx, ptc ? { workingDirectory: true, tools: { mode: 'ptc' } } : {}) await ctx.plugin(ComputerUseRegistry) - await ctx.plugin(Subprocess, []) + if (ptc) { + await ctx.plugin(SubprocessLocal) + await ctx.plugin(Sandbox) + await ctx.plugin(SandboxPolicy, { mode: 'danger-full-access' }) + await ctx.plugin(NodeRuntime) + } else await ctx.plugin(Subprocess, []) const provider = await ctx.plugin(NativeProvider, config) await mountAgentLoopTestHarness(ctx) - const handle = await ctx.agents.create({ sessionId: SessionId('native-lifecycle'), ...model ? { agentOptions: model } : {} }) + const handle = await ctx.agents.create({ sessionId: SessionId('native-lifecycle'), meta: { cwd: process.cwd() }, ...model ? { agentOptions: model } : {} }) let index = 0 const execute = (method = 'listApps', params: Record = {}, signal = new AbortController().signal) => executeToolFixture(ctx, { agent: handle.agent, signal, callId: ToolCallId(`lifecycle-${++index}`), name: 'cua', arguments: { method, params }, @@ -138,6 +147,69 @@ async function selectApplication(f: Awaited>) { expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) } +it('requires an enclosing run_code before sending a directional key', async () => { + const f = await setup() + await selectApplication(f) + native.request.mockClear() + const result = await f.execute('sendKeyEvent', { appHandle: 'app-one', key: 'w', direction: 'down' }) + expect(result.isError).toBe(true) + expect(JSON.stringify(result.content)).toContain('active run_code') + expect(native.request).not.toHaveBeenCalled() +}) + +it.each(['return', 'throw', 'pause'])('cleans up only the held W when run_code ends by %s', async (ending) => { + const f = await setup(undefined, true) + await selectApplication(f) + const held = new Set() + const events: string[] = [] + const controller = new AbortController() + const runIds = new Set() + native.request.mockImplementation(async (method, params) => { + expect(method).toBe('sendKeyEvent') + runIds.add(params.runId) + const key = String(params.key) + if (params.direction === 'down') held.add(key) + else held.delete(key) + events.push(`${key}:${params.direction}`) + return { dispatched: true } + }) + native.cleanup.mockImplementation(async (method, params) => { + expect(method).toBe('releaseHeldKeys') + expect(runIds).toEqual(new Set([params.runId])) + for (const key of held) events.push(`${key}:up`) + held.clear() + return { dispatched: true } + }) + f.ctx.tools.register({ + name: 'check_held_keys', description: 'Observe the keys held by the test receiver.', + parameters: { type: 'object', properties: {} }, + output: { schema: {}, render: () => [] }, + async execute() { + const keys = [...held] + if (ending === 'pause' && keys.length === 1) { + controller.abort() + await setImmediate() + expect(held.size).toBe(0) + } + return keys + }, + }) + const code = [ + "const key = (key, direction) => tools.cua({ method: 'sendKeyEvent', params: { appHandle: 'app-one', key, direction } });", + "await key('w', 'down'); await key('d', 'down');", + "if ((await tools.check_held_keys({})).join(',') !== 'w,d') throw Error('lost held keys');", + "await key('d', 'up');", + "if ((await tools.check_held_keys({})).join(',') !== 'w') throw Error('lost W');", + ending === 'throw' ? "throw Error('program failed');" : "return 'done';", + ].join('\n') + const result = await executeModelToolFixture(f.ctx, { agent: f.handle.agent, callId: ToolCallId('keyboard-run'), + name: 'run_code', arguments: { code, description: 'Exercise held keys' }, signal: controller.signal }) + expect(events, JSON.stringify(result.content)).toEqual(['w:down', 'd:down', 'd:up', 'w:up']) + expect(native.cleanup).toHaveBeenCalledTimes(1) + expect(result.isError).toBe(ending !== 'return') + if (ending === 'throw') expect(JSON.stringify(result.content)).toContain('program failed') +}) + it('rejects a relative helper path before publishing computer-use operations', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -200,7 +272,7 @@ it('merges startup capabilities into runtime native fields', async () => { coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', - 'selectText', 'setValue', 'show', 'typeText', + 'selectText', 'sendKeyEvent', 'setValue', 'show', 'typeText', ] }) expect(native.request).toHaveBeenCalledExactlyOnceWith('getCapabilities', {}, expect.any(AbortSignal), ToolCallId('lifecycle-1')) }) diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index d6c75f46bc..e06b6989ea 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -126,6 +126,9 @@ export interface CuaBackend { createChannel: NativeChannelFactory guidance: string capabilities: CuaStartupCapabilities + /** Dispatch a directional key through the provider's execution-scoped input owner, after application authorization. */ + sendKeyEvent?: (client: NativeClient, params: Extract['params'], + execution: Readonly) => Promise /** * Build native permission parameters for the selected action and Client language. * @param method Native permission action. @@ -211,6 +214,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const signal = execution.signal signal.throwIfAborted() const client = execution.nativeClient + if (method === 'sendKeyEvent' && backend.sendKeyEvent) return backend.sendKeyEvent(client, parsedParams, execution) const nativeParams = method === 'findApps' ? { ...params, maxAppMatches: config.maxAppMatches } : method === 'paste' diff --git a/packages/experimental/cua/src/native-client.ts b/packages/experimental/cua/src/native-client.ts index 25c93e15ce..d2e70813ec 100644 --- a/packages/experimental/cua/src/native-client.ts +++ b/packages/experimental/cua/src/native-client.ts @@ -81,7 +81,12 @@ export class NativeClient { private diagnostics: string[] = [] private diagnosticBytes = 0 private discardedDiagnostics = false - private active: { id: string; resolve: (value: JsonValue) => void; reject: (error: NativeError) => void } | undefined + private active: { + id: string + outcome: Promise + resolve: (value: JsonValue) => void + reject: (error: NativeError) => void + } | undefined /** * @param owner - Managed subprocess provider. @@ -133,7 +138,10 @@ export class NativeClient { throw new NativeError('NATIVE_REQUEST_LIMIT', 'Request exceeded the configured byte limit before dispatch') } const outcome = Promise.withResolvers() - this.active = { id, resolve: outcome.resolve, reject: (error) => { nativeFailure = error; outcome.reject(error) } } + this.active = { + id, outcome: outcome.promise, resolve: outcome.resolve, + reject: (error) => { nativeFailure = error; outcome.reject(error) }, + } if (capture) this.diagnostic(screenshotPrefix + JSON.stringify({ requestId: id, stage: 'host.dispatch', elapsedMs: 0 })) const interrupt = (error: NativeError): void => { if (interruption || this.active?.id !== id) return @@ -193,6 +201,17 @@ export class NativeClient { } } + /** + * Dispatch cleanup after any active request acknowledges completion or cancellation. + * @param method - Host-only helper cleanup operation. + * @param params - Resources owned by the enclosing execution. + * @returns The native cleanup acknowledgement, bounded by the connection's request deadline. + */ + async requestCleanup(method: string, params: Record): Promise { + while (this.active) await this.active.outcome.catch(() => undefined) + return this.request(method, params, new AbortController().signal) + } + private screenshotDiagnostics(): string { const records = [...this.diagnostics] if (this.discardedDiagnostics) records.unshift('[Earlier screenshot diagnostic records were discarded from the bounded diagnostic buffer.]') diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 42b545e66a..e4de9f4734 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -93,7 +93,7 @@ export const NativeResourceParameters = { } /** Host-only launch continuation and user-facing system permission controls; absent from model arguments. */ export const NativeOnlyOperations = [ - operation('launchApp', app), operation('permissionStatus', {}), + operation('launchApp', app), operation('releaseHeldKeys', { runId: z.string().min(1) }), operation('permissionStatus', {}), operation('applicationIcons', { paths: z.array(z.string().min(1)) }), operation('requestPermission', { permission: z.enum(['accessibility', 'screenRecording']) }), operation('openPermissionSettings', { permission: z.enum(['accessibility', 'screenRecording']) }), diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index da1c1eaf56..ca34f16e2c 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -169,7 +169,7 @@ export interface CuaSurface { pressKey(key: string | string[], modifiers?: string[]): Promise /** * Send one key-down or key-up event at the current input focus. - * Does not send the opposite event. + * On macOS, down persists within one run_code until explicit up, program completion (including errors), or user pause. * Rejects when the provider does not support keyboard input at this scope. * @param key - Platform-defined key name; modifier keys may themselves be targets. * @param direction - Explicit event direction; the opposite event is never sent implicitly. diff --git a/packages/experimental/cua/tests/native-client.spec.ts b/packages/experimental/cua/tests/native-client.spec.ts index f7a9007f42..19475f83fe 100644 --- a/packages/experimental/cua/tests/native-client.spec.ts +++ b/packages/experimental/cua/tests/native-client.spec.ts @@ -63,6 +63,18 @@ function fixture(options: { } describe('native helper transport', () => { + it('sends run cleanup after an interrupted native request has acknowledged its outcome', async () => { + const f = fixture() + const active = f.client.request('getState', {}, new AbortController().signal) + const release = f.client.requestCleanup('releaseHeldKeys', { runId: 'program' }) + expect(f.requests).toHaveLength(1) + f.reply({ id: f.id(), error: { code: 'NATIVE_CANCELLED', message: 'paused' } }) + await expect(active).rejects.toThrow('paused') + expect(f.requests.map(frame => frame.method)).toEqual(['getState', 'releaseHeldKeys']) + f.reply({ id: f.id(), result: { dispatched: true } }) + await expect(release).resolves.toEqual({ dispatched: true }) + }) + it('passes transport limits and connection deadlines to the shared channel', () => { const f = fixture({ maxRequestBytes: 4096, maxResponseBytes: 8192, timeoutMs: 1234 }) expect(f.connect).toHaveBeenCalledExactlyOnceWith(f.owner, f.config) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 10a71dc794..728ff1c9c2 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949731596,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949731597,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1793949731597,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949731597,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732188,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732188,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732188,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732188,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 73107a5ee3..3ed01c96c7 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -78,7 +78,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 14f13a5a00..e8c8afed86 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -80,7 +80,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index 6e0914ac15..990f35fcd0 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -80,7 +80,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From 7f8a6ad4d4943787be86ae9ce1d2d461ec732a8b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 13:36:26 +0800 Subject: [PATCH 071/149] test(cua-macos): isolate desktop key holds and cover failed pause cleanup --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../native/tests/CapabilitiesFixture.swift | 5 +- .../native/tests/capabilities_e2e.py | 59 +++++++++++-------- .../experimental/cua-macos/src/held-keys.ts | 1 - .../tests/provider-lifecycle.spec.ts | 13 ++-- packages/experimental/cua/src/index.ts | 4 +- 12 files changed, 59 insertions(+), 39 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dede6981f9..67006d1d03 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1144,7 +1144,7 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:34`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9c142b9b43..d476f746da 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1146,7 +1146,7 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:34`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b4cffaf1aa..18901156ff 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -84,7 +84,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, `cua-macos`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b1b58ef08a..9ebcc28905 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -86,7 +86,7 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, `cua-macos`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 2b78969668..5e6adac2ce 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -24,8 +24,8 @@ en: 851614a21ebfeb65 zh: ff868b14dc77adb8 /dsh-cua-native-helper/verification: - en: ade5a2ccd3aeb1b4 - zh: 74c2b0bcb23fc4bc + en: c87945aeec587bf5 + zh: e5d69e4927e2fed3 /dsh-cua-native-helper/further-exploration: en: 265e1927952ce6d7 zh: 7c35d9fd98b6e6c8 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index b3074d6ce9..cc1c046ca9 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -159,7 +159,7 @@ For text pacing comparisons, use `native/tests/text_input_benchmark.py` with `-- For an independently isolated Electron/Chromium check, run `python3 packages/experimental/cua-macos/native/tests/electron_e2e.py --electron /absolute/path/to/Electron.app/Contents/MacOS/Electron`. Omitting `--electron` uses an already installed desktop workspace runtime. The check never downloads Electron. Its renderer runs with Node integration disabled, context isolation and Chromium sandboxing enabled, private userData/sessionData/log directories, and a local fixture page. Renderer click/input events write independent observations through the main process; all test actions still use the native helper. AX polling waits for actual controls after Chromium accessibility activation. Native and Electron subprocess groups are owned and stopped before temporary files are removed. Optional artifacts include a fixture-only screenshot and private request transcript. -Run `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence` to verify a stopped application's discovery and launch, Unicode selection, multiline plain/HTML paste, middle/modifier gestures, overlapping W and D key events, and keypad identities through an isolated AppKit receiver. Add `--click-only` to check click holds, AX routing and cancellation/timeout release in a background fixture without keyboard or clipboard input. It requires existing Accessibility/Screen Recording grants and records the receiver's actual state separately from dispatch results. +Run `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence` to verify a stopped application's discovery and launch, Unicode selection, multiline plain/HTML paste, middle/modifier gestures, overlapping W and D key events, and keypad identities through an isolated AppKit receiver. Add `--keyboard-only` for a background receiver that checks a two-second W+D hold, independent D release, and W cleanup. Add `--click-only` to check click holds, AX routing and cancellation/timeout release in a background fixture without keyboard or clipboard input. It requires existing Accessibility/Screen Recording grants and records the receiver's actual state separately from dispatch results. Run `python3 packages/experimental/cua-macos/native/tests/paste_e2e.py --artifacts /private/evidence` to verify that an owned AppKit app accepts a background Paste action without reading its data, reports `PASTE_NOT_CONSUMED`, then consumes the same paste in the foreground. The test requires an unlocked Mac and Accessibility permission, activates only its own two applications, and records received actions and text independently of helper replies. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 6035ffaee2..73b7754b3a 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -159,7 +159,7 @@ Blender 键盘检查使用 `python3 packages/experimental/cua-macos/native/tests 隔离的 Electron/Chromium 检查使用 `python3 packages/experimental/cua-macos/native/tests/electron_e2e.py --electron /absolute/path/to/Electron.app/Contents/MacOS/Electron`。省略 `--electron` 时使用桌面工作区已安装的运行时,检查不下载 Electron。渲染器禁用 Node 集成,启用上下文隔离和 Chromium 沙箱,使用独立 userData/sessionData/log 目录及本地 fixture 页面。渲染器的点击/输入事件通过主进程写出独立观察,测试动作仍通过原生 helper 执行。AX 轮询等待 Chromium 辅助功能启用后的真实控件。原生和 Electron 子进程组由测试持有,在删除临时文件前停止。可选产物包含仅有 fixture 的截图和私有请求记录。 -运行 `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence`,通过隔离的 AppKit 接收方验证未运行应用的发现和启动、Unicode 选区、多行纯文本/HTML 粘贴、带修饰键的中键手势、W 和 D 同时按住时的事件顺序及数字键盘键码。需要已有辅助功能/屏幕录制权限,接收方实际状态与分派结果分开记录。 添加 `--click-only` 可在后台测试窗口中验证按住时长、AX 路由和取消、超时后的松开事件,不发送键盘或剪贴板输入。 +运行 `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence`,通过隔离的 AppKit 接收方验证未运行应用的发现和启动、Unicode 选区、多行纯文本/HTML 粘贴、带修饰键的中键手势、W 和 D 同时按住时的事件顺序及数字键盘键码。需要已有辅助功能/屏幕录制权限,接收方实际状态与分派结果分开记录。添加 `--keyboard-only` 可用后台窗口验证 W、D 同时按住两秒、单独释放 D 和收尾时释放 W。添加 `--click-only` 可在后台测试窗口中验证按住时长、AX 路由和取消、超时后的松开事件,不发送键盘或剪贴板输入。 运行 `python3 packages/experimental/cua-macos/native/tests/paste_e2e.py --artifacts /private/evidence`,验证自有 AppKit 应用在后台接收 Paste 动作但不读取数据时返回 `PASTE_NOT_CONSUMED`,以及前台读取同一次粘贴后成功。测试要求 Mac 已解锁并已授予辅助功能权限,只激活自有的两个应用,并独立记录收到的动作和文本以核对 helper 回复。 diff --git a/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift b/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift index 5382155260..1151468790 100644 --- a/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift +++ b/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift @@ -92,7 +92,10 @@ final class CapabilitiesFixture: NSObject, NSApplicationDelegate, NSWindowDelega } window.contentView!.addSubview(pad) if Bundle.main.object(forInfoDictionaryKey: "DSHFixtureBackground") as? Bool == true || CommandLine.arguments.contains("--background") { - window.orderFront(nil) + if Bundle.main.object(forInfoDictionaryKey: "DSHFixtureKeyboard") as? Bool == true { + window.makeFirstResponder(pad) + window.orderBack(nil) + } else { window.orderFront(nil) } } else { window.makeKeyAndOrderFront(nil) window.makeFirstResponder(text) diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 432f70e81f..6649b82dd9 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -123,11 +123,39 @@ def check_clicks(helper, window, point, state_file): return state +def check_keyboard(call, owner, state_file, state): + count = len(state["events"]) + call("pressKey", {**owner, "key": ["w", "d"]}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) + chord = [(event["type"], event.get("keyCode")) for event in state["events"][count:]] + assert chord == [(10, 13), (10, 2), (11, 2), (11, 13)], chord + count = len(state["events"]) + + def key(name, direction): + call("sendKeyEvent", {**owner, "runId": "held-input", "key": name, "direction": direction}) + + key("w", "down") + key("d", "down") + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 2) + call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) + time.sleep(2) + key("d", "up") + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 3) + held = state["events"][count:] + assert [(event["type"], event["keyCode"]) for event in held] == [(10, 13), (10, 2), (11, 2)], held + assert held[2]["timestamp"] - held[1]["timestamp"] >= 2, held + call("releaseHeldKeys", {"runId": "held-input"}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) + assert [(e["type"], e["keyCode"]) for e in state["events"][count:]] == [(10, 13), (10, 2), (11, 2), (11, 13)] + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--helper", type=Path, required=True) parser.add_argument("--artifacts", type=Path) - parser.add_argument("--click-only", action="store_true") + only = parser.add_mutually_exclusive_group() + only.add_argument("--click-only", action="store_true") + only.add_argument("--keyboard-only", action="store_true") args = parser.parse_args() transcript = [] helper = None @@ -141,7 +169,8 @@ def main(): info = {"CFBundleName": "Capabilities Fixture", "CFBundleDisplayName": "Capabilities Fixture", "CFBundleIdentifier": "test.dsh.cua." + uuid.uuid4().hex, "CFBundleExecutable": "CapabilitiesFixture", "CFBundlePackageType": "APPL", - "DSHFixtureStatePath": str(state_file), "DSHFixtureBackground": args.click_only} + "DSHFixtureStatePath": str(state_file), "DSHFixtureBackground": args.click_only or args.keyboard_only, + "DSHFixtureKeyboard": args.keyboard_only} (application / "Contents/Info.plist").write_bytes(plistlib.dumps(info)) subprocess.run(["swiftc", str(ROOT / "tests/CapabilitiesFixture.swift"), "-o", str(executable)], check=True) try: @@ -158,6 +187,10 @@ def main(): app = call("launchApp", owner) assert app["running"] is True, app pid = wait_for_state(state_file, lambda value: value["windowId"] > 0)["pid"] + if args.keyboard_only: + check_keyboard(call, owner, state_file, json.loads(state_file.read_text())) + print("PASS: W+D short press, two-second hold, independent D up and remaining W cleanup") + return if not args.click_only: call("activate", owner) state = wait_for_state(state_file, lambda value: value["pid"] == pid and value["active"] and value["keyWindow"]) @@ -188,27 +221,7 @@ def main(): if not args.click_only: call("click", {**window, **point, "mouseButton": "middle", "modifiers": ["shift"]}) state = wait_for_state(state_file, lambda value: any(event["type"] == 26 and event.get("button") == 2 and event["shift"] for event in value["events"])) - count = len(state["events"]) - call("pressKey", {**owner, "key": ["w", "d"]}) - state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) - chord = [(event["type"], event.get("keyCode")) for event in state["events"][count:]] - assert chord == [(10, 13), (10, 2), (11, 2), (11, 13)], chord - count = len(state["events"]) - def key(name, direction): - call("sendKeyEvent", {**owner, "runId": "held-input", "key": name, "direction": direction}) - key("w", "down") - key("d", "down") - state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 2) - call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) - time.sleep(2) - key("d", "up") - state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 3) - held = state["events"][count:] - assert [(event["type"], event["keyCode"]) for event in held] == [(10, 13), (10, 2), (11, 2)], held - assert held[2]["timestamp"] - held[1]["timestamp"] >= 2, held - call("releaseHeldKeys", {"runId": "held-input"}) - state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) - assert [(e["type"], e["keyCode"]) for e in state["events"][count:]] == [(10, 13), (10, 2), (11, 2), (11, 13)] + check_keyboard(call, owner, state_file, state) call("drag", {**window, "fromX": point["x"], "fromY": point["y"], "toX": point["x"] + 80, "toY": point["y"] + 20, "durationMs": 200, "steps": 10, "mouseButton": "middle", "modifiers": ["shift"]}) state = wait_for_state(state_file, lambda value: any(event["type"] == 27 and event.get("button") == 2 and event["shift"] for event in value["events"])) diff --git a/packages/experimental/cua-macos/src/held-keys.ts b/packages/experimental/cua-macos/src/held-keys.ts index 30c1311da8..0cfb58c6fa 100644 --- a/packages/experimental/cua-macos/src/held-keys.ts +++ b/packages/experimental/cua-macos/src/held-keys.ts @@ -35,7 +35,6 @@ export function installHeldKeys(ctx: Context): NonNullable { const run = execution.parent && runs.get(execution.parent) if (!run) throw new NativeError('INVALID_ARGUMENT', 'Use sendKeyEvent inside an active run_code execution') - if (run.client && run.client !== client) throw new NativeError('NATIVE_CLOSED', 'The keyboard connection changed; start a new run_code') run.client = client return client.request('sendKeyEvent', { ...params, runId: run.id }, execution.signal, execution.callId) } diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 194f7244fd..a6efc825a9 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -37,12 +37,14 @@ import type { NativeClient } from '@deepseek-ai/dsh-cua/native-client' import type { NativeBuildSpec } from '../src/native-build.ts' const native = vi.hoisted(() => ({ + connect: vi.fn(), prepare: vi.fn<(spec: NativeBuildSpec) => Promise>(), architecture: vi.fn(() => 'arm64' as const), cleanup: vi.fn(), request: vi.fn(), dispose: vi.fn(), instances: [] as Array<{ disposed: boolean }>, reportDiagnostic: undefined as ((message: string) => void) | undefined, })) +vi.mock('../src/shared-native.ts', () => ({ createSharedNativeChannel: native.connect })) vi.mock('../src/native-build.ts', async importOriginal => ({ ...await importOriginal(), nativeHostArchitecture: native.architecture, @@ -53,6 +55,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ NativeClient: class { disposed = false constructor(...args: ConstructorParameters) { + args[2](args[0], args[1]) native.reportDiagnostic = args[3] native.instances.push(this) } @@ -114,6 +117,7 @@ const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoA const imageMetadata = { pixelWidth: 1, pixelHeight: 1, bounds: { x: 2, y: 3, width: 1, height: 1 }, coordinateSpace: 'window-points', windowId: 7 } async function setup(model?: { provider: string; model: string }, ptc = false) { + native.connect.mockClear() native.cleanup.mockReset().mockResolvedValue({ dispatched: true }) native.request.mockReset().mockResolvedValue([]) native.dispose.mockReset() @@ -157,7 +161,7 @@ it('requires an enclosing run_code before sending a directional key', async () = expect(native.request).not.toHaveBeenCalled() }) -it.each(['return', 'throw', 'pause'])('cleans up only the held W when run_code ends by %s', async (ending) => { +it.each(['return', 'throw', 'pause', 'pause with cleanup error'])('cleans up only the held W when run_code ends by %s', async (ending) => { const f = await setup(undefined, true) await selectApplication(f) const held = new Set() @@ -176,6 +180,7 @@ it.each(['return', 'throw', 'pause'])('cleans up only the held W when run_code e native.cleanup.mockImplementation(async (method, params) => { expect(method).toBe('releaseHeldKeys') expect(runIds).toEqual(new Set([params.runId])) + if (ending === 'pause with cleanup error') throw new Error('release acknowledgement failed') for (const key of held) events.push(`${key}:up`) held.clear() return { dispatched: true } @@ -186,10 +191,10 @@ it.each(['return', 'throw', 'pause'])('cleans up only the held W when run_code e output: { schema: {}, render: () => [] }, async execute() { const keys = [...held] - if (ending === 'pause' && keys.length === 1) { + if (ending.startsWith('pause') && keys.length === 1) { controller.abort() await setImmediate() - expect(held.size).toBe(0) + expect(held.size).toBe(ending === 'pause' ? 0 : 1) } return keys }, @@ -204,7 +209,7 @@ it.each(['return', 'throw', 'pause'])('cleans up only the held W when run_code e ].join('\n') const result = await executeModelToolFixture(f.ctx, { agent: f.handle.agent, callId: ToolCallId('keyboard-run'), name: 'run_code', arguments: { code, description: 'Exercise held keys' }, signal: controller.signal }) - expect(events, JSON.stringify(result.content)).toEqual(['w:down', 'd:down', 'd:up', 'w:up']) + expect(events, JSON.stringify(result.content)).toEqual(['w:down', 'd:down', 'd:up', ...ending === 'pause with cleanup error' ? [] : ['w:up']]) expect(native.cleanup).toHaveBeenCalledTimes(1) expect(result.isError).toBe(ending !== 'return') if (ending === 'throw') expect(JSON.stringify(result.content)).toContain('program failed') diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index e06b6989ea..d88a958711 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -127,7 +127,7 @@ export interface CuaBackend { guidance: string capabilities: CuaStartupCapabilities /** Dispatch a directional key through the provider's execution-scoped input owner, after application authorization. */ - sendKeyEvent?: (client: NativeClient, params: Extract['params'], + sendKeyEvent?: (client: NativeClient, params: Record, execution: Readonly) => Promise /** * Build native permission parameters for the selected action and Client language. @@ -214,7 +214,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const signal = execution.signal signal.throwIfAborted() const client = execution.nativeClient - if (method === 'sendKeyEvent' && backend.sendKeyEvent) return backend.sendKeyEvent(client, parsedParams, execution) + if (method === 'sendKeyEvent' && backend.sendKeyEvent) return backend.sendKeyEvent(client, params, execution) const nativeParams = method === 'findApps' ? { ...params, maxAppMatches: config.maxAppMatches } : method === 'paste' From de0515a771cd93ab0f4f92d6712900188046f1c6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 13:41:15 +0800 Subject: [PATCH 072/149] fix(cua-macos): retain the run signal and validate test receiver events --- packages/experimental/cua-macos/README.i18n.yaml | 2 +- packages/experimental/cua-macos/README.zh.md | 2 +- packages/experimental/cua-macos/src/held-keys.ts | 5 +++-- .../cua-macos/tests/provider-lifecycle.spec.ts | 7 ++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 1abb4f6399..bb17939f06 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -16,7 +16,7 @@ zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: en: f4206e2cb725c917 - zh: 37f0b04e9e74c193 + zh: d10792bd8e7e242b /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 0f6f4dad51..51826288e4 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -81,7 +81,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. `pressKey(key, modifiers?)` 接受单个键名或由不同物理键组成的非空数组。`Enter` 和 `Return` 等别名指向同一个物理键。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。需要全程按住的修饰键单独传入,例如 `pressKey('Tab', ['shift'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。单独传入 `+` 或 `plus` 会发送 Shift 和等号。在多键组合中请使用 `=`,需要全程按住 Shift 时将其列入修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。短按使用 App 对象上的 `pressKey`。 -长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。按下成功后,在同一次执行中调用其他工具时该键仍保持按下。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 向每个键原先的接收进程松开该次执行仍按着的键。后续执行不能释放前一次执行的键。 +长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。按下成功后,在同一次执行中调用其他工具时该键仍保持按下。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 为本次执行仍按着的键逐一发送松开事件,每个事件都发回原接收进程。后续执行不能释放前一次执行的键。 Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 diff --git a/packages/experimental/cua-macos/src/held-keys.ts b/packages/experimental/cua-macos/src/held-keys.ts index 0cfb58c6fa..fee0519c53 100644 --- a/packages/experimental/cua-macos/src/held-keys.ts +++ b/packages/experimental/cua-macos/src/held-keys.ts @@ -24,11 +24,12 @@ export function installHeldKeys(ctx: Context): NonNullable { void release()?.catch(() => undefined) } - execution.signal.addEventListener('abort', onAbort, { once: true }) + const signal = execution.signal + signal.addEventListener('abort', onAbort, { once: true }) try { return await next() } finally { - execution.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) await release() } }) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index a6efc825a9..f65e1a6a61 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -171,10 +171,11 @@ it.each(['return', 'throw', 'pause', 'pause with cleanup error'])('cleans up onl native.request.mockImplementation(async (method, params) => { expect(method).toBe('sendKeyEvent') runIds.add(params.runId) - const key = String(params.key) - if (params.direction === 'down') held.add(key) + const { key, direction } = params + if (typeof key !== 'string' || (direction !== 'down' && direction !== 'up')) throw new Error('Invalid directional event') + if (direction === 'down') held.add(key) else held.delete(key) - events.push(`${key}:${params.direction}`) + events.push(`${key}:${direction}`) return { dispatched: true } }) native.cleanup.mockImplementation(async (method, params) => { From 5195b73915779c4d9c54253149b8c6eb68e21f12 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sat, 7 Nov 2026 13:46:49 +0800 Subject: [PATCH 073/149] fix(ui-subagent): distinguish unread catalogs and isolate browser checks --- .../unread-catalog.expected.md | 12 ------- apps/web/tests/forge-panel.e2e.ts | 15 +++++---- apps/web/tests/subagent-conversation.e2e.ts | 32 ++++++++++++++++--- packages/client/ui-subagent/README.i18n.yaml | 8 ++--- packages/client/ui-subagent/README.md | 4 ++- packages/client/ui-subagent/README.zh.md | 4 ++- .../client/SubagentHeaderLineage.module.css | 4 --- .../src/client/SubagentHeaderLineage.tsx | 7 ++-- .../client/ui-subagent/src/client/locales.ts | 8 +++++ .../tests/conversation-ui.client.spec.tsx | 25 +++++++++++++-- 10 files changed, 81 insertions(+), 38 deletions(-) delete mode 100644 apps/web/tests/expected/subagent-conversation/unread-catalog.expected.md diff --git a/apps/web/tests/expected/subagent-conversation/unread-catalog.expected.md b/apps/web/tests/expected/subagent-conversation/unread-catalog.expected.md deleted file mode 100644 index b1ccd511e5..0000000000 --- a/apps/web/tests/expected/subagent-conversation/unread-catalog.expected.md +++ /dev/null @@ -1,12 +0,0 @@ -- tree "Subagent sessions": - - treeitem "event-sourcing researcher agent-1 · continuable · completed 7.9K tok · {{duration}}" [expanded] [level=1]: - - button "Collapse event-sourcing researcher descendants" - - text: event-sourcing researcher agent-1 7.9K tok {{duration}} - - button "Open event-sourcing researcher in sidebar" - - group: - - treeitem "example editor recorded-grandchild · continuable · completed 0 tok · {{duration}}" [level=2]: - - text: example editor recorded-grandchild 0 tok {{duration}} - - button "Open example editor in sidebar" - - treeitem "event-sourcing reviewer recorded-one-shot · one-shot · completed 0 tok · {{duration}}" [level=1]: - - text: event-sourcing reviewer recorded-one-shot 0 tok ~6mo 12d - - button "Open event-sourcing reviewer in sidebar" diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 0392e51260..4cb03a2c86 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -462,13 +462,14 @@ it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omi await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'), await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE) - // Reference lookup can finish after the turn; the visible card confirms its binding was appended. - await scaffold.ctx.sessionPersistence.flush() - const boundEvents = await readPersistedEvents(scaffold, sessionId) - expect(boundEvents.flatMap((event) => { - const record = pluginRecordOf(event) - return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] - })).toEqual([{ action: 'reference', url: PULL }]) + await expect.poll(async () => { + await scaffold.ctx.sessionPersistence.flush() + const boundEvents = await readPersistedEvents(scaffold, sessionId) + return boundEvents.flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] + }) + }).toEqual([{ action: 'reference', url: PULL }]) const calls = (await readFile(join(project, '.fixture-bin', 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url']) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 018f5e3f18..a3345c93bf 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -405,14 +405,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await grandchild.waitFor() expect(await grandchild.getAttribute('aria-expanded')).toBeNull() await compareOrRefreshGolden( - fileURLToPath(new URL('./expected/subagent-conversation/unread-catalog.expected.md', import.meta.url)), + TREE_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), MODE, ) expect(page.url()).toBe(parentUrl) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() - await mkdir('.playwright-mcp/catalog-expand', { recursive: true }) - await page.screenshot({ path: '.playwright-mcp/catalog-expand/discovery.png' }) await page.keyboard.press('Escape') await page.locator('[data-subagent-panel]').waitFor({ state: 'detached' }) } finally { @@ -481,7 +479,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = } expect(await row.getAttribute('aria-label')).toContain(`${LABEL} ${CHILD_NAME} `) expect(await row.getByRole('button', { name: /description/u }).count()).toBe(0) - expect(await row.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).count()).toBe(1) + await row.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).waitFor() expect(await row.getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).count()).toBe(1) expect(page.url()).toBe(originalUrl) expect(await page.getByRole('tooltip').count()).toBe(0) @@ -497,8 +495,32 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('expands locally available cold catalogs without requesting or activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree')) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + const trigger = page.getByRole('button', { name: '2 subagents', exact: true }) + await trigger.waitFor() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + const siblingsRead = page.waitForResponse((response) => { + if (!response.url().endsWith('/api/session/projections')) return false + const body = response.request().postDataJSON() as { payload: { args: { request: { sessionId: SessionId } } } } + return body.payload.args.request.sessionId === oneShotId + }) + await trigger.click() + await (await siblingsRead).finished() + const expand = page.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }) + await expand.waitFor() + const descendantsRead = page.waitForResponse((response) => { + if (!response.url().endsWith('/api/session/projections')) return false + const body = response.request().postDataJSON() as { payload: { args: { request: { sessionId: SessionId } } } } + return body.payload.args.request.sessionId === grandchildId + }) + await expand.click() + await (await descendantsRead).finished() + await page.keyboard.press('Escape') + await page.locator('[data-subagent-panel]').waitFor({ state: 'detached' }) const catalogReads = apiCalls.filter(path => path === '/api/session/projections').length - await page.getByRole('button', { name: '2 subagents' }).hover() + await page.locator('[data-composer-input]').first().hover() + await trigger.hover() const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) expect(await catalogTree.evaluate((element) => { const rect = element.getBoundingClientRect() diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index e4ce63e820..7381b1f9d5 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -30,8 +30,8 @@ en: c158f12dcaed87b7 zh: e5d315e7b1fb4fa9 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation: - en: 08735d4cc7569cfa - zh: 517309ca6a540adf + en: 3c06acc93d7d4d59 + zh: b967155aec39df0c /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens: en: dcbc58d4cad47fa8 zh: e962a8cbdfd4a914 @@ -57,8 +57,8 @@ en: de2762a99572475f zh: 9b41107ad14ebf8a /deepseek-ai-dsh-client-ui-subagent/known-limitations-and-deferred-work: - en: ee310ff3cd487639 - zh: 23793aa1c28a23af + en: ea983111988e4b9b + zh: c0ed7a583d2cad52 /deepseek-ai-dsh-client-ui-subagent/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 357453d99e..555c6fd2d9 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -61,7 +61,7 @@ The [subagent runtime](../../subagent/subagent/README.md) owns continuation and ### Catalog derivation -The header lineage renderer reads `subagentCatalog` only from `projectionsBySession` through the standard `useSessions` hook. The Session object layer merges list-cache hints, explicit reads, and the Host-wide control stream into these shared values; live frames update loaded levels without per-menu subscriptions. Activity prefers the unified UI status and falls back to Session summaries; summaries also supply titles and usage. Rendering and hovering use only local projections and never request missing catalogs. Missing and explicitly empty catalogs have no expansion arrow; a later shared projection with children makes the arrow available. Failures require explicit retry. Migration-required rows keep their history-opening actions without a format notice or descendant expansion; opening the Session supplies its catalog through history and control projections. External rows are always leaves and do not load child Sessions. +The header lineage renderer reads `subagentCatalog` only from `projectionsBySession` through the standard `useSessions` hook. The Session object layer merges list-cache hints, explicit reads, and the Host-wide control stream into these shared values; live frames update loaded levels without per-menu subscriptions. Activity prefers the unified UI status and falls back to Session summaries; summaries also supply titles and usage. Rendering and hovering use only local projections and never request missing catalogs. An unread empty menu says “Subagents not loaded”; the loading notice appears only during a request. Missing and explicitly empty catalogs have no expansion arrow; a later shared projection with children makes the arrow available. Failures require explicit retry. Migration-required rows keep their history-opening actions without a format notice or descendant expansion; opening the Session supplies its catalog through history and control projections. External rows are always leaves and do not load child Sessions. Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. CSS anchors keep the panel attached to its trigger within layout-viewport size limits. Browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Internal scrolling, hover behavior, pinned opening, and focus restoration remain independent. @@ -123,6 +123,8 @@ Append-only. This package never edits earlier request tokens. These limits define what the catalog can show and what `@` references mean; they are current package constraints. - **Non-completed inactive outcomes remain grouped** — the catalog distinguishes a latest normal completion from other inactive states, but does not distinguish failure, cancellation, refusal, token exhaustion, or a child with no closed turn; the UI exposes no Activation identity, and stopping is limited to the composer's current-turn Stop for a running continuable child. +- **Ancestor menus require keyboard activation for uncached branches** — hovering does not read, and clicking the ancestor title navigates. Focus the title and press ArrowDown to prepare that level without leaving the current Session. +- **Level discovery stops on interruption** — an unexpected read rejection stops later siblings and is logged to the console. A reconnect can interrupt discovery before direct children are read; explicitly reopen the menu to prepare the level again. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 5498652e1b..056fad6cde 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -61,7 +61,7 @@ kind: "package-reference" ### 目录派生 -页头谱系 renderer 通过标准 `useSessions` 钩子,仅从 `projectionsBySession` 读取 `subagentCatalog`。Session 对象层将列表缓存提示、显式读取和 Host 全局 control 流合并到这些共享值;实时帧更新已加载层级,无需每个菜单单独订阅。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要还提供标题与用量。渲染与悬停仅使用本地投影,不会请求缺失目录。缺失或显式为空的目录没有展开箭头;后续共享投影包含下级时才显示箭头。读取失败后需要显式重试。需要迁移的行保留打开历史的操作,不显示格式提示,也不展开下级;打开 Session 后,历史与 control 投影提供其目录。外部行始终为叶子,不加载子 Session。 +页头谱系 renderer 通过标准 `useSessions` 钩子,仅从 `projectionsBySession` 读取 `subagentCatalog`。Session 对象层将列表缓存提示、显式读取和 Host 全局 control 流合并到这些共享值;实时帧更新已加载层级,无需每个菜单单独订阅。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要还提供标题与用量。渲染与悬停仅使用本地投影,不会请求缺失目录。尚未读取的空菜单显示“尚未加载子智能体”;仅请求进行中显示加载提示。缺失或显式为空的目录没有展开箭头;后续共享投影包含下级时才显示箭头。读取失败后需要显式重试。需要迁移的行保留打开历史的操作,不显示格式提示,也不展开下级;打开 Session 后,历史与 control 投影提供其目录。外部行始终为叶子,不加载子 Session。 每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。CSS 锚点让面板跟随触发器,并按布局视口限制尺寸。浏览器缩放和平移时面板随页面移动,因此可以超出视觉视口。内部滚动、悬停行为、点击固定展开和焦点恢复保持独立。 @@ -123,6 +123,8 @@ one-shot 和模式未知的 child 选用只读编辑器。可继续 child 经由 这些限制定义目录能显示什么、`@` 引用意味着什么;它们是当前包约束。 - **非完成的 inactive 结果仍合并显示**:目录能区分最近一次正常完成与其他 inactive 状态,但不区分失败、取消、拒绝、token 耗尽或尚无已结束轮次的 child;UI 不公开 Activation 身份,停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。 +- **祖先菜单的未缓存分支需要键盘激活**:悬停不读取,点击祖先标题会导航。聚焦标题后按 ArrowDown,可在不离开当前 Session 的情况下准备这一层。 +- **单层发现会在中断后停止**:意外的读取拒绝会停止后续兄弟节点,并记录到控制台。重连可能在直接子节点读取前中断发现;需再次显式打开菜单来准备这一层。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 570c6cd7ef..c450098395 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -450,10 +450,6 @@ height: 23px; } -.children[aria-busy='true']::before { - content: none; -} - .children > .node::before { top: 0; bottom: 0; diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index cffac90f99..8cbb5be217 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -255,10 +255,13 @@ function CatalogRows({ const timer = setInterval(() => { setNow(Date.now()) }, 1_000) return () => { clearInterval(timer) } }, [running]) - const emptyLoading = (catalog.state === 'loading' || catalog.state === 'idle') && catalog.entries.length === 0 + const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 const reserveDisclosure = catalog.entries.some(entry => canExpandCatalog(entry, catalogs, summaries)) return ( <> + {catalog.state === 'idle' && catalog.entries.length === 0 && ( +
{t('load.idle')}
+ )} {emptyLoading && ( )} @@ -552,7 +555,7 @@ function CatalogDropdown(props: CatalogDropdownProps) { const totalCountKey = directCount === 1 ? 'count.total.one' : 'count.total.other' const runningCountKey = runningCount === 1 ? 'count.running.one' : 'count.running.other' const presentedCatalog: SubagentCatalogSnapshot | undefined = catalog ?? (variant === 'switcher' || props.hasBoardContent - ? { entries: [], state: 'loading', error: null } + ? { entries: [], state: 'idle', error: null } : undefined) const cancelHoverClose = (): void => { diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index e7edefbb2c..83754edd6c 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -26,6 +26,7 @@ export const zh = { 'tokens.million': '{value}M', 'tokens.total': '{value} tok', 'loading.label': '正在加载子智能体…', + 'load.idle': '尚未加载子智能体', 'load.error': '无法加载子智能体', 'retry': '重试', 'mode.external': '外部任务', @@ -76,6 +77,7 @@ export const en: Record = { 'tokens.million': '{value}M', 'tokens.total': '{value} tok', 'loading.label': 'Loading subagents…', + 'load.idle': 'Subagents not loaded', 'load.error': 'Unable to load subagents', 'retry': 'Retry', 'mode.external': 'External task', @@ -129,6 +131,7 @@ export const ja = { 'tokens.million': '{value}M', 'tokens.total': '{value} tok', 'loading.label': 'サブエージェントを読み込み中…', + 'load.idle': 'サブエージェントは未読み込みです', 'load.error': 'サブエージェントを読み込めません', 'retry': '再試行', 'mode.external': '外部タスク', @@ -179,6 +182,7 @@ export const es = { 'tokens.million': '{value}M', 'tokens.total': 'Tok {value}', 'loading.label': 'Cargando subagentes…', + 'load.idle': 'Subagentes sin cargar', 'load.error': 'No se pueden cargar subagentes', 'retry': 'Reintentar', 'mode.external': 'tarea externa', @@ -229,6 +233,7 @@ export const fr = { 'tokens.million': '{value}M', 'tokens.total': 'Jeton {value}', 'loading.label': 'Chargement des sous-agents…', + 'load.idle': 'Sous-agents non chargés', 'load.error': 'Impossible de charger les sous-agents', 'retry': 'Réessayer', 'mode.external': 'Tâche externe', @@ -279,6 +284,7 @@ export const de = { 'tokens.million': '{value}M', 'tokens.total': '{value}-Token', 'loading.label': 'Subagenten werden geladen…', + 'load.idle': 'Subagenten nicht geladen', 'load.error': 'Subagenten können nicht geladen werden', 'retry': 'Erneut versuchen', 'mode.external': 'Externe Aufgabe', @@ -329,6 +335,7 @@ export const ru = { 'tokens.million': '{value}M', 'tokens.total': '{value} ток', 'loading.label': 'Загрузка субагентов…', + 'load.idle': 'Субагенты не загружены', 'load.error': 'Невозможно загрузить субагенты', 'retry': 'Повторить', 'mode.external': 'Внешняя задача', @@ -379,6 +386,7 @@ export const pt = { 'tokens.million': '{value}M', 'tokens.total': '{value} valor', 'loading.label': 'Carregando subagentes…', + 'load.idle': 'Subagentes não carregados', 'load.error': 'Não foi possível carregar subagentes', 'retry': 'Tentar novamente', 'mode.external': 'Tarefa externa', diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index e3234cfef1..a70f41295e 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1781,7 +1781,9 @@ describe('SubagentHeaderLineage', () => { const switcher = screen.getByRole('button', { name: '切换子智能体:worker' }) hoverCatalog(switcher) - expect(screen.getByText('正在加载子智能体…')).toBeTruthy() + expect(input.prepareCatalog).not.toHaveBeenCalled() + expect(screen.getByText('尚未加载子智能体')).toBeTruthy() + expect(screen.queryByText('正在加载子智能体…')).toBeNull() expect(screen.queryAllByRole('treeitem')).toEqual([]) expect(input.refreshProjection).not.toHaveBeenCalled() }) @@ -1887,11 +1889,30 @@ describe('SubagentHeaderLineage', () => { const switcher = screen.getByRole('button', { name: '切换子智能体:正在扫描项目文件' }) expect(input.refreshProjection).not.toHaveBeenCalled() hoverCatalog(switcher) - expect(screen.getByText('正在加载子智能体…')).toBeTruthy() + expect(input.prepareCatalog).not.toHaveBeenCalled() + expect(screen.getByText('尚未加载子智能体')).toBeTruthy() + expect(screen.queryByText('正在加载子智能体…')).toBeNull() expect(screen.queryAllByRole('treeitem')).toEqual([]) expect(input.refreshProjection).not.toHaveBeenCalled() }) + it.each(['idle', 'loading'] as const)('shows the actual %s state of an empty title menu', (state) => { + const input = { + ...props(undefined, {}, {}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }), + lineageSessionId: CHILD, + displayTitle: 'worker', + } + const initial = input.useSessions(value => value) + render( select({ + ...initial, + projectionsBySession: { [PARENT]: { state, error: null, values: {} } }, + })} />) + hoverCatalog(screen.getByRole('button', { name: '切换子智能体:worker' })) + expect(screen.getByText(state === 'idle' ? zh['load.idle'] : zh['loading.label'])).toBeTruthy() + expect(screen.queryByText(state === 'idle' ? zh['loading.label'] : zh['load.idle'])).toBeNull() + expect(input.prepareCatalog).not.toHaveBeenCalled() + }) + it('keeps a nested title switcher scoped to its direct-parent catalog', () => { const input = { ...props(catalog(), { From a9e0950206c575c09c84312ac410a860a230048a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:10:32 +0800 Subject: [PATCH 074/149] docs(cua-macos): explain physical key repeat in sendKeyEvent guidance --- packages/experimental/cua-macos/README.md | 2 +- packages/experimental/cua-macos/README.zh.md | 2 +- packages/experimental/cua-macos/src/guidance.ts | 2 +- snapshots/session/cua-native/system-prompt.expected.md | 2 +- snapshots/session/cua-ptc/system-prompt.expected.md | 2 +- snapshots/session/ptc-script-retained/system-prompt.expected.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 042cc4fa94..02b260705a 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -224,7 +224,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 51826288e4..f67195c67b 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -224,7 +224,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index e750c4068b..2fe07ef025 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -14,7 +14,7 @@ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { if (method === 'pressKey') return `- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 3ed01c96c7..82210fa306 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -79,7 +79,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index e8c8afed86..3acaa30b88 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -81,7 +81,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index 990f35fcd0..dc18435318 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -81,7 +81,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — hold a physical key across tool calls within one run_code. Choose the keys, how long to hold them, and which tools to call while they are down. Send 'up' for each key when needed. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From ab9700e6ac3d8165aa3bc410198396c1e898011e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:19:08 +0800 Subject: [PATCH 075/149] refactor(cua-macos): reserve overlapping ordinary keys for sendKeyEvent --- ...05-cua-scoped-surfaces-and-input.i18n.yaml | 8 +- ...026-11-05-cua-scoped-surfaces-and-input.md | 4 +- ...-11-05-cua-scoped-surfaces-and-input.zh.md | 4 +- .../experimental/cua-macos/README.i18n.yaml | 4 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../native/Sources/CuaNative/AXWorker.swift | 2 +- .../Sources/CuaNative/KeyboardInput.swift | 27 ++----- .../Sources/CuaNative/RequestAdmission.swift | 7 -- .../cua-macos/native/generate-protocol.ts | 4 +- .../cua-macos/native/protocol.schema.json | 79 ++++++++----------- .../native/tests/capabilities_e2e.py | 12 +-- .../tests/swift/KeyboardChordTests.swift | 48 +++++------ .../native/tests/swift/ProtocolTests.swift | 12 --- .../experimental/cua-macos/src/guidance.ts | 4 +- .../cua-macos/tests/guidance.spec.ts | 5 +- .../tests/protocol-generation.spec.ts | 8 ++ packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- .../experimental/cua/src/native-schema.ts | 8 +- packages/experimental/cua/src/protocol.ts | 7 +- packages/experimental/cua/src/types.ts | 8 +- packages/experimental/cua/tests/api.spec.ts | 5 +- .../experimental/cua/tests/protocol.spec.ts | 7 -- .../cua-native/system-prompt.expected.md | 4 +- .../cua-native/tool-schemas.expected.json | 17 +--- .../session/cua-ptc/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- 32 files changed, 128 insertions(+), 192 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml index 716f07d599..3e32b6eb92 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.i18n.yaml @@ -9,11 +9,11 @@ en: f72db0b79aa02fb5 zh: b91c1f177dc93ab4 /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/decision: - en: 74f97ec371e61b82 - zh: a8a5cefc1e06bb20 + en: ec115e11f96ffe60 + zh: c1fe9eb2866196d0 /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/alternatives-considered: - en: 2113f17a49e16670 - zh: 1df04d9736a3ad4f + en: 32c970ae153e0f6c + zh: 8431be0a10ffed15 /agent-note-cua-scoped-surfaces-and-separated-input-capabilities/consequences: en: c4b12ca80865e670 zh: 396426fad0105eaa diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md index 6b4fe604d5..cce266e52b 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.md @@ -14,7 +14,7 @@ Application and window objects share accessibility operations, but only an expli Provider startup capabilities are static. Platform, supported methods, application-level accessibility, keyboard scopes, browser support, and coordinate space are fixed when the provider is installed. The native `getCapabilities` RPC reports runtime permissions and session lock only; `installCua` merges the two layers for the model-visible result. The neutral RPC is a common operation representation, not a capability inventory; provider guidance names the actions its platform supports. -Keyboard input keeps keys and modifiers separate: `pressKey(key, modifiers?)` presses one key or, on macOS, several physical keys in order and releases them in reverse while holding the listed modifiers, and its keyboard modifier list is a plain string array defined by the provider. `sendKeyEvent(key, direction)` sends exactly one key-down or key-up without an implicit counterpart. A caller can therefore hold a modifier or split a gesture; neither method packs a chord string, and the shared schema does not enumerate platform key names. +Keyboard input keeps keys and modifiers separate: `pressKey(key, modifiers?)` presses and releases one key while holding the listed modifiers, and its keyboard modifier list is a plain string array defined by the provider. `sendKeyEvent(key, direction)` sends exactly one key-down or key-up without an implicit counterpart. A caller can therefore hold a modifier or split a gesture; neither method packs a chord string, and the shared schema does not enumerate platform key names. Within one API instance, the object facade keeps one current accessibility snapshot per application handle, shared by every application and window object for that handle, including repeated selections of the same application. `getWindow` reads its initial tree silently; `getApp` does the same when the provider reports application-level accessibility. A later `getAccessibilityTree` read invalidates the previous snapshot before dispatch. The facade automatically attaches the latest snapshot ID to element operations; callers use element IDs from the latest tree and do not assume an older ID still identifies the same control. Element IDs are opaque provider strings. Only an explicit `CuaWindow` captures screenshots or sends pointer input. The native helper retains its own application and optional-window snapshots behind that facade; the native layer and the facade snapshot remain separate. The external runtime continues to own variable persistence and completion serialization; the facade adds no evaluator. The [native object decision](2026-09-17-experimental-cua-object-api.md) owns evaluator independence, and the [shared helper decision](2026-10-09-cua-shared-native-helper.md) owns connection lifetime. @@ -24,7 +24,7 @@ Within one API instance, the object facade keeps one current accessibility snaps **An application-specific branch in the shared base** would let window objects inherit decisions and capability checks that apply only to applications. Scope factories that inject their own requirements keep the shared operations independent of which scope uses them. -**A packed chord string** requires parsing key names, separators, and plus escaping together, and it pushes platform alias normalization into the shared layer. Keystroke and direction are separate semantics: `pressKey` describes a complete press and release, while `sendKeyEvent` describes one explicit direction. Separate key and modifier values keep those semantics without a parser. +**A packed chord string** requires parsing key names, separators, and plus escaping together, and it pushes platform alias normalization into the shared layer. Keystroke and direction are separate semantics: `pressKey` describes one complete keystroke, while `sendKeyEvent` describes one explicit direction. Separate key and modifier values keep those semantics without a parser. **Platform keyboard key enumerations in the shared schema** would couple the portable operation schema to one provider's key names and require a schema change for each platform. Provider-defined strings let the provider interpret its own names while the shared schema stays neutral. diff --git a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md index d03c3b4723..5135dc9be8 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md +++ b/.agents/notes/implemented/architecture/2026-11-05-cua-scoped-surfaces-and-input.zh.md @@ -14,7 +14,7 @@ Status: implemented Provider 的启动能力是静态的。平台、支持的方法、应用级辅助功能、键盘作用域、浏览器支持和坐标空间在安装 provider 时固定。原生 `getCapabilities` RPC 只报告运行时权限和会话锁定状态;`installCua` 为模型可见结果合并两层。中立的 RPC 是通用操作表示,不是能力清单;provider 指导文本说明其平台支持的动作。 -键盘输入把按键与修饰键分开:`pressKey(key, modifiers?)` 在按住所列修饰键时按下并释放指定的键。macOS 也接受多个物理键,依次按下并逆序释放。修饰键名称由提供方定义,以字符串数组传入。`sendKeyEvent(key, direction)` 只发送一次按下或抬起,不隐式发送相反事件。因此调用方可以保持修饰键或拆分手势;两个方法都不接受组合键字符串,共享 schema 也不枚举平台按键名。 +键盘输入把按键与修饰键分开:`pressKey(key, modifiers?)` 在按住所列修饰键时按下并释放一个键,其键盘修饰键列表是 provider 定义的普通字符串数组。`sendKeyEvent(key, direction)` 只发送一次按下或抬起,不隐式发送相反事件。因此调用方可以保持修饰键或拆分手势;两个方法都不接受组合键字符串,共享 schema 也不枚举平台按键名。 在单个 API 实例内,对象门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享这份快照,包括对同一应用的重复选择。`getWindow` 静默执行首次树读取;当 provider 报告应用级辅助功能时,`getApp` 也静默读取。之后的 `getAccessibilityTree` 读取会在分派前使上一份快照失效。门面会为元素操作自动附加最新快照 ID;调用方应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。元素 ID 是 provider 定义的不透明字符串。只有显式 `CuaWindow` 可以截图或发送指针输入。原生 helper 在该门面之后保留自己的应用快照和可选窗口快照;原生层与门面快照相互独立。外部运行时继续负责变量持久化和最终表达式序列化;门面不加入 evaluator。[原生对象决定](2026-09-17-experimental-cua-object-api.zh.md)负责 evaluator 独立性,[共享 helper 决定](2026-10-09-cua-shared-native-helper.zh.md)负责连接生命期。 @@ -24,7 +24,7 @@ Provider 的启动能力是静态的。平台、支持的方法、应用级辅 **在共享基类中加入应用专属分支**会让窗口对象继承只适用于应用的决定和能力检查。由各作用域工厂注入自己的要求,可以让共享操作不依赖使用它的作用域。 -**打包的组合键字符串**需要一起解析按键名、分隔符和加号转义,并把平台别名归一化推入共享层。击键与方向是两种语义:`pressKey` 在一次调用中完成按下和释放,`sendKeyEvent` 描述一个显式方向。分开的按键和修饰键值让这些语义无需解析器。 +**打包的组合键字符串**需要一起解析按键名、分隔符和加号转义,并把平台别名归一化推入共享层。击键与方向是两种语义:`pressKey` 描述一次完整击键,`sendKeyEvent` 描述一个显式方向。分开的按键和修饰键值让这些语义无需解析器。 **在共享 schema 中枚举平台键盘按键**会把可移植的操作 schema 绑定到某个 provider 的按键名,并让每个平台都修改 schema。由 provider 定义字符串,平台可以解释自己的名称,共享 schema 保持中立。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index bb17939f06..00c45988de 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: f4206e2cb725c917 - zh: d10792bd8e7e242b + en: db5e8ad4d2ea832a + zh: 647cf55f4851760e /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 02b260705a..971acf4818 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -79,7 +79,7 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-cua-macos) lists all fields. Native tool calls use `{method, params}`; the complete method-specific schema is exposed to the model. `getApp` returns the `appHandle` used by every app-targeted operation, and `app.getWindow(windowId)` returns the object that owns screenshots and pointer input. -`pressKey(key, modifiers?)` accepts one key or a nonempty array of distinct physical keys. Aliases such as `Enter` and `Return` identify the same key. `pressKey(['w', 'd'])` presses W then D and releases D then W. Pass held modifiers separately, as in `pressKey('Tab', ['shift'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. In a multi-key chord, use `=` and pass Shift explicitly to apply it to the whole chord. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. Use the App object's `pressKey` for a short press. +`pressKey(key, modifiers?)` presses and releases one key. For shortcuts, pass held modifiers separately, as in `pressKey('a', ['command'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. Use the App object's `pressKey` for a short press. For a long press, call `app.sendKeyEvent('w', 'down')` inside `run_code`. Each successful down remains active across subsequent tool calls in that execution. Choose when to send each up: after W down, D down and D up, W stays down. When the program returns, exits with an error, or the user pauses it, the helper releases the remaining keys at the processes that received their downs. A later execution cannot release an earlier execution's keys. @@ -223,8 +223,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index f67195c67b..57c2f3f371 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -79,7 +79,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-cua-macos)列出全部字段。原生工具调用使用 `{method, params}`,模型可以看到每个方法的完整参数定义。`getApp` 返回每个面向应用的操作所需的 `appHandle`,`app.getWindow(windowId)` 返回负责截图和指针输入的对象。 -`pressKey(key, modifiers?)` 接受单个键名或由不同物理键组成的非空数组。`Enter` 和 `Return` 等别名指向同一个物理键。例如,`pressKey(['w', 'd'])` 依次按下 W、D,再依次松开 D、W。需要全程按住的修饰键单独传入,例如 `pressKey('Tab', ['shift'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。单独传入 `+` 或 `plus` 会发送 Shift 和等号。在多键组合中请使用 `=`,需要全程按住 Shift 时将其列入修饰键。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。短按使用 App 对象上的 `pressKey`。 +`pressKey(key, modifiers?)` 按下并松开一个键。使用快捷键时单独传入修饰键,例如 `pressKey('a', ['command'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。`+` 和 `plus` 都会发送 Shift 和等号。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。短按使用 App 对象上的 `pressKey`。 长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。按下成功后,在同一次执行中调用其他工具时该键仍保持按下。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 为本次执行仍按着的键逐一发送松开事件,每个事件都发回原接收进程。后续执行不能释放前一次执行的键。 @@ -223,8 +223,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 5e6adac2ce..58af404d5d 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2a69d89b7fc95998 zh: 084575bb31fb40dd /dsh-cua-native-helper/observation-and-input: - en: 851614a21ebfeb65 - zh: ff868b14dc77adb8 + en: 0bbc620165d868fa + zh: abe73bd9c7b8cc5d /dsh-cua-native-helper/verification: en: c87945aeec587bf5 zh: e5d69e4927e2fed3 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index cc1c046ca9..cc3a0de35f 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -103,7 +103,7 @@ Clicks send mouse-down and mouse-up events at the element center or the supplied Capture and pointer delivery read CG bounds before and after an exact-window AX frame query. Movement during the query returns WINDOW_CHANGED. Valid AX/CG frames must have equal outward-rounded integral point rectangles; a known mismatch returns WINDOW_CHANGED before mouse movement, priming or button delivery. The helper preserves both coordinate sources rather than substituting AX geometry for CG geometry. Missing or unusable AX geometry remains unknown, while AX communication, permission and invalid-element errors propagate. Capture without Accessibility permission retains its CG/SCK checks. Advertised AX actions bypass this pointer preflight; an AX action error does not trigger pointer input. These reads do not lock the window against later movement, and no geometry check activates or raises it. -`typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses its modifiers followed by the requested keys in order, then releases all pressed keys in reverse order, including on cancellation. Keyboard events go directly to the target PID and bypass the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. +`typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses the listed modifiers followed by one key, then releases all pressed keys in reverse order, including on cancellation. Keyboard events go directly to the target PID and bypass the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. The Host associates directional key events with one `run_code` execution. On down, the helper retains the selected application, recipient PID and process start time, physical key code, and matching up event. Explicit up and cleanup verify the original process identity. On return, error, or user pause, the Host waits for any in-flight native request and asks the helper to release only that execution’s remaining keys. Disconnect cleanup also releases keys owned by the closed connection. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 73b7754b3a..1b0c3f406f 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -103,7 +103,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 截图和鼠标输入在精确窗口的 AX 边界查询前后读取 CG 边界,查询期间移动会返回 WINDOW_CHANGED。有效的 AX/CG 边界向外取整后必须一致;已知不一致会在移动、预备事件或按键发送前返回 WINDOW_CHANGED。helper 保留两个坐标来源,不用 AX 几何替换 CG 几何。缺失或无效的 AX 几何仍表示未知,AX 通信、权限和无效元素错误继续传递。无辅助功能权限的截图保留 CG/SCK 检查。声明的 AX 动作不经过鼠标预检,AX 动作失败也不改用鼠标输入。这些读取不会锁住窗口以阻止后续移动,几何检查不激活或抬起窗口。 -`typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和指定按键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。键盘事件直接发给目标进程,绕过全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 +`typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和一个主键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。键盘事件直接发给目标进程,绕过全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 宿主为每次 `run_code` 的 `sendKeyEvent` 附上执行标识。按下时,helper 保存已选应用、接收进程的 PID 与启动时间、物理键码及对应的松开事件。显式松键或执行结束时会核对原进程身份。正常返回、异常退出或用户暂停时,宿主等待在途原生请求结束,再通知 helper 释放该执行持有的其余按键。连接断开时,helper 也会清理该连接持有的按键。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index 80d820d0f1..1ebde9ee41 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -345,7 +345,7 @@ actor AXWorker { let recipient = try ax().keyboardRecipient(identity, catalog: catalog) try NativePlatform.verify(recipient) if method == "typeText" { try inputs().0.typeText(params.string("text"), to: recipient.pid) } - else { try inputs().0.pressKey(params.pressKeys(), modifiers: params.modifiers(), to: recipient.pid) } + else { try inputs().0.pressKey(params.string("key"), modifiers: params.modifiers(), to: recipient.pid) } case "setValue": try ax().setAttribute(element(handle, params), "AXValue", params.values["value"]) case "performAction": try ax().performAction(element(handle, params), action: params.string("action"), argument: params.values["argument"]) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift index 91572fbd5f..1a7ca086ba 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift @@ -43,30 +43,15 @@ final class KeyboardInput { } /// Prepare every event before dispatch. On cancellation, release only the keys this call pressed. - func pressKey(_ keys: [String], modifiers: [String], to pid: Int32) throws { + func pressKey(_ name: String, modifiers: [String], to pid: Int32) throws { try checkCancellation() var chord = try modifiers.map(Self.modifier) - var namesByCode: [CGKeyCode: String] = [:] - for name in keys { - let key: Key - if name == "+" || name.lowercased() == "plus" { - guard keys.count == 1 else { - throw RequestParameters.invalid("For a multi-key chord, use = with an explicit shift modifier instead of \(name)") - } - chord.append(try Self.modifier("shift")) - key = Key(keyCode: try Self.keyCode("="), flag: []) - } else if let modifier = Self.namedModifier(name) { - key = modifier - } else { - key = Key(keyCode: try Self.keyCode(name), flag: []) - } - if let previous = namesByCode.updateValue(name, forKey: key.keyCode) { - throw RequestParameters.invalid("Keys '\(previous)' and '\(name)' name the same physical key") - } - chord.append(key) - } + let isPlus = name == "+" || name.lowercased() == "plus" + if isPlus { chord.append(try Self.modifier("shift")) } + let code = try Self.keyCode(isPlus ? "=" : name) + chord.append(Key(keyCode: code, flag: Self.namedModifier(name)?.flag ?? [])) let alreadyHeld = held.snapshot(pid: pid) - guard namesByCode.keys.allSatisfy({ alreadyHeld[$0] == nil }) else { + guard alreadyHeld[code] == nil else { throw RequestParameters.invalid("Release the held key before using it in pressKey") } var selected = Set(alreadyHeld.keys) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift index 6b69122d99..e3d64894c1 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestAdmission.swift @@ -36,13 +36,6 @@ struct RequestParameters: Sendable { return path } } - func pressKeys() throws -> [String] { - switch values["key"] { - case .string(let name) where !name.isEmpty: return [name] - case .array(let items) where !items.isEmpty: return try strings("key") - default: throw Self.invalid("key must be a nonempty string or a nonempty array of key names") - } - } func optionalString(_ key: String) throws -> String? { contains(key) ? try string(key) : nil } func optionalBoolean(_ key: String) throws -> Bool? { guard let raw = values[key] else { return nil } diff --git a/packages/experimental/cua-macos/native/generate-protocol.ts b/packages/experimental/cua-macos/native/generate-protocol.ts index 29ccc58344..6dafb10d01 100644 --- a/packages/experimental/cua-macos/native/generate-protocol.ts +++ b/packages/experimental/cua-macos/native/generate-protocol.ts @@ -13,7 +13,9 @@ export function renderNativeProtocol(): { json: string; swift: string } { sendKeyEvent: z.object({ runId: z.string().min(1) }), mouseMove: z.object({ allowSystemPointer: z.boolean().optional() }), requestPermission: PermissionGuideParameters, openPermissionSettings: PermissionSettingsParameters, - }) + }, [z.object({ + method: z.literal('releaseHeldKeys'), params: z.object({ runId: z.string().min(1) }).strict(), + }).strict()]) const quoted = (values: Iterable) => [...values].sort().map(value => JSON.stringify(value)).join(', ') const swift = [ '// Generated by native/generate-protocol.ts from @deepseek-ai/dsh-cua/native-schema. Do not edit.', diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index f8a9824752..5c347ae54c 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -993,21 +993,8 @@ "maximum": 4294967295 }, "key": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "minItems": 1, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - } - ], - "description": "One key name or distinct physical keys to press in order and release in reverse." + "type": "string", + "minLength": 1 }, "modifiers": { "type": "array", @@ -1309,37 +1296,6 @@ ], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "method": { - "type": "string", - "const": "releaseHeldKeys" - }, - "params": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "runId" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "method", - "params" - ], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -1500,6 +1456,37 @@ "params" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string", + "const": "releaseHeldKeys" + }, + "params": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "runId" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "method", + "params" + ], + "additionalProperties": false } ] } diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 6649b82dd9..86f5cdef80 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -125,10 +125,12 @@ def check_clicks(helper, window, point, state_file): def check_keyboard(call, owner, state_file, state): count = len(state["events"]) - call("pressKey", {**owner, "key": ["w", "d"]}) - state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) - chord = [(event["type"], event.get("keyCode")) for event in state["events"][count:]] - assert chord == [(10, 13), (10, 2), (11, 2), (11, 13)], chord + call("pressKey", {**owner, "key": ["w", "d"]}, "INVALID_ARGUMENT") + call("pressKey", {**owner, "key": "d", "modifiers": ["w"]}, "INVALID_ARGUMENT") + call("pressKey", {**owner, "key": "d", "modifiers": ["shift"]}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 2) + shortcut = [(event["type"], event.get("keyCode"), event["shift"]) for event in state["events"][count:]] + assert shortcut == [(10, 2, True), (11, 2, True)], shortcut count = len(state["events"]) def key(name, direction): @@ -189,7 +191,7 @@ def main(): pid = wait_for_state(state_file, lambda value: value["windowId"] > 0)["pid"] if args.keyboard_only: check_keyboard(call, owner, state_file, json.loads(state_file.read_text())) - print("PASS: W+D short press, two-second hold, independent D up and remaining W cleanup") + print("PASS: Shift+D shortcut, two-second W+D hold, independent D up and remaining W cleanup") return if not args.click_only: call("activate", owner) diff --git a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift index 52cae6911e..f81b10d1da 100644 --- a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift @@ -4,49 +4,43 @@ import Darwin import Testing @testable import CuaNative -@Test(arguments: [ - (["w", "d"], ["shift"], [56, 13, 2, 2, 13, 56]), - (["shift", "w", "d"], [], [56, 13, 2, 2, 13, 56]), - (["a", "="], ["shift"], [56, 0, 24, 24, 0, 56]), -]) -func chordKeepsShiftThroughBothKeyReleases(_ keys: [String], _ modifiers: [String], _ codes: [Int64]) throws { +@Test func shortcutHoldsCommandAndShiftAroundOneKey() throws { var events: [CGEvent] = [] let input = KeyboardInput(post: { event, pid in #expect(pid == 42) events.append(event) }, textEventInterval: 0) - try input.pressKey(keys, modifiers: modifiers, to: 42) - #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == codes) - #expect(events.map(\.type) == [.flagsChanged, .keyDown, .keyDown, .keyUp, .keyUp, .flagsChanged]) - #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, true, true, false]) + try input.pressKey("s", modifiers: ["cmd", "shift"], to: 42) + #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [55, 56, 1, 1, 56, 55]) + #expect(events.map(\.type) == [.flagsChanged, .flagsChanged, .keyDown, .keyUp, .flagsChanged, .flagsChanged]) + #expect(events.map { $0.flags.contains(.maskShift) } == [false, true, true, true, false, false]) + #expect(events.map { $0.flags.contains(.maskCommand) } == [true, true, true, true, true, false]) } @Test(arguments: [1, 2]) -func cancelledChordReleasesOnlyPressedKeys(_ cancelAfter: Int) { +func cancelledShortcutReleasesOnlyPressedKeys(_ cancelAfter: Int) { let cancellation = CancellationLatch() var events: [CGEvent] = [] let input = KeyboardInput(post: { event, _ in events.append(event) if events.count == cancelAfter { cancellation.cancel() } }, textEventInterval: 0, checkCancellation: cancellation.check) - #expect(throws: NativeFailure.self) { try input.pressKey(["w", "d"], modifiers: [], to: 42) } - let held = [Int64(13), 2].prefix(cancelAfter) + #expect(throws: NativeFailure.self) { try input.pressKey("d", modifiers: ["shift"], to: 42) } + let held = [Int64(56), 2].prefix(cancelAfter) #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == Array(held) + held.reversed()) - #expect(events.map(\.type) == Array(repeating: .keyDown, count: cancelAfter) + Array(repeating: .keyUp, count: cancelAfter)) + let types: [CGEventType] = cancelAfter == 1 ? [.flagsChanged, .flagsChanged] : [.flagsChanged, .keyDown, .keyUp, .flagsChanged] + #expect(events.map(\.type) == types) } @Test(arguments: [ - (["w", "unknown"], "Unknown key name: unknown"), - (["w", "W"], "Keys 'w' and 'W' name the same physical key"), - (["Enter", "Return"], "Keys 'Enter' and 'Return' name the same physical key"), - (["a", "+"], "For a multi-key chord, use = with an explicit shift modifier instead of +"), - (["Plus", "w"], "For a multi-key chord, use = with an explicit shift modifier instead of Plus"), + ("unknown", ["shift"], "Unknown key name: unknown"), + ("w", ["d"], "Unsupported key modifier: d"), ]) -func invalidChordPostsNoInput(_ keys: [String], _ message: String) throws { - let input = KeyboardInput(post: { _, _ in Issue.record("Invalid chord posted input") }, textEventInterval: 0) +func invalidShortcutPostsNoInput(_ key: String, _ modifiers: [String], _ message: String) throws { + let input = KeyboardInput(post: { _, _ in Issue.record("Invalid shortcut posted input") }, textEventInterval: 0) do { - try input.pressKey(keys, modifiers: ["shift"], to: 42) - Issue.record("Invalid chord was accepted") + try input.pressKey(key, modifiers: modifiers, to: 42) + Issue.record("Invalid shortcut was accepted") } catch let error as NativeFailure { #expect(error.code == "INVALID_ARGUMENT") #expect(error.message == message) @@ -57,7 +51,7 @@ func invalidChordPostsNoInput(_ keys: [String], _ message: String) throws { func plusAlonePressesShiftEquals(_ alias: String) throws { var events: [CGEvent] = [] let input = KeyboardInput(post: { event, _ in events.append(event) }, textEventInterval: 0) - try input.pressKey([alias], modifiers: [], to: 42) + try input.pressKey(alias, modifiers: [], to: 42) #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [56, 24, 24, 56]) #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, false]) } @@ -65,7 +59,7 @@ func plusAlonePressesShiftEquals(_ alias: String) throws { @Test func standaloneShiftSetsItsFlagOnlyWhileHeld() throws { var events: [CGEvent] = [] let input = KeyboardInput(post: { event, _ in events.append(event) }, textEventInterval: 0) - try input.pressKey(["shift"], modifiers: [], to: 42) + try input.pressKey("shift", modifiers: [], to: 42) #expect(events.map(\.type) == [.flagsChanged, .flagsChanged]) #expect(events.map { $0.flags.contains(.maskShift) } == [true, false]) } @@ -82,8 +76,8 @@ func plusAlonePressesShiftEquals(_ alias: String) throws { try input.keyDown("w", app: "editor", run: "two", recipient: target) #expect(throws: NativeFailure.self) { try input.keyDown("W", app: "editor", run: "one", recipient: target) } #expect(throws: NativeFailure.self) { try input.keyUp("w", app: "other", run: "two") } - #expect(throws: NativeFailure.self) { try input.pressKey(["w"], modifiers: [], to: target.pid) } - try input.pressKey(["d"], modifiers: ["shift"], to: target.pid) + #expect(throws: NativeFailure.self) { try input.pressKey("w", modifiers: [], to: target.pid) } + try input.pressKey("d", modifiers: ["shift"], to: target.pid) try input.releaseHeldKeys(run: "one") #expect(try input.keyUp("w", app: "editor", run: "two")) try input.releaseHeldKeys(run: "two") diff --git a/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift b/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift index 85fa37aaf0..84a9fec849 100644 --- a/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/ProtocolTests.swift @@ -138,15 +138,3 @@ func rejectsMalformedApplicationIconPaths(_ value: String) throws { let request = try NativeRequest(data: Data("{\"id\":\"icons\",\"method\":\"applicationIcons\",\"params\":{\"paths\":\(value)}}".utf8)) #expect(throws: NativeFailure.self) { try RequestParameters(request).strings("paths") } } - -@Test func invalidPressKeyTypeNamesBothAcceptedForms() throws { - let request = try NativeRequest(data: Data(#"{"id":"call","method":"pressKey","params":{"key":7}}"#.utf8)) - let params = try RequestParameters(request) - do { - _ = try params.pressKeys() - Issue.record("Numeric key was admitted") - } catch let error as NativeFailure { - #expect(error.code == "INVALID_ARGUMENT") - #expect(error.message == "key must be a nonempty string or a nonempty array of key names") - } -} diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 2fe07ef025..6b2f073924 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -13,8 +13,8 @@ const methodDetails = new Map([ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { - if (method === 'pressKey') return `- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` + if (method === 'pressKey') return `${entry} +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index 54322e2316..542dbe74bb 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -31,9 +31,8 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(GUIDANCE).toContain("sendKeyEvent(key: string, direction: 'down' | 'up')") expect(GUIDANCE).toContain('returns, throws, or the user pauses') expect(COMMON_GUIDANCE).not.toContain('sendKeyEvent(') - expect(pressKey).toContain('key: string | string[]') - expect(pressKey).toContain("pressKey(['w', 'd']) presses W then D and releases D then W") - expect(pressKey).toContain("Packed strings such as 'w+d'") + expect(pressKey).toBe(COMMON_GUIDANCE.split('\n').find(line => line.startsWith('- pressKey('))) + expect(GUIDANCE).toContain('W down, D down, D up, W up') const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove(')) for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'systemPointerHover']) expect(hover).toContain(term) const click = GUIDANCE.split('\n').find(line => line.startsWith('- click(')) diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index aefc6fbee6..78135ed0c2 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -21,6 +21,13 @@ describe('native protocol generation', () => { expect(validate({ id: 'invalid', method: 'setValue', params: target })).toBe(false) }) + it('rejects a key array at both model and native pressKey admission', () => { + const request = { method: 'pressKey', params: { appHandle: 'app', key: ['w', 'd'] } } + expect(Request.safeParse(request).success).toBe(false) + const validate = new Ajv2020().compile(JSON.parse(renderNativeProtocol().json)) + expect(validate({ id: 'shortcut', ...request })).toBe(false) + }) + it.each([ { method: 'getState', params: { appHandle: 'app', maxElements: 1 } }, { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, @@ -28,6 +35,7 @@ describe('native protocol generation', () => { { method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } }, { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, allowSystemPointer: true } }, { method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } }, + { method: 'releaseHeldKeys', params: { runId: 'execution' } }, { method: 'launchApp', params: { appHandle: 'pending' } }, { method: 'applicationIcons', params: { paths: ['/Applications/Fixture.app'] } }, { method: 'permissionStatus', params: {} }, diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 8587d8c7d5..f4abb13c43 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -21,8 +21,8 @@ en: d92d5948a7941e8d zh: d43491ae6521b87e /deepseek-ai-dsh-cua/understand-the-implementation: - en: 181158347d09c629 - zh: 231b2dcaf826d386 + en: 2ab74a1ff120459e + zh: 39b3ef198989f133 /deepseek-ai-dsh-cua/further-exploration: en: c174fb5a00ea3681 zh: 039340042676881d diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 9fb8f64eb7..c95e4ae8ec 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -91,7 +91,7 @@ One object facade ([src/api.ts](src/api.ts)) builds a shared `CuaSurface` and de Provider startup capabilities are fixed at installation: platform, methods, app-level accessibility, keyboard scopes, browser support, and coordinate space. The native `getCapabilities` RPC reports runtime permissions and session lock only; `installCua` merges both layers for the model-visible result. The raw RPC remains a common operation representation rather than a capability inventory, so provider guidance states the actions its platform supports. -Check `methods` and `keyboard` in the result of `await cua.getCapabilities()` for the supported operations and input scopes. `pressKey(key, modifiers?)` holds the listed modifiers while pressing the requested keys in order and releasing them in reverse. For example, `pressKey(['w', 'd'])` holds W through the D keystroke. `sendKeyEvent(key, direction)` sends exactly one key-down or key-up. `typeText` and `paste` deliver literal text at the provider's declared keyboard scope. Keyboard modifier names are plain strings that the provider interprets. +Check `methods` and `keyboard` in the result of `await cua.getCapabilities()` for the supported operations and input scopes. `pressKey(key, modifiers?)` presses and releases one key while holding the listed modifiers; `sendKeyEvent(key, direction)` sends exactly one key-down or key-up. `typeText` and `paste` deliver literal text at the provider's declared keyboard scope. Keyboard modifier names are plain strings that the provider interprets. Platform providers import `CuaConfigFields` from `@deepseek-ai/dsh-cua/config` to extend the shared configuration. The root Host entry exports `installCua`, `CuaConfig`, and the Typert-discovered `CuaAccess` service; its `apply()` only marks the optional Client contribution. `/client` loads the permission UI through the existing module loader. Separate Host and Client compiler projects keep React, CSS, and browser registrations out of headless imports. `/api` owns the app/window API and its types; `/bridge`, `/native-client`, and `/protocol` retain runtime and platform consumers. `/types` exports only the named types required by generated Remote declarations. The Client uses package-local types. `/typert` supplies Host reflection and `/remote` supplies generated browser descriptors and codecs; permission authority remains on the Host. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index 2f8e31b77c..c1c5ed863c 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -91,7 +91,7 @@ Computer Use 详情页的权限卡片、组件列表和来源信息使用 l3 边 提供方启动能力在安装时固定:平台、方法、应用级辅助功能、键盘作用域、浏览器支持和坐标空间。原生 `getCapabilities` RPC 只报告运行时权限和会话锁定状态;`installCua` 为模型可见结果合并两层。原生 RPC 仍是通用操作表示,而不是能力清单,因此 provider 指引说明其平台支持的动作。 -通过 `await cua.getCapabilities()` 返回的 `methods` 和 `keyboard` 确认支持的操作和输入范围。`pressKey(key, modifiers?)` 按住所列修饰键,依次按下指定的键,再逆序释放。例如,`pressKey(['w', 'd'])` 在按下和松开 D 期间持续按住 W。`sendKeyEvent(key, direction)` 只发送一次按下或抬起。`typeText` 和 `paste` 在 provider 声明的键盘作用域内传送字面文本。键盘修饰键名称是 provider 解释的普通字符串。 +通过 `await cua.getCapabilities()` 返回的 `methods` 和 `keyboard` 确认支持的操作和输入范围。`pressKey(key, modifiers?)` 在按住所列修饰键时按下并释放一个键;`sendKeyEvent(key, direction)` 只发送一次按下或抬起。`typeText` 和 `paste` 在 provider 声明的键盘作用域内传送字面文本。键盘修饰键名称是 provider 解释的普通字符串。 平台提供方从 `@deepseek-ai/dsh-cua/config` 导入 `CuaConfigFields` 来扩展共享配置。根 Host 入口导出 `installCua`、`CuaConfig` 和供 Typert 发现的 `CuaAccess` 服务,`apply()` 只标记可选 Client 贡献。`/client` 通过现有模块加载器加载权限界面。Host 和 Client 使用独立编译项目,headless 导入不会加载 React、CSS 或浏览器注册。`/api` 提供应用、窗口 API 及其类型;`/bridge`、`/native-client` 和 `/protocol` 保留运行时和平台消费者。`/types` 仅导出生成 Remote 声明所需的命名类型,Client 使用包内类型。`/typert` 提供 Host 反射,`/remote` 提供浏览器描述和编解码,权限判断仍由 Host 执行。 diff --git a/packages/experimental/cua/src/native-schema.ts b/packages/experimental/cua/src/native-schema.ts index a6122ed71e..5bb061383f 100644 --- a/packages/experimental/cua/src/native-schema.ts +++ b/packages/experimental/cua/src/native-schema.ts @@ -14,15 +14,19 @@ function variants(schema: Schema): Schema[] { return schema.anyOf ?? schema.oneO /** * Derive native request schemas and admission fields from the shared operation definitions. * @param platformFields Additional native protocol fields accepted by the provider. + * @param privateOperations Additional provider-only request schemas. * @returns Native field and enum tables and the complete wire schema. */ -export function nativeProtocol(platformFields: Partial['method'] | 'requestPermission' | 'openPermissionSettings', z.ZodObject>> = {}): { +export function nativeProtocol( + platformFields: Partial['method'] | 'requestPermission' | 'openPermissionSettings', z.ZodObject>> = {}, + privateOperations: readonly z.ZodObject[] = [], +): { fields: Map> enums: Map>> json: string } { const resources = new Map(Object.entries({ ...NativeResourceParameters, ...platformFields })) - const schemas = [...Request.options, ...NativeOnlyOperations] + const schemas = [...Request.options, ...NativeOnlyOperations, ...privateOperations] .map(schema => z.toJSONSchema(schema)) const fields = new Map>() const enums = new Map>>() diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index e4de9f4734..538c95cfe6 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -62,10 +62,7 @@ export const Request = z.discriminatedUnion('method', [ selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), - operation('pressKey', { ...app, windowId: id.optional(), - key: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]) - .describe('One key name or distinct physical keys to press in order and release in reverse.'), - modifiers: z.array(z.string()).optional() }), + operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }), operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), z.object({ method: z.literal('scroll'), params: z.union([ z.object({ ...absoluteCoordinate, @@ -93,7 +90,7 @@ export const NativeResourceParameters = { } /** Host-only launch continuation and user-facing system permission controls; absent from model arguments. */ export const NativeOnlyOperations = [ - operation('launchApp', app), operation('releaseHeldKeys', { runId: z.string().min(1) }), operation('permissionStatus', {}), + operation('launchApp', app), operation('permissionStatus', {}), operation('applicationIcons', { paths: z.array(z.string().min(1)) }), operation('requestPermission', { permission: z.enum(['accessibility', 'screenRecording']) }), operation('openPermissionSettings', { permission: z.enum(['accessibility', 'screenRecording']) }), diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index ca34f16e2c..b84663f9e2 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -160,16 +160,16 @@ export interface CuaSurface { */ paste(text: string, options?: CuaPasteOptions): Promise /** - * Press the keys in order at the current focus, then release them in reverse order. + * Press and release one key at the current input focus. * Rejects when the provider does not support keyboard input at this scope. - * @param key - One provider-defined key name or a nonempty array of distinct physical keys to hold together. + * @param key - Provider-defined key name to press and release; not a chord. * @param modifiers - Platform- or provider-defined modifier names held for this keystroke; omitted means none. * @returns Whether the provider successfully executed the requested operation. */ - pressKey(key: string | string[], modifiers?: string[]): Promise + pressKey(key: string, modifiers?: string[]): Promise /** * Send one key-down or key-up event at the current input focus. - * On macOS, down persists within one run_code until explicit up, program completion (including errors), or user pause. + * Does not send the opposite event. * Rejects when the provider does not support keyboard input at this scope. * @param key - Platform-defined key name; modifier keys may themselves be targets. * @param direction - Explicit event direction; the opposite event is never sent implicitly. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index f5b3452dc3..cbaa419951 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -683,8 +683,6 @@ describe('input actions', () => { ['fractional scroll', ({ window }) => window.scroll({ x: 0, y: 0, deltaLinesX: 0.5, deltaLinesY: 0 })], ['negative drag point', ({ window }) => window.drag([0, 0], [-1, 3])], ['empty key', ({ app }) => app.pressKey('')], - ['empty chord', ({ app }) => app.pressKey([])], - ['empty chord member', ({ app }) => app.pressKey(['w', ''])], ] it.each<[string, (surfaces: Surfaces) => Promise]>([ ['getState windowId: Too small: expected number to be >0', ({ app }) => app.getWindow(0)], @@ -710,8 +708,7 @@ describe('input actions', () => { expect(f.dispatch).toHaveBeenCalledTimes(calls) }) - it.each<[string | string[], string[] | undefined, string[]]>([ - [['w', 'd'], undefined, []], + it.each<[string, string[] | undefined, string[]]>([ ['+', undefined, []], ['plus', [], []], ['+', ['command'], ['command']], diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index c3d69e309c..50fb329c07 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -12,8 +12,6 @@ const rejectedRequests: Array<{ reason: string; request: JsonValue }> = [ { reason: 'numeric element id', request: { method: 'click', params: { appHandle: 'app', snapshotId: 'snapshot', elementId: 0 } } }, { reason: 'coordinate click without a window', request: { method: 'click', params: { appHandle: 'app', x: 1, y: 2 } } }, { reason: 'mixed element and coordinate click', request: { method: 'click', params: { appHandle: 'app', snapshotId: 'snapshot', elementId: '0', windowId: 23, x: 1, y: 2 } } }, - { reason: 'empty pressKey chord', request: { method: 'pressKey', params: { appHandle: 'app', key: [] } } }, - { reason: 'non-string pressKey chord member', request: { method: 'pressKey', params: { appHandle: 'app', key: ['w', 7] } } }, { reason: 'non-string pressKey modifier', request: { method: 'pressKey', params: { appHandle: 'app', key: 'a', modifiers: ['command', 7] } } }, { reason: 'sendKeyEvent modifiers', request: { method: 'sendKeyEvent', params: { appHandle: 'app', key: 'a', direction: 'down', modifiers: [] } } }, { reason: 'sendKeyEvent without direction', request: { method: 'sendKeyEvent', params: { appHandle: 'app', key: 'a' } } }, @@ -25,11 +23,6 @@ it.each(rejectedRequests)('rejects $reason at operation admission', ({ request } expect(Request.safeParse(request).success).toBe(false) }) -it('admits an ordered chord in a raw pressKey request', () => { - const request = { method: 'pressKey', params: { appHandle: 'app', key: ['w', 'd'] } } - expect(Request.parse(request)).toEqual(request) -}) - it('admits arbitrary provider-defined pressKey modifier names', () => { const request = { method: 'pressKey', params: { appHandle: 'app', key: 'PlatformKey', modifiers: ['platform-modifier', 'shift'] } } expect(Request.parse(request)).toEqual(request) diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 82210fa306..e8deb643fb 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -78,8 +78,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 3a94712d06..33f3636750 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -1032,21 +1032,8 @@ "maximum": 4294967295 }, "key": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "minItems": 1, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - } - ], - "description": "One key name or distinct physical keys to press in order and release in reverse." + "type": "string", + "minLength": 1 }, "modifiers": { "type": "array", diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 3acaa30b88..5322aa44d7 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -80,8 +80,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index dc18435318..4c5b91329b 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -80,8 +80,8 @@ A string target is an element id; coordinate targets are [x, y] and are pixels i Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. -- pressKey(key: string | string[], modifiers?: string[]) -> ActionResult — use for a short press. pressKey(['w', 'd']) presses W then D and releases D then W. Each name identifies one physical key. Packed strings such as 'w+d', unknown names and duplicate physical keys are rejected before input. Pass held modifiers separately, as in pressKey('Tab', ['shift']). '+' and 'plus' mean Shift+= when used alone. In a multi-key chord use '=' and pass 'shift' explicitly if needed. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once to hold a key across tool calls within one run_code. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From e2237f26b94ebe1e3412f32fa9ec373c8f8dae10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 14:23:33 +0800 Subject: [PATCH 076/149] feat(forge): show each changed file as a card A file's trailing collapsed-context strip, the next file's header, and that file's leading strip read as three adjacent rows, so the boundary between files was hard to see. Each file is now a card with an R16 border and a gap before the next one, and its header takes a tint over the view background. The header still sticks to the top of the scroll area while its lines scroll, flush with the edge rather than inside the list's padding. The collapsed-context strips keep their style. Resolves #8104 --- .../client-ui-forge/README.i18n.yaml | 4 +-- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangesTab.module.css | 32 ++++++++++++++++--- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5b25741184..d8c84dfd57 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 05ee6d9ba0b8d2c7 - zh: 98c988e8f957511f + en: 8d017c771840d6de + zh: 0705332c947f825a /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 7495fe1e0d0ee643 zh: f820a5790ae6e2a7 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 27ff8a01c3..67966cf983 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -38,7 +38,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color, with its head branch end filled in the color of running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll. - **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. Cancelled checks have a separate count; a collapsed card names cancellations when no listed check failed. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, whose count ends in `+` when the provider counted only some threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. - **Local changes card** — whenever the `branch` scope changes files, independently of bound change requests, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, which is always the default base below, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered when the code host repository is recognized and no open change request (including drafts) has the checked-out branch as its head, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. -- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. +- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 4aca1d2c38..0bdfdea790 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" - **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,head 分支端点填充为检查进行中、已通过或失败对应的颜色;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 - **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。已取消的检查单独计数;列出的检查没有失败项时,折叠卡片显示取消数量。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数(提供方只统计了部分讨论时数字后带 `+`)、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 - **本地变更卡片**——`branch` 范围有文件变更时始终显示,不受已关联变更请求影响。变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`,始终是下文的默认基准)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库、且没有以当前检出分支为源分支的打开中变更请求(含草稿)时,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 -- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 +- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。每个文件是一张独立的卡片,带浅色底的标题在滚动时停在顶部。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index f664c4b2fa..ae8a40f3f8 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -346,9 +346,12 @@ } .files { + --forge-files-inset: 10px; flex: 1; + box-sizing: border-box; min-width: 0; min-height: 0; + padding: var(--forge-files-inset); overflow: auto; } @@ -377,14 +380,34 @@ text-align: center; } +/* Each file is one card, so a file's last collapsed-context strip and the next file's header no longer read as adjacent + rows. Diffs share R16; clip, unlike hidden, keeps the card from becoming the sticky header's scroll container. */ .file { - border-bottom: 0.5px solid var(--dsw-alias-border-l3); + overflow: clip; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-lg); + background: var(--forge-changes-bg); } -/* Above the comment plus (z-index 1) of rows scrolled beneath it. */ +.file + .file { + margin-top: 12px; +} + +@container forge-changes (max-width: 380px) { + .files { + --forge-files-inset: 6px; + } + + .file + .file { + margin-top: 8px; + } +} + +/* Above the comment plus (z-index 1) of rows scrolled beneath it. Sticky offsets start inside the list's padding, + so the negative inset keeps a stuck header flush with the scroll area's top edge. */ .fileHeader { position: sticky; - top: 0; + top: calc(-1 * var(--forge-files-inset, 0px)); z-index: 2; display: flex; align-items: center; @@ -393,7 +416,8 @@ min-height: 36px; padding: 0 4px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); - background: var(--forge-changes-bg); + /* The hover tint over the view background stays opaque while code scrolls beneath the sticky header. */ + background: linear-gradient(var(--dsw-alias-interactive-bg-hover), var(--dsw-alias-interactive-bg-hover)), var(--forge-changes-bg); } /* A bare chevron: its rotation shows whether the file is expanded, so it takes no fill in any state. */ From 946cc8d29820de59b964897789e9f0b8d145b347 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:26:21 +0800 Subject: [PATCH 077/149] feat(cua): ask before moving the system pointer for a session --- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../cua-hover-ax-only/guide.i18n.yaml | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.md | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md | 4 +- .../experimental/cua-macos/README.i18n.yaml | 8 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../native/Sources/CuaNative/AXHover.swift | 7 -- .../native/Sources/CuaNative/AXWorker.swift | 29 ++++---- .../CuaNative/NativeProtocol.generated.swift | 2 +- .../CuaNative/RequestCoordinator.swift | 3 +- .../native/Sources/CuaNative/Runtime.swift | 7 +- .../cua-macos/native/generate-protocol.ts | 2 +- .../cua-macos/native/protocol.schema.json | 4 +- .../cua-macos/native/tests/e2e.py | 4 +- .../cua-macos/native/tests/electron_e2e.py | 6 +- .../tests/swift/SharedHelperTests.swift | 10 +-- .../native/tests/swift/SystemHoverTests.swift | 14 ---- .../experimental/cua-macos/src/guidance.ts | 2 +- packages/experimental/cua-macos/src/index.ts | 9 +-- .../cua-macos/src/shared-native.ts | 10 +-- .../cua-macos/tests/guidance.spec.ts | 10 +-- .../tests/protocol-generation.spec.ts | 2 +- .../cua-macos/tests/provider-channel.spec.ts | 6 +- .../tests/provider-lifecycle.spec.ts | 73 ++++++++++++++++++- .../cua-macos/tests/shared-native.spec.ts | 10 +-- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/src/api.ts | 5 +- .../cua/src/client/ApplicationApproval.tsx | 10 +-- packages/experimental/cua/src/guidance.ts | 1 + packages/experimental/cua/src/index.ts | 9 ++- packages/experimental/cua/src/locales.ts | 25 +++++++ .../experimental/cua/src/mouse-control.ts | 56 ++++++++++++++ .../experimental/cua/src/native-schema.ts | 4 +- packages/experimental/cua/src/protocol.ts | 9 ++- packages/experimental/cua/src/types.ts | 5 ++ packages/experimental/cua/tests/api.spec.ts | 8 ++ .../application-approval.client.spec.tsx | 14 ++++ .../session/cua-native/replay.override.json | 66 +++++++++++++++++ .../cua-native/workspace/native-helper.mjs | 14 ++-- 46 files changed, 346 insertions(+), 146 deletions(-) create mode 100644 packages/experimental/cua/src/mouse-control.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dede6981f9..ed075c348a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1144,15 +1144,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:32`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9c142b9b43..f01692f549 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1146,15 +1146,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:32`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml index 9a48f7428c..cbce06f055 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 55c3070b24b4ad39 zh: 2555d127b0f4e4a0 /macos-hover-keeps-the-system-pointer-in-place-by-default/migration: - en: 1f0ab7e8741e821f - zh: f59adc3a23ebcb43 + en: bf3f886ad344f12a + zh: c877a616d4e147fb diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md index f7191cc254..a7d03dc869 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md @@ -14,5 +14,5 @@ On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that adver ## Migration 1. For an available control, use its advertised action or click it directly. Use browser tools for web content that requires page hover. -2. To restore visible pointer movement, set `systemPointerHover: true` in the `config` of the macOS provider entry (`id: cua`) in your profile's `cordis.patch.yml`. Preserve the entry's other settings. If you set `helperPath`, rebuild the custom helper so it accepts `systemPointerHover` in the connection handshake. Restart the profile. -3. Call `win.mouseMove` on a foreground, unobscured control without AX alternate UI. The opt-in result reports `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. Keep the default to avoid disturbing the user's cursor. +2. Remove `systemPointerHover` from the macOS provider entry in your profile’s `cordis.patch.yml`. If you set `helperPath`, rebuild that helper to support the per-request `useSystemPointer` field. Restart the profile. +3. When the task requires a physical hover, call `cua.requestMouseControl()`. After the user approves, `win.mouseMove` on an unobscured foreground target returns `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. This grant expires when DSH restarts. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md index 713ae43fb3..17064261b3 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md @@ -14,5 +14,5 @@ description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否 ## 迁移 1. 对已经可用的控件,可执行它声明的动作或直接点击。需要网页悬停时可用浏览器工具。 -2. 要恢复可见光标移动,在 profile 的 `cordis.patch.yml` 中,给 macOS 提供方条目(`id: cua`)的 `config` 添加 `systemPointerHover: true`。保留该条目的其他设置。如果指定了 `helperPath`,先重新构建自定义 helper,使其在建立连接时能读取 `systemPointerHover`,然后重启 profile。 -3. 对前台未受遮挡且不提供 AX 备用界面的控件调用 `win.mouseMove`。启用配置时,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。要保留用户的光标位置,使用默认配置。 +2. 从 profile 的 `cordis.patch.yml` 中移除 macOS 提供方配置项 `systemPointerHover`。如果设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 +3. 任务确实需要移动鼠标时,调用 `cua.requestMouseControl()`。用户同意后,对前台未受遮挡的目标调用 `win.mouseMove`,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。重启 DSH 后授权失效。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 0d83c6e538..25ab23c779 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,11 +15,11 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: 533ed4dd11af5126 - zh: 03036e8c949d6aca + en: 7fdb7c0462275417 + zh: 0a4a9562f4a82945 /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: - en: b734a0907747580e - zh: 8110c5951b70e100 + en: 2391a460e295cfca + zh: 1889a903efb6142d /deepseek-ai-dsh-experimental-cua-macos/understand-the-implementation: en: 4e82b85a5aa72eee zh: 4e004b7a205d8688 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 46b035112e..3190f8561d 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -64,7 +64,6 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates | `buildTimeoutMs` | `300000` | Maximum native preparation time. | | `timeoutMs` | `30000` | Per-operation deadline; cancellation can leave partial input. | | `connectTimeoutMs` | `5000` | Deadline for connecting to or starting the shared helper. | -| `systemPointerHover` | `false` | Allow `mouseMove` to move the visible cursor when AX hover is unavailable. | | `permissionPollIntervalMs` | `1000` | Interval in milliseconds between permission and position checks while the native guide is open. | | `screenshotTimeoutMs` | `5000` | Screenshot deadline including queue time, capped by `timeoutMs`. | | `axTimeoutMs` | `3000` | Deadline per Accessibility system call, configured independently of the complete operation. | @@ -115,7 +114,7 @@ Use the `cua` API to discover and select the application (`listApps`, `getState` `getWindow` reads its initial tree silently. An owned window without an AX surface returns an empty snapshot and remains available for screenshots and coordinate input. Within one API instance, the facade keeps one current accessibility snapshot per application handle, shared by every app and window object for that handle, including repeated selections. Reading any window replaces that snapshot; element operations use the latest snapshot ID, so use IDs from the latest tree and do not assume an older ID still identifies the same control. A failed read publishes no partial snapshot; read again without repeating the preceding input. Window objects own screenshots and pointer input. Keyboard methods stay on `app` because input follows the app’s current focus. -`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. Enable `systemPointerHover` only when moving the user’s visible cursor is acceptable. That route requires a foreground, unobscured target and leaves the cursor at the destination. +`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. If these alternatives cannot complete the task, `cua.requestMouseControl()` asks for this session until DSH restarts. On approval, every subsequent hover moves the system pointer to the foreground, unobscured target and leaves it there. `paste(text, { format: "text" | "html" })` temporarily publishes the requested clipboard format and invokes the application’s enabled standard Paste menu command. It keeps the temporary format until a pasteboard reader requests its data, then restores every captured original format while it still owns the clipboard, preserving a later copy. Paste operations share a cross-process lock and cancellation joins helper cleanup. A forced process exit can prevent restoration. Unsupported Paste commands fail without typing fallback; `typeText` remains separate key-event input. @@ -186,6 +185,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -210,7 +210,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 341796b8c4..87c9fb046f 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -64,7 +64,6 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. | `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 | | `timeoutMs` | `30000` | 单次操作截止时间,取消后可能已发生部分输入。 | | `connectTimeoutMs` | `5000` | 连接或启动共享 helper 的截止时间。 | -| `systemPointerHover` | `false` | AX 悬停不可用时,允许 `mouseMove` 移动可见光标。 | | `permissionPollIntervalMs` | `1000` | 原生引导打开期间检查权限和窗口位置的间隔,单位为毫秒。 | | `screenshotTimeoutMs` | `5000` | 截图总期限,包含排队时间,且不超过 `timeoutMs`。 | | `axTimeoutMs` | `3000` | 每次 Accessibility 系统调用的截止时间,与完整操作截止独立配置。 | @@ -115,7 +114,7 @@ void await window.getScreenshot() `getWindow` 静默读取初始树。已确认归属但没有 AX 界面的窗口返回空快照,仍可截图和发送坐标输入。在单个 API 实例内,门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享它,包括重复选择。读取任一窗口都会替换该快照;元素操作使用最新快照 ID,因此应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。读取失败不会发布部分快照;可以重新读取,但不要重复此前的输入。窗口对象负责截图和指针输入。键盘方法保留在 `app` 上,因为输入跟随应用的当前焦点。 -`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有允许移动用户可见光标时才开启 `systemPointerHover`。这条路径要求目标在前台且未被遮挡,操作后光标停在目标位置。 +`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有其他操作都无法完成任务时,才调用 `cua.requestMouseControl()` 询问用户。用户同意后,本会话后续悬停会把系统光标移到前台未受遮挡的目标并停在那里,重启 DSH 后授权失效。 `paste(text, { format: "text" | "html" })` 临时提供指定格式的剪贴板内容,并调用应用已启用的标准 Paste 菜单命令。临时格式保留到剪贴板读取方请求其数据,然后只在仍持有剪贴板时恢复保存的全部原格式,保留期间产生的新复制内容。粘贴操作共用跨进程锁,取消会等待辅助进程清理。强制终止进程可能阻止恢复。应用不支持 Paste 命令时明确失败,不自动改成逐字输入;`typeText` 仍单独提供按键事件输入。 @@ -186,6 +185,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -210,7 +210,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 8d43fbd6b8..b858a45026 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 88a59904ca4ebedc zh: 14f2b666d7382145 /dsh-cua-native-helper/observation-and-input: - en: 2d380e942a4057e4 - zh: fe7c9339ec63a6e5 + en: c7b28877014797fa + zh: 069271b6be75c8c1 /dsh-cua-native-helper/verification: en: 49f4cce2a4cc3114 zh: e9d65c92ef35fe24 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index bc80110951..107904886c 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -121,7 +121,7 @@ Tree observation and action discovery treat a generic AX failure reading one ele `paste` publishes one plain-text or HTML clipboard format, dispatches the application's enabled standard Paste menu command, and waits for a clipboard data request. It then restores the preserved formats only while its ownership marker and change count still match. The Host serializes its pastes across helpers with a file lock. maxClipboardBytes bounds both input and preserved contents; unsupported menus and oversized data fail before dispatch. A later copy is preserved. Cancellation restores owned clipboard data when possible; forced termination cannot guarantee restoration, and a data request does not prove the target completed editing. After dispatch, `pasteReadTimeoutMs` bounds waiting for clipboard consumption. Expiry returns `PASTE_NOT_CONSUMED`, closes the promised data provider, and restores the original clipboard only while still owned. The failure does not prove the app will never process its queued Paste command; inspect the target before deciding whether to retry. -`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. The macOS provider sets `systemPointerHover` in the connection handshake. Diagnostic clients that omit this setting may opt in per request with `allowSystemPointer: true`. The default is false. Only true permits posting a system pointer move after checking the foreground process and window hit. +`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. After session approval, the Host sends `useSystemPointer: true` on each `mouseMove` to select the system pointer directly. The opt-in system pointer route returns `FOREGROUND_REQUIRED` if the target is obscured or belongs to a background app. The result reports the route and whether the visible cursor moved in `hoverRoute` and `systemPointerMoved`. It never presses a button or restores the cursor after a read. Later input may interrupt the hover. Clicks send their button events at the target without an off-window priming click. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 0927a82bce..1834f216c6 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -121,7 +121,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `paste` 提供一种纯文本或 HTML 剪贴板格式,调用应用已启用的标准 Paste 菜单命令,并等待剪贴板数据请求。随后仅在所有权标记和修改计数仍匹配时恢复原有格式。Host 通过文件锁串行化不同 helper 的粘贴。maxClipboardBytes 同时限制输入和保留内容,不支持的菜单和过大的数据在分派前失败。后续复制会被保留。取消会尽可能恢复仍持有的剪贴板数据,强制结束不保证恢复,数据请求也不证明目标已完成编辑。 分派完成后,`pasteReadTimeoutMs` 限制等待剪贴板读取的时间。到期返回 `PASTE_NOT_CONSUMED`,关闭延迟数据提供方,并仅在仍持有剪贴板时恢复原内容。该失败不能证明应用以后不会处理已排队的 Paste 命令;决定是否重试前先检查目标内容。 -默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。macOS 提供方在连接握手中设置 `systemPointerHover`。未在握手中指定该配置的诊断客户端,可以在单次请求中传入 `allowSystemPointer: true`。默认值为 false。只有值为 true 才允许移动全局光标,且须检查前台进程和鼠标命中的窗口。 +默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。用户授权后,Host 在本会话的每次 `mouseMove` 请求中传入 `useSystemPointer: true`,直接使用系统光标。 开启后,系统指针路线会检查前台应用和坐标命中的窗口,不满足条件时返回 `FOREGROUND_REQUIRED`。返回值 `hoverRoute` 和 `systemPointerMoved` 说明走了哪条路线以及是否移动了可见光标。悬停不会按下鼠标,也不会在读取后还原光标。后续输入可能中断悬停。点击只在目标点发送按键事件,不先在窗口外点击。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift index 17b142d113..959034ff4f 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift @@ -2,13 +2,6 @@ import ApplicationServices final class AXHover { - /// Only diagnostic clients without a connection policy may opt in per request. - static func requireSystemPointer(connectionSetting: Bool?, requestSetting: Bool?) throws { - guard connectionSetting ?? requestSetting ?? false else { - throw NativeFailure(code: "HOVER_UNAVAILABLE", message: "No enabled element at this target or in its ancestors offers both AXShowAlternateUI and AXShowDefaultUI. System-pointer hover is disabled; this provider has no other pointer-free hover route. No input was sent and the pointer did not move. Use click on an available control or performAction with an action from listActions.") - } - } - private var revealed: (element: AXUIElement, windowID: UInt32)? /// Entering revealed descendants preserves their parent UI; leaving restores it once. diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index c4cc6066c8..c44b4078af 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -78,7 +78,6 @@ actor AXWorker { private let axTimeout: Float private let textEventInterval: Double private let maxClickHoldMs: Int - private let systemPointerHover: Bool? private let sessionState: @Sendable () -> Bool? private var access: AXAccess? private var keyboard: KeyboardInput? @@ -86,13 +85,12 @@ actor AXWorker { private var hovers: [String: AXHover] = [:] private var closed = false - init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, systemPointerHover: Bool? = nil, + init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, sessionState: @escaping @Sendable () -> Bool? = NativePlatform.sessionLocked) { self.cancellation = cancellation self.axTimeout = axTimeout self.textEventInterval = textEventInterval self.maxClickHoldMs = maxClickHoldMs - self.systemPointerHover = systemPointerHover self.sessionState = sessionState } private func identifier(_ kind: String) throws -> String { @@ -106,13 +104,13 @@ actor AXWorker { access = created return created } - private func moveUsingAX(_ element: AXUIElement?, windowID: UInt32, handle: String, params: RequestParameters) throws -> Bool { - let requestSetting = try params.optionalBoolean("allowSystemPointer") + private func moveUsingAX(_ element: AXUIElement?, windowID: UInt32, handle: String) throws -> DriverOutcome { let hover = hovers[handle] ?? AXHover() hovers[handle] = hover - if try hover.move(to: element, in: windowID, access: ax()) { return true } - try AXHover.requireSystemPointer(connectionSetting: systemPointerHover, requestSetting: requestSetting) - return false + guard try hover.move(to: element, in: windowID, access: ax()) else { + throw NativeFailure(code: "HOVER_UNAVAILABLE", message: "No enabled element at this target or in its ancestors offers both AXShowAlternateUI and AXShowDefaultUI. No input was sent and the pointer did not move. Use click on an available control or performAction with an action from listActions.") + } + return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false)) } private func inputs() -> (KeyboardInput, WindowPointerInput) { if let keyboard, let pointer { return (keyboard, pointer) } @@ -351,6 +349,7 @@ actor AXWorker { try ax().selectText(element(handle, params), text: params.string("text"), prefix: params.optionalString("prefix"), suffix: params.optionalString("suffix"), mode: params.optionalString("selectionType") ?? "text") case "click", "mouseMove": + let useSystemPointer = try params.optionalBoolean("useSystemPointer") ?? false let holdMs = params.contains("holdMs") ? try params.integer("holdMs", in: 0...maxClickHoldMs) : nil let button = try params.optionalString("mouseButton") ?? "left" let count = params.contains("clickCount") ? try params.integer("clickCount", in: 1...2) : 1 @@ -370,9 +369,7 @@ actor AXWorker { } if method == "mouseMove" { _ = try NativePlatform.ownedWindow(identity.pid, enclosing.id) - if try moveUsingAX(selected, windowID: enclosing.id, handle: handle, params: params) { - return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false)) - } + if !useSystemPointer { return try moveUsingAX(selected, windowID: enclosing.id, handle: handle) } } else { try ax().requireEnabled(selected) } @@ -389,12 +386,14 @@ actor AXWorker { let route = WindowRoute(owner: identity, windowID: window.windowID, bounds: window.bounds) if method == "mouseMove" { try WindowPointerInput.validate(point, in: route) - if elementWindow == nil { + if !useSystemPointer { var hit = try ax().element(at: CGPoint(x: window.bounds.x + point.x, y: window.bounds.y + point.y), owner: identity) if let target = hit, try ax().windowID(target) != window.windowID { hit = nil } - if try moveUsingAX(hit, windowID: window.windowID, handle: handle, params: params) { - return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false)) - } + return try moveUsingAX(hit, windowID: window.windowID, handle: handle) + } + if let hover = hovers[handle] { + _ = try hover.move(to: nil, in: window.windowID, access: ax()) + hovers.removeValue(forKey: handle) } return .mouseMove(route, point) } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift index 0da4e5cb96..a39965362b 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift @@ -14,7 +14,7 @@ enum NativeProtocol { "listActions": ["appHandle", "elementId", "snapshotId", "windowId"], "listApps": [], "listWindows": ["appHandle"], - "mouseMove": ["allowSystemPointer", "appHandle", "elementId", "snapshotId", "windowId", "x", "y"], + "mouseMove": ["appHandle", "elementId", "snapshotId", "useSystemPointer", "windowId", "x", "y"], "openPermissionSettings": ["permission", "settingsMessage"], "paste": ["appHandle", "format", "maxClipboardBytes", "maxDepth", "maxElements", "pasteReadTimeoutMs", "text", "windowId"], "performAction": ["action", "appHandle", "argument", "elementId", "snapshotId", "windowId"], diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift index 000a69e848..3a778cb8ae 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift @@ -42,8 +42,7 @@ import Darwin self.onClose = onClose self.onFatal = onFatal worker = AXWorker(cancellation: cancellation, axTimeout: configuration.axTimeoutSeconds, - textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs, - systemPointerHover: configuration.systemPointerHover) + textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs) input = JSONLInput(limit: configuration.maxRequestBytes, cancellation: cancellation, channel: channel) self.writer = writer } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift index c31a07ebef..d61f0803ab 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift @@ -21,7 +21,6 @@ struct HelperConfiguration: Sendable { var shutdownGraceSeconds: Double = 3 var textEventIntervalSeconds: Double = 0.00025 var maxClickHoldMs = 5000 - var systemPointerHover: Bool? var timeoutSeconds: Double = 30 var screenshotTimeoutSeconds: Double = 5 init(arguments: [String]) throws { @@ -65,7 +64,7 @@ struct HelperConfiguration: Sendable { guard let fields = decoded.object, Set(fields.keys) == ["type", "protocol", "runtimeKey", "config"], fields["type"] == .string("hello"), fields["protocol"] == .number(1), fields["runtimeKey"] == .string(runtimeKey), let config = fields["config"]?.object, - Set(config.keys).subtracting(["systemPointerHover"]) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else { + Set(config.keys) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else { throw NativeFailure(code: "NATIVE_PROTOCOL", message: "Expected shared helper protocol 1 hello for this runtime") } func number(_ name: String) throws -> String { @@ -83,10 +82,6 @@ struct HelperConfiguration: Sendable { "--screenshot-timeout-ms", number("screenshotTimeoutMs"), "--timeout-ms", number("timeoutMs"), "--shutdown-grace-ms", number("graceMs"), ]) - if let setting = config["systemPointerHover"] { - guard case .bool(let allowed) = setting else { throw RequestParameters.invalid("systemPointerHover must be a boolean") } - systemPointerHover = allowed - } } } diff --git a/packages/experimental/cua-macos/native/generate-protocol.ts b/packages/experimental/cua-macos/native/generate-protocol.ts index 398999cc2c..c643dfb830 100644 --- a/packages/experimental/cua-macos/native/generate-protocol.ts +++ b/packages/experimental/cua-macos/native/generate-protocol.ts @@ -10,7 +10,7 @@ import { PermissionGuideParameters, PermissionSettingsParameters } from '../src/ */ export function renderNativeProtocol(): { json: string; swift: string } { const { fields, enums, json } = nativeProtocol({ - mouseMove: z.object({ allowSystemPointer: z.boolean().optional() }), + mouseMove: z.object({ useSystemPointer: z.boolean().optional() }), requestPermission: PermissionGuideParameters, openPermissionSettings: PermissionSettingsParameters, }) const quoted = (values: Iterable) => [...values].sort().map(value => JSON.stringify(value)).join(', ') diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index b0d74a84dc..818394ad1b 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -489,7 +489,7 @@ "minLength": 1, "description": "Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot." }, - "allowSystemPointer": { + "useSystemPointer": { "type": "boolean" } }, @@ -524,7 +524,7 @@ "type": "number", "minimum": 0 }, - "allowSystemPointer": { + "useSystemPointer": { "type": "boolean" } }, diff --git a/packages/experimental/cua-macos/native/tests/e2e.py b/packages/experimental/cua-macos/native/tests/e2e.py index 2a16b54b49..c5b385189a 100644 --- a/packages/experimental/cua-macos/native/tests/e2e.py +++ b/packages/experimental/cua-macos/native/tests/e2e.py @@ -236,7 +236,7 @@ def main(): assert (after["cursorChanges"], after["hoverVisible"]) == (before["cursorChanges"], before["hoverVisible"]), after def mouse_move(target): - result = call("mouseMove", target if args.alternate_hover else {**target, "allowSystemPointer": True}) + result = call("mouseMove", target if args.alternate_hover else {**target, "useSystemPointer": True}) if not args.alternate_hover: assert result["hoverRoute"] == "system-pointer" and result["systemPointerMoved"] is True, result if args.alternate_hover: @@ -271,7 +271,7 @@ def main(): mouse_move(target) wait_state(lambda actual: actual["hoverVisible"], "Input-transparent overlay blocked hover") before = cycle_overlay(2) - call("mouseMove", {**target, "allowSystemPointer": True}, "FOREGROUND_REQUIRED") + call("mouseMove", {**target, "useSystemPointer": True}, "FOREGROUND_REQUIRED") after = wait_state(lambda actual: actual["cursorSamples"] >= before["cursorSamples"] + 2, "Pointer observation did not advance") assert after["cursorChanges"] == before["cursorChanges"], "Rejected hover moved the system pointer" diff --git a/packages/experimental/cua-macos/native/tests/electron_e2e.py b/packages/experimental/cua-macos/native/tests/electron_e2e.py index 403bb21eba..d8acffbe9d 100644 --- a/packages/experimental/cua-macos/native/tests/electron_e2e.py +++ b/packages/experimental/cua-macos/native/tests/electron_e2e.py @@ -136,7 +136,7 @@ def main(): call('mouseMove', {**owner, 'snapshotId': state['snapshotId'], 'elementId': trigger['elementId'], 'x': 0}, 'INVALID_ARGUMENT') hover_target = {**owner, 'snapshotId': state['snapshotId'], 'elementId': trigger['elementId']} call('mouseMove', hover_target, 'HOVER_UNAVAILABLE') - call('mouseMove', {**hover_target, 'allowSystemPointer': True}) + call('mouseMove', {**hover_target, 'useSystemPointer': True}) actual = wait_state(lambda actual: actual['popupOpen'], 'Hover did not reveal delayed information') assert actual['pointerDowns'] == baseline and actual['lastMove'] == {'buttons': 0, 'trusted': True}, actual state = observe() @@ -151,13 +151,13 @@ def main(): target = {**owner, 'windowId': window_id, 'x': bounds[0] + bounds[2] / 2 - window['bounds']['x'], 'y': bounds[1] + bounds[3] / 2 - window['bounds']['y']} - call('mouseMove', {**target, 'allowSystemPointer': True}) + call('mouseMove', {**target, 'useSystemPointer': True}) state = observe() assert 'Hover information' in state['tree'], 'Moving into the popup dismissed it' call('click', target) actual = wait_state(lambda actual: actual['popupClicks'] == expected_clicks, 'Popup action did not receive its click') assert actual['popupOpen'] and actual['pointerDowns'] == baseline + 1 and actual['popupClickTrusted'], actual - call('mouseMove', {**owner, 'windowId': window_id, 'x': 10, 'y': 40, 'allowSystemPointer': True}) + call('mouseMove', {**owner, 'windowId': window_id, 'x': 10, 'y': 40, 'useSystemPointer': True}) wait_state(lambda actual: not actual['popupOpen'], 'Leaving the hover region did not close it') hover_sequence() helper.eof() diff --git a/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift b/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift index 52f7f0f224..c0cd744ec4 100644 --- a/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift @@ -33,14 +33,6 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { } } -@Test func readsSystemPointerHoverFromEachConnection() throws { - #expect(try HelperConfiguration(hello: hello(), runtimeKey: runtimeKey).systemPointerHover == nil) - for allowed in [true, false] { - let configuration = try HelperConfiguration(hello: hello(overrides: ["systemPointerHover": .bool(allowed)]), runtimeKey: runtimeKey) - #expect(configuration.systemPointerHover == allowed) - } -} - @Test func resolvesClickHoldLimitFromHelperArguments() throws { #expect(try HelperConfiguration(arguments: []).maxClickHoldMs == 5000) for limit in [0, 7000] { @@ -56,7 +48,7 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { ("axTimeoutMs", .number(0)), ("textEventIntervalMs", .number(-1)), ("screenshotTimeoutMs", .number(0)), ("timeoutMs", .string("8000")), ("graceMs", .number(-1)), ("maxClickHoldMs", .number(-1)), ("maxClickHoldMs", .number(0.5)), - ("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ("systemPointerHover", .string("true")), + ("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ("systemPointerHover", .bool(true)), ]) func refusesInvalidSharedConfiguration(_ name: String, _ value: JSONValue) throws { #expect(throws: NativeFailure.self) { try HelperConfiguration(hello: hello(overrides: [name: value]), runtimeKey: runtimeKey) } diff --git a/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift b/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift index df48871c4c..38f82ac4a8 100644 --- a/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift @@ -3,20 +3,6 @@ import Testing import CoreGraphics @testable import CuaNative -@Test func unavailableAXHoverHonorsConnectionPolicyBeforeRequestFlags() throws { - for (connection, request): (Bool?, Bool?) in [(nil, nil), (nil, false), (false, nil), (false, true)] { - do { - try AXHover.requireSystemPointer(connectionSetting: connection, requestSetting: request) - Issue.record("An unapproved request was allowed to move the pointer") - } catch let failure as NativeFailure { - #expect(failure.code == "HOVER_UNAVAILABLE") - #expect(failure.message.contains("No input was sent")) - } - } - try AXHover.requireSystemPointer(connectionSetting: true, requestSetting: nil) - try AXHover.requireSystemPointer(connectionSetting: nil, requestSetting: true) -} - @Test func systemHoverRequiresTheForegroundUnobscuredTarget() throws { let owner = ProcessIdentity(pid: 42, startedSeconds: 1, startedMicroseconds: 0) let bounds = WindowBounds(x: 100, y: 100, width: 400, height: 300) diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index f8779651a9..5a340f045f 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -5,7 +5,7 @@ import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are available at app or window level. Simulated keyboard input is available at app level. Simulated mouse input is available at window level. For pointer input on a sheet, select its window ID with app.getWindow(id). For menu items outside a window, use app.invoke(id). findApps searches /Applications, /System/Applications, and ~/Applications. Use the Computer Use permission page to grant the helper application Accessibility and Screen Recording permissions in System Settings. Native window and AX bounds use macOS points, while delivered screenshots carry their own pixel dimensions. show does not switch Spaces, and Stage Manager can present reduced window geometry; explain any required foreground step before calling app.activate() or performing an advertised AXRaise action.' const methodDetails = new Map([ - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 4b07e9c8b7..dff6dd6997 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' import { installCua, type CuaConfig, type CuaStartupCapabilities } from '@deepseek-ai/dsh-cua' -import type { NativeChannelFactory } from '@deepseek-ai/dsh-cua/native-client' import { CuaConfigFields } from '@deepseek-ai/dsh-cua/config' import { createPermissionParameters } from './permission-guide.ts' import { nativeHostArchitecture, prepareNativeExecutable, type SourceSigning } from './native-build.ts' @@ -16,7 +15,7 @@ const CAPABILITIES: CuaStartupCapabilities = { platform: 'macos', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', 'selectText', 'setValue', 'show', 'typeText', ], appLevelAccessibility: true, @@ -33,8 +32,6 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -45,7 +42,6 @@ export interface Config extends CuaConfig { /** Validate shared operation bounds and macOS source signing before preparing the helper. */ export const Config = Schema.object({ ...CuaConfigFields, - systemPointerHover: Schema.boolean().default(false), permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000), sourceSigning: Schema.union([ Schema.const('ad-hoc'), @@ -64,8 +60,7 @@ export const Config = Schema.object({ * @returns Completion after native preparation and tool registration. */ export async function apply(ctx: Context, config: Config): Promise { - const createChannel: NativeChannelFactory = (owner, native) => createSharedNativeChannel(owner, native, config.systemPointerHover) - const backend = { createChannel, guidance: GUIDANCE, capabilities: CAPABILITIES, + const backend = { createChannel: createSharedNativeChannel, guidance: GUIDANCE, capabilities: CAPABILITIES, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/src/shared-native.ts b/packages/experimental/cua-macos/src/shared-native.ts index 7be638a96b..e76e1f0440 100644 --- a/packages/experimental/cua-macos/src/shared-native.ts +++ b/packages/experimental/cua-macos/src/shared-native.ts @@ -56,8 +56,7 @@ class SharedNativeChannel implements NativeChannel { private cleanupTimer: ReturnType | undefined private cleanupFailure: Error | undefined - constructor(private readonly owner: { subprocess: Pick }, private readonly config: NativeClientConfig, - private readonly systemPointerHover: boolean) { + constructor(private readonly owner: { subprocess: Pick }, private readonly config: NativeClientConfig) { this.stdin = new Writable({ write: (chunk: Buffer, encoding, callback) => { void this.ready.promise.then((socket) => { @@ -216,7 +215,7 @@ class SharedNativeChannel implements NativeChannel { screenshotTimeoutMs, timeoutMs, graceMs } = this.config socket.write(JSON.stringify({ type: 'hello', protocol: 1, runtimeKey, config: { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, maxClickHoldMs, - screenshotTimeoutMs, timeoutMs, graceMs, systemPointerHover: this.systemPointerHover } }) + '\n') + screenshotTimeoutMs, timeoutMs, graceMs } }) + '\n') }) const receive = (chunk: Buffer): void => { const newline = chunk.indexOf(10) @@ -288,9 +287,8 @@ class SharedNativeChannel implements NativeChannel { * Prepare or join the native process; writes wait for identity-checked readiness. * @param owner - Provider owning the short-lived native startup command. * @param config - Native runtime identity, operation limits and connection deadlines. - * @param systemPointerHover - Whether this connection may move the visible pointer when AX hover is unavailable. * @returns One independently disposable Agent connection, without operation replay. */ -export function createSharedNativeChannel(owner: { subprocess: Pick }, config: NativeClientConfig, systemPointerHover: boolean): NativeChannel { - return new SharedNativeChannel(owner, config, systemPointerHover) +export function createSharedNativeChannel(owner: { subprocess: Pick }, config: NativeClientConfig): NativeChannel { + return new SharedNativeChannel(owner, config) } diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index c4a6aeec7d..2db5c24eb6 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -4,7 +4,7 @@ import type { CuaApi, CuaApp, CuaSurface, CuaWindow } from '@deepseek-ai/dsh-cua import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' import { GUIDANCE, PLATFORM_GUIDANCE } from '../src/guidance.ts' -const apiNames: Array = ['getCapabilities', 'listApps', 'findApps', 'getState', 'getApp'] +const apiNames: Array = ['getCapabilities', 'requestMouseControl', 'listApps', 'findApps', 'getState', 'getApp'] const accessibilityNames: Array> = [ 'getAccessibilityTree', 'listActions', 'invoke', 'performAction', 'setValue', 'selectText', ] @@ -28,12 +28,12 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(PLATFORM_GUIDANCE).toContain('Simulated keyboard input is available at app level') expect(PLATFORM_GUIDANCE).toContain('Simulated mouse input is available at window level') const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove(')) - for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'systemPointerHover']) expect(hover).toContain(term) + for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'The question interrupts the user']) expect(hover).toContain(term) const click = GUIDANCE.split('\n').find(line => line.startsWith('- click(')) expect(click).toContain('await app.getWindow(windowId), then call win.click(target)') expect(click).toContain('use app.invoke(id)') for (const step of ['call app.activate() first', 'outside the window', 'screenshot pixels', 'before typing or retrying']) expect(click).toContain(step) - for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'systemPointerHover', 'mouse-down']) { + for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'The question interrupts the user', 'mouse-down']) { expect(COMMON_GUIDANCE).not.toContain(platformDetail) } }) @@ -50,8 +50,8 @@ it('enumerates every available method once in its catalog entry', () => { for (const catalog of [COMMON_GUIDANCE, GUIDANCE]) { for (const signature of signatures) expect(catalog.split('\n').filter(line => line.startsWith(`- ${signature}`))).toHaveLength(1) } - expect(signatures).toHaveLength(24) - expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(23) + expect(signatures).toHaveLength(25) + expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(24) expect(COMMON_GUIDANCE).toContain('app.appHandle') expect(COMMON_GUIDANCE).toContain('window.windowId') }) diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index aefc6fbee6..87afd7eed9 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -26,7 +26,7 @@ describe('native protocol generation', () => { { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', maxClipboardBytes: 1024 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } }, - { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, allowSystemPointer: true } }, + { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, useSystemPointer: true } }, { method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } }, { method: 'launchApp', params: { appHandle: 'pending' } }, { method: 'applicationIcons', params: { paths: ['/Applications/Fixture.app'] } }, diff --git a/packages/experimental/cua-macos/tests/provider-channel.spec.ts b/packages/experimental/cua-macos/tests/provider-channel.spec.ts index 02d9fc0fe6..1b9ac97c44 100644 --- a/packages/experimental/cua-macos/tests/provider-channel.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-channel.spec.ts @@ -14,10 +14,10 @@ vi.mock('@deepseek-ai/dsh-cua', async importOriginal => ({ })) vi.mock('../src/shared-native.ts', () => ({ createSharedNativeChannel: vi.fn() })) -it.each([false, true])('forwards systemPointerHover=%s and native limits to each channel', async (systemPointerHover) => { +it('forwards native limits to each channel', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) - const config = Config({ helperPath: resolve('fixture-helper'), systemPointerHover }) + const config = Config({ helperPath: resolve('fixture-helper') }) const channel: NativeChannel = { stdin: new PassThrough(), stdout: new PassThrough(), done: Promise.resolve(), close() {}, waitForClose: async () => {}, @@ -32,5 +32,5 @@ it.each([false, true])('forwards systemPointerHover=%s and native limits to each const owner = { subprocess: { spawn: vi.fn() } } const native = { ...config, helperPath: resolve('fixture-helper'), cwd: process.cwd() } expect(backend.createChannel(owner, native)).toBe(channel) - expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native, systemPointerHover) + expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native) }) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 48b9eb99e3..f975862baa 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -21,7 +21,7 @@ import { CuaConfig } from '@deepseek-ai/dsh-cua' import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import FileSystem from '@deepseek-ai/dsh-fs-local' -import UserQuestions from '@deepseek-ai/dsh-user-questions' +import UserQuestions, { UserQuestionError } from '@deepseek-ai/dsh-user-questions' import NodeRuntime from '@deepseek-ai/dsh-ptc-runtime-node' import Sandbox from '@deepseek-ai/dsh-sandbox-local' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' @@ -61,7 +61,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ })) const config = { - permissionPollIntervalMs: 1000, systemPointerHover: false, + permissionPollIntervalMs: 1000, agentPreset: false, helperPath: resolve('fixture-helper'), sourceSigning: 'ad-hoc', buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20, graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, @@ -71,7 +71,6 @@ const { helperPath: _helperPath, ...automaticConfig } = config it('defaults to ad-hoc signing and requires a keychain path for certificate signing', () => { expect(NativeProvider.Config({}).sourceSigning).toBe('ad-hoc') - expect(NativeProvider.Config({}).systemPointerHover).toBe(false) expect(NativeProvider.Config({ sourceSigning: 'ad-hoc' }).sourceSigning).toBe('ad-hoc') for (const sourceSigning of [{ identity: 'Apple Development: Example', keychain: '/keychains/custom.keychain-db' }, { identity: 'ABC123', keychain: '/keychains/custom.keychain-db' }]) { expect(NativeProvider.Config({ sourceSigning }).sourceSigning).toEqual(sourceSigning) @@ -199,7 +198,7 @@ it('merges startup capabilities into runtime native fields', async () => { expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false, coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', 'selectText', 'setValue', 'show', 'typeText', ] }) expect(native.request).toHaveBeenCalledExactlyOnceWith('getCapabilities', {}, expect.any(AbortSignal), ToolCallId('lifecycle-1')) @@ -878,3 +877,69 @@ it('routes permission actions through the macOS provider with the Client languag permission: 'screenRecording', settingsMessage: 'Open System Settings > Privacy & Security > Screen Recording manually.', }, expect.any(AbortSignal)) }) + +it('moves only for the consenting session, without granting application access or retaining consent after reload', async () => { + const f = await setup() + await f.ctx.plugin(UserQuestions) + const ask = vi.fn((id: string) => ({ answers: [{ id, selected: ['Allow for this session'] }] })) + f.ctx.on('user-questions/request', async ({ questions }) => ask(questions[0]!.id)) + const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } + native.request.mockResolvedValueOnce({ appHandle: 'app-one', name: 'Fixture', running: true, + authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } }) + expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) + const move = async (useSystemPointer: boolean) => { + expect((await f.execute('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer }, expect.any(AbortSignal), expect.any(String)) + } + await move(false) + expect((await f.execute('mouseMove', { ...target, useSystemPointer: true })).isError).toBe(true) + for (let i = 0; i < 2; i++) expect((await f.execute('requestMouseControl')).value).toBe(true) + await move(true) + const other = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) + const otherCall = (method: string, params: Record) => executeToolFixture(f.ctx, { + agent: other.agent, signal: new AbortController().signal, callId: ToolCallId(method), name: 'cua', arguments: { method, params }, + }) + expect((await otherCall('requestMouseControl', {})).value).toBe(true) + expect((await otherCall('mouseMove', target)).isError).toBe(true) + await f.provider.dispose() + await f.ctx.plugin(NativeProvider, config) + expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(ask.mock.calls.map(([id]) => id)).toEqual(['cua-app-access', 'cua-mouse-control', 'cua-mouse-control', 'cua-mouse-control']) +}) + +it.each(['Deny', 'custom', 'cancel'] as const)('remembers a %s answer without moving the pointer or asking again', async (answer) => { + const f = await setup() + await f.ctx.plugin(UserQuestions) + const ask = vi.fn(() => { + if (answer === 'cancel') throw new UserQuestionError('User dismissed the request', 'ASK_CANCELLED') + return { answers: [{ id: 'cua-mouse-control', selected: [answer === 'custom' ? 'Allow for this session' : answer], + ...(answer === 'custom' ? { custom: 'maybe later' } : {}) }] } + }) + f.ctx.on('user-questions/request', async () => ask()) + expect((await f.execute('requestMouseControl')).value).toBe(false) + expect((await f.execute('requestMouseControl')).value).toBe(false) + expect(ask).toHaveBeenCalledTimes(1) + expect(native.request).not.toHaveBeenCalled() +}) + +it('drops late approval after cancellation and admits only one pending question', async () => { + const f = await setup() + expect((await f.execute('requestMouseControl')).isError).toBe(true) + await f.ctx.plugin(UserQuestions) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + f.ctx.on('user-questions/request', async () => { + entered.resolve(undefined) + await release.promise + return { answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] } + }) + const controller = new AbortController() + const first = f.execute('requestMouseControl', {}, controller.signal) + await entered.promise + expect((await f.execute('requestMouseControl')).isError).toBe(true) + controller.abort() + release.resolve(undefined) + await Promise.allSettled([first]) + expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(native.request).not.toHaveBeenCalled() +}) diff --git a/packages/experimental/cua-macos/tests/shared-native.spec.ts b/packages/experimental/cua-macos/tests/shared-native.spec.ts index 871475fb53..86c5437ccf 100644 --- a/packages/experimental/cua-macos/tests/shared-native.spec.ts +++ b/packages/experimental/cua-macos/tests/shared-native.spec.ts @@ -133,8 +133,8 @@ async function fixture(options: { timeoutMs: 30_000, graceMs: options.graceMs ?? 1000, axTimeoutMs: 3000, maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000, } - function open(systemPointerHover = false): NativeChannel { - const channel = createSharedNativeChannel({ subprocess: { spawn } }, config, systemPointerHover) + function open(): NativeChannel { + const channel = createSharedNativeChannel({ subprocess: { spawn } }, config) channels.push(channel) return channel } @@ -172,7 +172,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', ( it('keeps concurrent Agent scopes independent and leaves other sockets alive when one closes', async () => { const f = await fixture() const first = f.open() - const second = f.open(true) + const second = f.open() const firstReply = nextBytes(first) const secondReply = nextBytes(second) await Promise.all([write(first, 'one\n'), write(second, 'two\n')]) @@ -191,9 +191,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', ( maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000, } expect(f.hellos).toHaveLength(2) - for (const systemPointerHover of [true, false]) { - expect(f.hellos).toContainEqual(expect.objectContaining({ type: 'hello', protocol: 1, config: { ...limits, systemPointerHover } })) - } + for (const hello of f.hellos) expect(hello).toMatchObject({ type: 'hello', protocol: 1, config: limits }) }) it('waits for both launcher outcome and managed range release before sending any bytes', async () => { diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index f4abb13c43..b72fd91584 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -15,8 +15,8 @@ en: 5db4d4fea25493b9 zh: 65ab64fc24eb0d09 /deepseek-ai-dsh-cua/use-this-package: - en: ba1f8e6ae51f69d7 - zh: 8f4fde9857bae319 + en: a55ba012aedca751 + zh: fcddea780c0ec58e /deepseek-ai-dsh-cua/use-this-package/permission-controls: en: d92d5948a7941e8d zh: d43491ae6521b87e diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index c95e4ae8ec..39a3e522d8 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -41,7 +41,7 @@ Observation methods emit and return their result. Use `void await app.getAccessi Application consent precedes launch, observation, capture, and input. Session access is stored by conversation and application identity and survives DSH restart and Computer Use disablement. Other conversations require their own consent. Saved grants require a platform-provided reusable application identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect. -Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. +Use `cua.requestMouseControl()` only when the task requires moving the user’s pointer. Approval applies to `mouseMove` in this session until DSH restarts. Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. `click(target, { holdMs: 800 })` holds the mouse button for 800 milliseconds before release. `holdMs` applies only to `click`; it accepts nonnegative integers and applies to each press of a double-click; zero releases without an additional hold. The provider config `maxClickHoldMs` limits explicit holds and defaults to 5000 ms. Explicit holds use pointer input; omission preserves ordinary click behavior. The operation deadline includes the hold, and cancellation or timeout releases the button. Received input cannot be undone; forced helper termination cannot guarantee release. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index c1c5ed863c..c2933dc0d1 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 应用授权发生在启动、观察、截图和输入之前。会话许可按对话和应用身份持久化,重启 DSH 或关闭 Computer Use 后仍可复用,其他对话需要单独授权。长期许可要求平台提供可复用的应用身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。 -动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 +只有任务确实需要移动用户的鼠标时,才调用 `cua.requestMouseControl()` 询问用户。授权仅适用于当前会话的 `mouseMove`,重启 DSH 后失效。动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 `click(target, { holdMs: 800 })` 按住鼠标按钮 800 毫秒后松开。`holdMs` 仅适用于 `click`;它接受非负整数,双击时应用于每次按下;0 表示按下后立即松开,不额外等待。提供方配置 `maxClickHoldMs` 限制显式按住时长,默认 5000 毫秒。指定时长后使用鼠标事件;省略时保留普通点击行为。按住时间计入操作超时,取消或超时会松开按钮。已接收的输入无法撤销;辅助进程被强制终止时无法保证松开。 diff --git a/packages/experimental/cua/src/api.ts b/packages/experimental/cua/src/api.ts index 22039c6591..930db84195 100644 --- a/packages/experimental/cua/src/api.ts +++ b/packages/experimental/cua/src/api.ts @@ -1,6 +1,6 @@ /** Object facade over logged native operations; its caller owns evaluator persistence. */ import { Buffer } from 'node:buffer' -import type { z } from 'zod' +import { z } from 'zod' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { ActionNamesResult, ActionResult, AppResult, AppsResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, StateResult, WindowsResult } from './protocol.ts' import type { CuaApi, CuaApp, CuaAppHandle, CuaCapabilities, CuaDispatch, CuaObservationOptions, CuaObservationSink, CuaOperation, CuaScreenshot, CuaSurface, CuaWindow } from './types.ts' @@ -51,6 +51,9 @@ export function createCuaApi(dispatch: CuaDispatch, sink?: CuaObservationSink): const objects = createCuaObjects(call, sink) const api: CuaApi = { + async requestMouseControl() { + return z.boolean().parse(await call('requestMouseControl', {})) + }, async getCapabilities(options) { const result = CapabilitiesResult.parse(await call('getCapabilities', {})) capabilities = result diff --git a/packages/experimental/cua/src/client/ApplicationApproval.tsx b/packages/experimental/cua/src/client/ApplicationApproval.tsx index a82a115fc4..f762d5d624 100644 --- a/packages/experimental/cua/src/client/ApplicationApproval.tsx +++ b/packages/experimental/cua/src/client/ApplicationApproval.tsx @@ -5,18 +5,18 @@ import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' import type {} from '@deepseek-ai/dsh-client-ui-approval/client' /** - * Select only the provider's fixed application question; other interactions keep their renderer. + * Select the provider’s application and mouse-control questions; other interactions keep their renderer. * @param pending The session's currently elected interaction. - * @returns Its application decision, or null for another interaction. + * @returns Its Computer Use decision, or null for another interaction. */ export function selectApplicationApproval(pending: ComposerChainProps['pendingInteraction']) { if (pending?.kind !== 'question' || pending.questions.length !== 1) return null const item = pending.questions[0] as (typeof pending.questions)[number] - if (item.id !== 'cua-app-access' || item.intent?.kind !== 'approval') return null + if ((item.id !== 'cua-app-access' && item.id !== 'cua-mouse-control') || item.intent?.kind !== 'approval') return null return { pending, item, intent: item.intent } } -/** Composer bindings for an application decision and caller-owned localized copy. */ +/** Composer bindings for a Computer Use decision and caller-owned localized copy. */ export type ApplicationApprovalProps = PropsRuntime<'conversation.composer'> & PropsRenderFactories & PropsLocale<'cuaPermissions'> & { matched: NonNullable> resolveText: (text: { en: string; [locale: string]: string }) => string @@ -32,7 +32,7 @@ export function ApplicationApproval({ matched: { pending, item, intent }, resolv const answer = (label: string) => pending.answer({ answers: [{ id: item.id, selected: [label] }] }) return renderFactorySlot('conversation.approval.card', { requestKey: pending.key, - warning: { label: t('approvalRisks'), title: t('approvalRiskTitle'), detail: t('approvalRiskNotice') }, + ...(item.id === 'cua-app-access' ? { warning: { label: t('approvalRisks'), title: t('approvalRiskTitle'), detail: t('approvalRiskNotice') } } : {}), title: intent.display === undefined ? item.question : resolveText(intent.display.question), ...(item.detail === undefined ? {} : { description: intent.display?.detail === undefined ? item.detail : resolveText(intent.display.detail), diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 745ccfe9be..5f0abd1dac 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -8,6 +8,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index d6c75f46bc..40cb0bf1cd 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -19,6 +19,7 @@ import type { CuaApplicationGrant, CuaApplicationId, CuaSystemPermission } from import type { CuaAppHandle, CuaOperation, CuaStartupCapabilities } from './types.ts' import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' import { CuaConfigFields } from './config.ts' +import { MouseControl } from './mouse-control.ts' import { CuaCoordinates } from './coordinates.ts' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import type {} from '@deepseek-ai/dsh-agent-preset-registry' @@ -156,6 +157,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) + const mouseControl = new MouseControl(ctx) const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -220,6 +222,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) ? { ...params, maxElements: config.maxElements, maxDepth: config.maxDepth } : method === 'drag' ? { ...params, durationMs: config.dragDurationMs, steps: config.dragSteps } : params + if (method === 'mouseMove') nativeParams.useSystemPointer = mouseControl.allowed(agent) const requestNative = () => client.request(method, nativeParams, signal, execution.callId) if (method === 'screenshot') { const { attachments, target } = await screenshotStore(ctx, execution) @@ -306,6 +309,10 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const signal = AbortSignal.any([execution.signal, lifetime.signal, owner.signal]) signal.throwIfAborted() const operation = (async () => { + if (request.method === 'requestMouseControl') { + if (!backend.capabilities.methods.includes(request.method)) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') + return mouseControl.request(agent, signal) + } const previous = clients.get(agent) if (previous?.disposed) { try { await previous.dispose() } catch (error) { @@ -333,7 +340,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const nativeClient = client const dispatch = async (signal: AbortSignal) => { const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates } - const result = await callNative(Request.parse(request), scoped) + const result = await callNative(request, scoped) signal.throwIfAborted() return result } diff --git a/packages/experimental/cua/src/locales.ts b/packages/experimental/cua/src/locales.ts index 499ce34339..3833690158 100644 --- a/packages/experimental/cua/src/locales.ts +++ b/packages/experimental/cua/src/locales.ts @@ -103,3 +103,28 @@ export const launchAccessQuestion = { ru: 'Разрешить запуск? Доступ к окнам запрашивается отдельно.', pt: 'Permitir iniciar esta entrada? O acesso às janelas é solicitado separadamente.', } + +/** Scope and interruption shown before the user grants mouse control. */ +export const mouseControlDisplay = { + question: { + en: 'Allow the agent to move your mouse pointer?', + zh: '允许智能体移动你的鼠标指针吗?', + ja: 'エージェントがマウスポインタを動かすことを許可しますか?', + es: '¿Permitir que el agente mueva el puntero del ratón?', + fr: 'Autoriser l’agent à déplacer le pointeur de la souris ?', + de: 'Darf der Agent den Mauszeiger bewegen?', + ru: 'Разрешить агенту перемещать указатель мыши?', + pt: 'Permitir que o agente mova o ponteiro do mouse?', + }, + detail: { + en: 'Hovering will move your pointer and leave it at the target, even while you are using the mouse. This permission applies only to this session and expires when DSH restarts.', + zh: '智能体悬停时会移动你的鼠标指针,并将它留在目标位置,即使你正在使用鼠标。授权仅限当前会话,重启 DSH 后失效。', + ja: 'ホバーすると、マウスを使用中でもポインタが移動し、移動先に残ります。許可はこのセッションに限り、DSH を再起動すると失効します。', + es: 'Al pasar sobre un elemento, el puntero se moverá y quedará allí aunque estés usando el ratón. El permiso solo se aplica a esta sesión y caduca al reiniciar DSH.', + fr: 'Lors d’un survol, le pointeur se déplace et reste sur la cible, même si vous utilisez la souris. Cette autorisation vaut pour cette session et expire au redémarrage de DSH.', + de: 'Beim Überfahren eines Ziels bewegt sich der Zeiger und bleibt dort, auch wenn Sie die Maus verwenden. Die Erlaubnis gilt nur für diese Sitzung und erlischt beim Neustart von DSH.', + ru: 'При наведении указатель переместится и останется на цели, даже если вы пользуетесь мышью. Разрешение действует только в этом сеансе до перезапуска DSH.', + pt: 'Ao passar sobre um elemento, o ponteiro se moverá e ficará no destino, mesmo enquanto você usa o mouse. A permissão vale apenas para esta sessão e expira ao reiniciar o DSH.', + }, + approve: applicationAccessDisplay.approve, +} diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts new file mode 100644 index 0000000000..306cd43a2f --- /dev/null +++ b/packages/experimental/cua/src/mouse-control.ts @@ -0,0 +1,56 @@ +/** Human decisions about the system pointer live in the provider process, keyed by Session. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' +import { mouseControlDisplay } from './locales.ts' +import { NativeError } from './native-client.ts' + +/** Mouse control applies only to mouseMove and never grants access to an application. */ +export class MouseControl { + private readonly decisions = new Map() + + constructor(private readonly ctx: Context) {} + + /** @param agent Calling Agent. @returns Whether this Session explicitly approved moving the system pointer. */ + allowed(agent: Agent): boolean { + return this.decisions.get(agent.id) === true + } + + /** + * Ask once for the Session. Simultaneous requests cannot open additional questions. + * @param agent Live Agent that owns the Session. + * @param signal Tool lifetime. An aborted tool cannot grant permission. + * @returns True only when the user chose the offered approval. + */ + async request(agent: Agent, signal: AbortSignal): Promise { + const decision = this.decisions.get(agent.id) + if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'A mouse control request is already awaiting an answer in this session') + if (decision !== undefined) return decision + const questions = this.ctx.get('userQuestions') + if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') + this.decisions.set(agent.id, 'asking') + const id = 'cua-mouse-control' + const approve = mouseControlDisplay.approve.en + const reject = 'Deny' + try { + const result = await questions.ask({ agent, signal, questions: [{ + id, question: mouseControlDisplay.question.en, detail: mouseControlDisplay.detail.en, + options: [{ label: approve }, { label: reject }], + intent: { kind: 'approval', approve, reject, display: mouseControlDisplay }, + }] }) + signal.throwIfAborted() + const [answer] = result.answers + const granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() + && answer.selected.length === 1 && answer.selected[0] === approve + this.decisions.set(agent.id, granted) + return granted + } catch (error) { + if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { + this.decisions.set(agent.id, false) + return false + } + this.decisions.delete(agent.id) + throw error + } + } +} diff --git a/packages/experimental/cua/src/native-schema.ts b/packages/experimental/cua/src/native-schema.ts index a6122ed71e..9cf8435001 100644 --- a/packages/experimental/cua/src/native-schema.ts +++ b/packages/experimental/cua/src/native-schema.ts @@ -1,7 +1,7 @@ /** Build-time native admission metadata derived from the shared operation definitions. */ import { z } from 'zod' import type { JSONSchema } from 'zod/v4/core' -import { NativeOnlyOperations, NativeResourceParameters, Request } from './protocol.ts' +import { NativeOnlyOperations, NativeResourceParameters, NativeToolOperations, type Request } from './protocol.ts' type Schema = JSONSchema.JSONSchema @@ -22,7 +22,7 @@ export function nativeProtocol(platformFields: Partial z.toJSONSchema(schema)) const fields = new Map>() const enums = new Map>>() diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 538c95cfe6..161da04ee7 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -34,8 +34,8 @@ const imageBase64 = z.string().min(1).refine(value => Buffer.from(value, 'base64 message: 'Expected canonical base64 image data', }) -/** Model-submittable operation parameters; Host resource fields and private continuations are separate. */ -export const Request = z.discriminatedUnion('method', [ +/** Operations shared by the model and native helper, before Host parameters are added. */ +export const NativeToolOperations = [ operation('getCapabilities', {}), operation('listApps', {}), operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), operation('getApp', { selector: z.string().min(1) }), @@ -77,7 +77,10 @@ export const Request = z.discriminatedUnion('method', [ fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), ]) }).strict(), operation('screenshot', window), -]) +] as const + +/** Model-submittable operations. The Host answers mouse-control requests without dispatching native input. */ +export const Request = z.discriminatedUnion('method', [...NativeToolOperations, operation('requestMouseControl', {})]) /** Resource parameters supplied by the Host, never accepted from model tool arguments. */ export const NativeResourceParameters = { diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index b84663f9e2..541dcdd070 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -246,6 +246,11 @@ export interface CuaApp extends CuaSurface { } /** Native app API; its caller owns global bindings and variable persistence. */ export interface CuaApi { + /** + * Ask to move the user's pointer with mouseMove for this session until restart. + * @returns True only after explicit approval. A refusal is remembered for the session. + */ + requestMouseControl(): Promise /** * Inspect permissions without requesting them. * @param options - output options. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index cbaa419951..053010af02 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -890,3 +890,11 @@ it('expands menu contents and details only for the requested observation', async await app.getAccessibilityTree({ includeMenus: false, details: false }) expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'getState', params: { appHandle: selected.appHandle, includeMenus: false, details: false } }) }) + +it('requests mouse control without dispatching a move and returns the decision', async () => { + const dispatch = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const cua = createCuaApi(dispatch) + expect(await cua.requestMouseControl()).toBe(false) + expect(await cua.requestMouseControl()).toBe(true) + expect(dispatch.mock.calls).toEqual(Array(2).fill([{ method: 'requestMouseControl', params: {} }])) +}) diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx index f9ec74239c..b6bc9d3d7c 100644 --- a/packages/experimental/cua/tests/application-approval.client.spec.tsx +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, expect, it, vi } from 'vitest' import type { ApprovalCardInput } from '@deepseek-ai/dsh-client-ui-approval/client' import { PendingQuestion, createWaterfallRequest } from '@deepseek-ai/dsh-client-ui-user-questions/src/client/contract/slots.ts' import { ApplicationApproval, selectApplicationApproval, type ApplicationApprovalProps } from '../src/client/ApplicationApproval.tsx' +import { mouseControlDisplay } from '../src/locales.ts' import { en, zh } from '../src/client/locales.ts' type AskUserQuestionItem = PendingQuestion['questions'][number] @@ -84,3 +85,16 @@ it('uses the original question cancellation and refuses late approval', async () expect(card.canAnswer()).toBe(false) await expect(card.onAllow()).rejects.toThrow('no channel accepts an answer yet') }) + +it('shows the mouse interruption and expiry with only session approval and refusal', () => { + const display = mouseControlDisplay + const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en, + detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }]) + const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) + render( text.zh } as ApplicationApprovalProps} />) + const card = factory.mock.calls[0]![1] + expect(card).toMatchObject({ title: display.question.zh, description: display.detail.zh, allowLabel: display.approve.zh }) + expect(card.onAlways).toBeUndefined() + expect(card.warning).toBeUndefined() +}) diff --git a/snapshots/session/cua-native/replay.override.json b/snapshots/session/cua-native/replay.override.json index b43a6e20f0..e6bf49a2c3 100644 --- a/snapshots/session/cua-native/replay.override.json +++ b/snapshots/session/cua-native/replay.override.json @@ -1418,6 +1418,72 @@ } ] }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-pointer-request", + "name": "cua", + "arguments": "{\"method\":\"requestMouseControl\",\"params\":{}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-system-hover", + "name": "cua", + "arguments": "{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/snapshots/session/cua-native/workspace/native-helper.mjs b/snapshots/session/cua-native/workspace/native-helper.mjs index 1e148d7ab5..e9a972a7eb 100755 --- a/snapshots/session/cua-native/workspace/native-helper.mjs +++ b/snapshots/session/cua-native/workspace/native-helper.mjs @@ -47,7 +47,6 @@ function serve() { const server = createServer({ allowHalfOpen: true }, socket => { sockets.add(socket) let connected = false - let systemPointerHover const connectionId = ++connection const input = createInterface({ input: socket }) const scopedRecord = (event, data = {}) => record(event, { connection: connectionId, ...data }) @@ -59,7 +58,6 @@ function serve() { socket.destroy() return } - systemPointerHover = request.config.systemPointerHover connected = true hadClient = true clearTimeout(startupDeadline) @@ -73,7 +71,7 @@ function serve() { } let response try { - const result = call(request.id, request.method, request.params, systemPointerHover) + const result = call(request.id, request.method, request.params) if (result === undefined) return response = { id: request.id, result } } catch (error) { @@ -124,7 +122,7 @@ function createApplicationState(socket, record, application, captureBroker, conn let observationChanged = false let malformedScreenshot = false function refuse(code, message) { throw Object.assign(new Error(message), { code }) } - function call(requestId, method, params, systemPointerHover) { + function call(requestId, method, params) { record('call', { requestId, method, params }) if (method === 'applicationIcons') return Object.fromEntries(params.paths.map(path => [path, `data:image/png;base64,${png}`])) if (method === 'openPermissionSettings') return { opened: true } @@ -204,11 +202,9 @@ function createApplicationState(socket, record, application, captureBroker, conn let systemPointerMoved = false if (method === 'mouseMove') { const alternate = params.elementId === '0' || (params.x >= 20 && params.x <= 40) - if (!alternate && !application.hovering) { - if (!(systemPointerHover ?? params.allowSystemPointer)) refuse('HOVER_UNAVAILABLE', 'No AX alternate UI at this target. No input was sent and the pointer did not move.') - systemPointerMoved = true - } - application.hovering = alternate + systemPointerMoved = params.useSystemPointer === true + if (!systemPointerMoved && !alternate && !application.hovering) refuse('HOVER_UNAVAILABLE', 'No AX alternate UI at this target. No input was sent and the pointer did not move.') + application.hovering = !systemPointerMoved && alternate } else if (method === 'click' && params.windowId === 8) application.visualCount++ else if (method === 'click') application.count++ From 256e1ae370e2668dd1998e7021525eb72a1020b3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 12:30:49 +0800 Subject: [PATCH 078/149] feat(forge): read pull request review threads at the displayed head ForgeProvider gains the optional readReview(ref, headSha, signal), which returns ForgeReviewData only when the read names headSha as the head and a stale failure otherwise. Each thread carries a current position on the head and the original position it was written at; ForgeFailure gains the unsupported code that ctx.forge.readReview returns for a provider without review reads. forge-github reads the threads, the head, and the viewer in one GraphQL query, pages threads up to maxReviewThreads and comments to their end, re-checks the head on every following thread page, and bounds all pages by maxOutputBytes. A thread is placed at the head only when GitHub keeps it current and its first comment is on the head commit. The forge panel adds the changeRequestReview Remote, cached per change request by head and update time and read again after reviewRefreshMs, since resolving a thread does not change a pull request's updatedAt. --- .../src/client/ChangeRequestCard.tsx | 1 + .../experimental/client-ui-forge/src/index.ts | 69 ++++- .../client-ui-forge/src/review-cache.ts | 73 +++++ .../experimental/client-ui-forge/src/types.ts | 16 +- .../tests/review-cache.host.spec.ts | 51 ++++ .../client-ui-forge/tests/review.host.spec.ts | 137 ++++++++++ .../client-ui-forge/tsconfig.host.json | 1 + .../experimental/forge-github/src/provider.ts | 20 +- .../experimental/forge-github/src/threads.ts | 209 ++++++++++++++ .../forge-github/tests/threads.spec.ts | 256 ++++++++++++++++++ packages/experimental/forge/src/index.ts | 36 ++- packages/experimental/forge/src/types.ts | 90 +++++- .../experimental/forge/tests/registry.spec.ts | 16 ++ .../experimental/forge/tests/watch.spec.ts | 25 +- 14 files changed, 980 insertions(+), 20 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/review-cache.ts create mode 100644 packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/review.host.spec.ts create mode 100644 packages/experimental/forge-github/src/threads.ts create mode 100644 packages/experimental/forge-github/tests/threads.spec.ts diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx index 5a90b9e6b3..a5f1abbab2 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -76,6 +76,7 @@ const FAILURE: Record = { 'unauthenticated': 'change.failure.unauthenticated', 'too-large': 'change.failure.unavailable', 'stale': 'change.failure.unavailable', + 'unsupported': 'change.failure.unavailable', 'unavailable': 'change.failure.unavailable', } diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 708eb757f4..6a1baaa71d 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-api-remote-access' import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { - ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewRequest, ForgeReviewResult, - MergeMethod, + ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewData, ForgeReviewRequest, + ForgeReviewResult, MergeMethod, } from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' import type { WorktreeCleanup } from '@deepseek-ai/dsh-experimental-worktree/types' @@ -30,6 +30,7 @@ import { rangeStart } from './commit-range.ts' import { ATTRIBUTES_PATH, FileLayers } from './layers.ts' import { missingWorktree, SessionPanel } from './panel.ts' import { PatchCache } from './patch-cache.ts' +import { ReviewCache } from './review-cache.ts' import { ChangeRequestStates, changeRequestStatesDomain } from './states.ts' import type { PanelConfig } from './panel.ts' import type { @@ -85,6 +86,8 @@ export class ForgePanelController extends TypertRemoteService { maxCommits: z.number().default(250), testPaths: z.array(z.string()).default([]), interfacePaths: z.array(z.string()).default([]), + maxCachedReviewBytes: z.number().default(4_194_304), + reviewRefreshMs: z.number().default(60_000), }) private readonly home: Context @@ -94,6 +97,7 @@ export class ForgePanelController extends TypertRemoteService { private creations!: WorktreeCreations private readonly patches: PatchCache private readonly layers: FileLayers + private readonly reviews: ReviewCache private states: ChangeRequestStates | undefined private readonly stopPanels: () => Promise private readonly stopStates: () => Promise @@ -102,13 +106,13 @@ export class ForgePanelController extends TypertRemoteService { super(ctx, 'forgePanel') const resolved = config as Required if (resolved.gitCommand.trim().length === 0) throw new Error('forge-panel: gitCommand must not be empty') - for (const key of ['gitTimeoutMs', 'graceMs', 'maxOutputBytes', 'readinessTimeoutMs'] as const) { + for (const key of ['gitTimeoutMs', 'graceMs', 'maxOutputBytes', 'readinessTimeoutMs', 'reviewRefreshMs'] as const) { const value = resolved[key] if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { throw new Error(`forge-panel: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) } } - for (const key of ['maxBranches', 'maxDiffBytes', 'maxUntrackedFiles', 'maxCachedDiffBytes', 'maxCommits'] as const) { + for (const key of ['maxBranches', 'maxDiffBytes', 'maxUntrackedFiles', 'maxCachedDiffBytes', 'maxCommits', 'maxCachedReviewBytes'] as const) { if (!Number.isSafeInteger(resolved[key]) || resolved[key] <= 0) throw new Error(`forge-panel: ${key} must be a positive integer`) } if (!branchPrefix(resolved.worktreePrefix)) throw new Error('forge-panel: worktreePrefix must start a valid Git branch name') @@ -119,6 +123,7 @@ export class ForgePanelController extends TypertRemoteService { this.home = ctx this.config = resolved this.patches = new PatchCache(resolved.maxCachedDiffBytes) + this.reviews = new ReviewCache(resolved.maxCachedReviewBytes, resolved.reviewRefreshMs) this.stopPanels = ctx.effect(() => async () => { const panels = [...this.panels.values()] this.panels.clear() @@ -458,6 +463,42 @@ export class ForgePanelController extends TypertRemoteService { return text } + /** + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved + * since, which do not change a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ + @Remote + async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise { + signal.throwIfAborted() + const { ref, update } = this.bound(session, url) + if (update === null || !('snapshot' in update)) { + throw new RemoteError('forgePanel/review-failed', 'The change request status has not been read yet', {}) + } + if (update.snapshot.headSha !== headSha) { + throw new RemoteError('forgePanel/stale-head', `The change request head moved from ${headSha} to ${update.snapshot.headSha}`, {}) + } + const key = changeRequestKey(ref) + const version = JSON.stringify([headSha, update.snapshot.updatedAt]) + const readAt = Date.now() + const kept = this.reviews.get(key, version, readAt) + if (kept !== undefined) return kept + const review = await this.home.forge.readReview(ref, headSha, signal) + if ('code' in review) throw reviewError(review) + this.reviews.set(key, version, review, readAt) + return review + } + /** * Publish a line comment or completed review for a visible Session binding. * @param session - Session retaining the change request. @@ -822,12 +863,32 @@ function readError(failure: ForgeFailure): RemoteError { case 'stale': return new RemoteError('forgePanel/stale-head', failure.message, {}) case 'not-found': case 'unauthenticated': + case 'unsupported': case 'unavailable': return new RemoteError('forgePanel/diff-failed', failure.message, {}) /* v8 ignore next -- closed ForgeFailure code union exhaustiveness guard */ default: return assertNever(failure.code, 'ForgeFailure.code') } } +/** + * Report why the code host did not provide review threads. + * @param failure - the provider's failure. + * @returns `forgePanel/stale-head` for a head that moved, `forgePanel/review-unsupported` for a provider without + * review reads, else `forgePanel/review-failed`, with the provider's message. + */ +function reviewError(failure: ForgeFailure): RemoteError { + switch (failure.code) { + case 'stale': return new RemoteError('forgePanel/stale-head', failure.message, {}) + case 'unsupported': return new RemoteError('forgePanel/review-unsupported', failure.message, {}) + case 'not-found': + case 'unauthenticated': + case 'too-large': + case 'unavailable': return new RemoteError('forgePanel/review-failed', failure.message, {}) + /* v8 ignore next -- closed ForgeFailure code union exhaustiveness guard */ + default: return assertNever(failure.code, 'ForgeFailure.code') + } +} + /** * Settle a diff read, reporting its size limit, its filter configuration, a missing fork point, a missing base branch, and chosen * commits that are not on the branch as Remote errors. diff --git a/packages/experimental/client-ui-forge/src/review-cache.ts b/packages/experimental/client-ui-forge/src/review-cache.ts new file mode 100644 index 0000000000..6a8d9f0d1e --- /dev/null +++ b/packages/experimental/client-ui-forge/src/review-cache.ts @@ -0,0 +1,73 @@ +/** + * Review threads of change requests kept in memory, so the changes view reads them from the code host again only + * after the change request's head or update time changes or the kept threads reach their age limit. + */ + +import { Buffer } from 'node:buffer' +import type { ChangeRequestKey, ForgeReviewData } from '@deepseek-ai/dsh-experimental-forge' + +interface Entry { + /** Head commit and update time the threads were read at. */ + readonly version: string + readonly review: ForgeReviewData + /** Epoch milliseconds of the read. */ + readonly readAt: number + /** UTF-8 size of the threads as JSON. */ + readonly bytes: number +} + +/** + * One read of review threads per change request, bounded by age and by the total UTF-8 size of the kept reads as + * JSON; the least recently read change request leaves first. + */ +export class ReviewCache { + private readonly entries = new Map() + private bytes = 0 + + /** + * @param maxBytes - most UTF-8 bytes kept together; a larger read is not kept. + * @param maxAgeMs - age in milliseconds at which a kept read is no longer returned. + */ + constructor(private readonly maxBytes: number, private readonly maxAgeMs: number) {} + + /** + * Read the threads kept for a change request at a version while they are younger than the age limit, marking them + * the most recently read. + * @param key - the change request. + * @param version - head commit and update time the reader needs. + * @param now - current epoch milliseconds. + * @returns the threads, or undefined when none are kept at that version or they reached the age limit. + */ + get(key: ChangeRequestKey, version: string, now: number): ForgeReviewData | undefined { + const entry = this.entries.get(key) + if (entry?.version !== version || now - entry.readAt >= this.maxAgeMs) return undefined + this.entries.delete(key) + this.entries.set(key, entry) + return entry.review + } + + /** + * Keep a read in place of the change request's earlier one, then drop the least recently read change requests + * until the rest fit within `maxBytes`. + * @param key - the change request. + * @param version - head commit and update time the threads were read at. + * @param review - the threads. + * @param readAt - epoch milliseconds when the read started. + */ + set(key: ChangeRequestKey, version: string, review: ForgeReviewData, readAt: number): void { + const previous = this.entries.get(key) + if (previous !== undefined) { + this.entries.delete(key) + this.bytes -= previous.bytes + } + const bytes = Buffer.byteLength(JSON.stringify(review)) + if (bytes > this.maxBytes) return + this.entries.set(key, { version, review, readAt, bytes }) + this.bytes += bytes + for (const [oldest, entry] of this.entries) { + if (this.bytes <= this.maxBytes) break + this.entries.delete(oldest) + this.bytes -= entry.bytes + } + } +} diff --git a/packages/experimental/client-ui-forge/src/types.ts b/packages/experimental/client-ui-forge/src/types.ts index 11dc7549e3..1588277207 100644 --- a/packages/experimental/client-ui-forge/src/types.ts +++ b/packages/experimental/client-ui-forge/src/types.ts @@ -414,6 +414,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } declare module '@deepseek-ai/dsh-typert-protocol' { @@ -438,8 +445,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'forgePanel/not-bound': Record /** * The change request's head is no longer the commit the request named: the head the user confirmed for a merge, - * the head displayed for complete checks, or the head of the latest status for a diff. A diff read polls the change - * request again at once, so a later status names the current head. + * the head displayed for complete checks, the head of the latest status for a diff, or the head of the displayed + * diff for review threads. A diff or review read the host refuses polls the change request again at once, so a + * later status names the current head. */ 'forgePanel/stale-head': Record /** The code host refused the change or could not be reached; the message gives the host's reason. */ @@ -468,6 +476,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'forgePanel/commits-missing': Record /** A chosen commit after the first lacks the commit listed before it as its first parent, so two ends cannot name the run. */ 'forgePanel/commits-not-linear': Record + /** The code host's provider cannot read review threads, so the changes view shows none. */ + 'forgePanel/review-unsupported': Record + /** The code host did not provide the change request's review threads, or its status has not been read yet; the message says why. */ + 'forgePanel/review-failed': Record } } diff --git a/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts new file mode 100644 index 0000000000..de498b8bbf --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { Buffer } from 'node:buffer' +import type { ChangeRequestKey, ForgeReviewData } from '@deepseek-ai/dsh-experimental-forge' +import { ReviewCache } from '../src/review-cache.ts' + +const a = 'fake:a#1' as ChangeRequestKey +const b = 'fake:b#2' as ChangeRequestKey +const c = 'fake:c#3' as ChangeRequestKey + +function review(viewer: string): ForgeReviewData { + return { headSha: 'a'.repeat(40), viewer, threads: [], truncated: false } +} + +const size = Buffer.byteLength(JSON.stringify(review('x'))) + +describe('ReviewCache', () => { + it('returns threads kept at the same version until they reach the age limit', () => { + const cache = new ReviewCache(size * 4, 100) + cache.set(a, 'v1', review('x'), 1_000) + + expect(cache.get(a, 'v1', 1_099)).toEqual(review('x')) + expect(cache.get(a, 'v2', 1_050)).toBeUndefined() + expect(cache.get(b, 'v1', 1_050)).toBeUndefined() + expect(cache.get(a, 'v1', 1_100)).toBeUndefined() + }) + + it('replaces a change request\'s earlier read and drops the least recently read reads past the byte limit', () => { + const cache = new ReviewCache(size * 2, 100) + cache.set(a, 'v1', review('x'), 0) + cache.set(a, 'v2', review('y'), 0) + cache.set(b, 'v1', review('z'), 0) + expect(cache.get(a, 'v1', 0)).toBeUndefined() + expect(cache.get(a, 'v2', 0)).toEqual(review('y')) + + cache.set(c, 'v1', review('w'), 0) + expect(cache.get(b, 'v1', 0)).toBeUndefined() + expect(cache.get(a, 'v2', 0)).toEqual(review('y')) + expect(cache.get(c, 'v1', 0)).toEqual(review('w')) + }) + + it('keeps no read larger than the byte limit and forgets the read it replaced', () => { + const cache = new ReviewCache(size, 100) + cache.set(a, 'v1', review('x'), 0) + cache.set(a, 'v2', review('longer'), 0) + + expect(cache.get(a, 'v1', 0)).toBeUndefined() + expect(cache.get(a, 'v2', 0)).toBeUndefined() + cache.set(b, 'v1', review('x'), 0) + expect(cache.get(b, 'v1', 0)).toEqual(review('x')) + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/review.host.spec.ts b/packages/experimental/client-ui-forge/tests/review.host.spec.ts new file mode 100644 index 0000000000..6869d3acb6 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review.host.spec.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ChangeRequestRef, ForgeFailure, ForgeReviewData, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge' +import { SessionId } from '@deepseek-ai/dsh-session' +import ForgePanelController from '../src/index.ts' +import type { ForgePanelState } from '../src/index.ts' +import { bench, follow, git, pullUrl, repository, scratch, shellTurn } from './harness.ts' +import type { Bench, Follower } from './harness.ts' + +const cleanups: (() => Promise)[] = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +const signal = new AbortController().signal +const HEAD = 'a'.repeat(40) + +async function opened(config: Parameters[1] = {}): Promise<{ opened: Bench; follower: Follower; repo: string }> { + const { path, remove } = await scratch('dsh-forge-review-') + cleanups.push(remove) + const repo = await repository(path) + await git(repo, 'remote', 'add', 'origin', 'https://fake.test/octo/demo.git') + const result = await bench(repo, config) + cleanups.push(() => result.ctx.fiber.dispose()) + const follower = follow(result.ctx, result.session) + cleanups.push(follower.stop) + return { opened: result, follower, repo } +} + +function review(headSha: string, path = 'src/a.ts'): ForgeReviewData { + return { + headSha, viewer: 'octo', truncated: false, + threads: [{ + id: 'thread-1' as ForgeThreadId, path, side: 'R', subject: 'line', resolved: false, diffHunk: '@@ -1 +1 @@\n-old\n+new', + current: { commit: headSha, startLine: 1, endLine: 1 }, original: { commit: headSha, startLine: 1, endLine: 1 }, comments: [], + }], + } +} + +/** Whether the change request at `url` holds a snapshot with the given head and update time. */ +function polledAt(state: ForgePanelState, url: string, headSha = HEAD, updatedAt?: string): boolean { + const snapshot = state.changeRequests.find(entry => entry.url === url)?.snapshot ?? null + return snapshot !== null && snapshot.headSha === headSha && (updatedAt === undefined || snapshot.updatedAt === updatedAt) +} + +describe('forgePanel change request reviews', () => { + it('reads threads at the displayed head and reuses them for every Session until the update time changes', async () => { + const { opened: panel, follower, repo } = await opened() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + Object.assign(panel.provider, { readReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)).resolves.toEqual(review(HEAD)) + const other = panel.ctx.sessions.create(SessionId('forge-review-other'), { meta: { cwd: repo } }) + panel.ctx.forgeSession.track(other, pullUrl(7)) + await expect(panel.ctx.forgePanel.changeRequestReview(other, pullUrl(7), HEAD, signal)).resolves.toEqual(review(HEAD)) + expect(readReview).toHaveBeenCalledTimes(1) + + panel.provider.overrides.set(7, { updatedAt: '2026-11-07T05:00:00Z' }) + await panel.ctx.forgePanel.refresh(panel.session, signal) + await follower.until(state => polledAt(state, pullUrl(7), HEAD, '2026-11-07T05:00:00Z')) + readReview.mockResolvedValueOnce(review(HEAD, 'src/b.ts')) + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)) + .resolves.toMatchObject({ threads: [{ path: 'src/b.ts' }] }) + expect(readReview).toHaveBeenCalledTimes(2) + }) + + it('reads again once the kept threads reach reviewRefreshMs', async () => { + const { opened: panel, follower } = await opened({ reviewRefreshMs: 1 }) + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + Object.assign(panel.provider, { readReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await new Promise(resolve => setTimeout(resolve, 5)) + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(2) + }) + + it('refuses a head other than the latest status without reading, and polls again when the host names another head', async () => { + const { opened: panel, follower } = await opened() + const pushed = 'b'.repeat(40) + const readReview = vi.fn(async (): Promise => ({ code: 'stale', message: `head moved to ${pushed}` })) + Object.assign(panel.provider, { readReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), pushed, signal)) + .rejects.toMatchObject({ code: 'forgePanel/stale-head', message: `The change request head moved from ${pushed} to ${HEAD}` }) + expect(readReview).not.toHaveBeenCalled() + + panel.provider.overrides.set(7, { headSha: pushed }) + const fetches = panel.provider.fetches.length + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)) + .rejects.toMatchObject({ code: 'forgePanel/stale-head', message: `head moved to ${pushed}` }) + await follower.until(state => polledAt(state, pullUrl(7), pushed)) + expect(panel.provider.fetches.length).toBe(fetches + 1) + }) + + it('reports an unbound change request, one not read yet, a provider without review reads, and a failed read, keeping no failure', async () => { + const { opened: panel, follower } = await opened() + panel.provider.failures.set(8, { code: 'unavailable', message: 'down' }) + shellTurn(panel.session, 1, [ + { callId: 'seven', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }, + { callId: 'eight', command: 'gh pr create --fill', output: `${pullUrl(8)}\n` }, + ]) + await follower.until(state => polledAt(state, pullUrl(7)) + && state.changeRequests.some(entry => entry.url === pullUrl(8) && entry.update !== null && 'failure' in entry.update)) + const read = (url: string): Promise => panel.ctx.forgePanel.changeRequestReview(panel.session, url, HEAD, signal) + + await expect(read(pullUrl(9))).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) + await expect(read(pullUrl(8))).rejects.toMatchObject({ code: 'forgePanel/review-failed', message: 'The change request status has not been read yet' }) + await expect(read(pullUrl(7))) + .rejects.toMatchObject({ code: 'forgePanel/review-unsupported', message: 'This provider does not support review reads' }) + const codes = ['too-large', 'not-found', 'unauthenticated', 'unavailable'] as const + const readReview = vi.fn(async (): Promise => ({ code: 'unavailable', message: 'down' })) + Object.assign(panel.provider, { readReview }) + for (const code of codes) { + readReview.mockResolvedValueOnce({ code, message: `threads ${code}` }) + await expect(read(pullUrl(7))).rejects.toMatchObject({ code: 'forgePanel/review-failed', message: `threads ${code}` }) + } + expect(readReview).toHaveBeenCalledTimes(codes.length) + }) + + it.each([ + [{ reviewRefreshMs: 0 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], + [{ reviewRefreshMs: 2_147_483_648 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], + [{ maxCachedReviewBytes: 1.5 }, 'forge-panel: maxCachedReviewBytes must be a positive integer'], + ])('rejects %j at load', async (config, message) => { + const { opened: panel } = await opened() + await panel.panel.dispose() + + await expect(panel.ctx.plugin(ForgePanelController, config)).rejects.toThrow(message) + }) +}) diff --git a/packages/experimental/client-ui-forge/tsconfig.host.json b/packages/experimental/client-ui-forge/tsconfig.host.json index a6c004f078..051f252042 100644 --- a/packages/experimental/client-ui-forge/tsconfig.host.json +++ b/packages/experimental/client-ui-forge/tsconfig.host.json @@ -15,6 +15,7 @@ "src/panel.ts", "src/parse.ts", "src/patch-cache.ts", + "src/review-cache.ts", "src/states.ts", "src/types.ts", "src/wildmatch.ts", diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index 67e963e3d9..cf6905149e 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -3,7 +3,7 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeProvider, - ForgeReadiness, ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, MergeRequest, } from '@deepseek-ai/dsh-experimental-forge' import { listCommits } from './commits.ts' import type { GraphqlReader } from './commits.ts' @@ -17,6 +17,7 @@ import { import type { PageResult, SnapshotPages } from './graphql.ts' import { checkedRepository, isCommit, parsePullRequestUrl, parseRemoteUrl } from './urls.ts' import { submitGitHubReview } from './review.ts' +import { readReviewThreads } from './threads.ts' const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i @@ -34,9 +35,9 @@ const NOT_ACCEPTABLE = '406' /** Bounds on what one read retains or counts. */ export interface GitHubLimits { - /** Maximum combined JSON bytes retained by an explicit check read. */ + /** Maximum combined JSON bytes retained by an explicit check read or a review read. */ readonly maxOutputBytes: number - /** Most review threads counted per pull request in one status read. */ + /** Most review threads counted per pull request in one status read, and read in one review read. */ readonly maxReviewThreads: number /** Most commits one commit listing returns. */ readonly maxCommits: number @@ -53,10 +54,21 @@ export class GitHubProvider implements ForgeProvider { return await submitGitHubReview(this.gh, ref, request, signal) } + /** + * Read the review threads of a pull request at `headSha` through GraphQL, each page naming the head it was read at. + * @see readReviewThreads + */ + async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) return failure + if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`) + return await readReviewThreads(this.graphql, ref, headSha, this.limits, signal) + } + /** * @param gh - runner that executes `gh` on the host. * @param hosts - host names this provider serves, such as `github.com` and Enterprise hosts. - * @param limits - bounds on retained check bytes, counted review threads, and listed commits. + * @param limits - bounds on retained check and review bytes, counted and read review threads, and listed commits. */ constructor(private readonly gh: GhRunner, hosts: readonly string[], private readonly limits: GitHubLimits) { this.hosts = new Set(hosts.map(host => host.toLowerCase())) diff --git a/packages/experimental/forge-github/src/threads.ts b/packages/experimental/forge-github/src/threads.ts new file mode 100644 index 0000000000..f23507e374 --- /dev/null +++ b/packages/experimental/forge-github/src/threads.ts @@ -0,0 +1,209 @@ +/** Reads the review threads of a GitHub pull request at one head commit through GraphQL. */ + +import { Buffer } from 'node:buffer' +import type { + ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewThread, ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge' +import { z } from 'zod' +import type { GraphqlReader } from './commits.ts' +import { checkedRepository, isCommit } from './urls.ts' + +/** Most nodes GitHub returns in one connection page. */ +const PAGE_SIZE = 100 + +const COMMENT_FIELDS = 'id state createdAt url body author { __typename login avatarUrl(size: 48) }' + +const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT_FIELDS} } }` + +/** + * Thread fields. `root` reads the commits and diff hunk of the first comment once, since replies repeat them; the + * first page of comments starts without a cursor. + */ +const THREAD_FIELDS = `id isOutdated isResolved path diffSide startDiffSide line startLine originalLine originalStartLine subjectType + root: comments(first: 1) { nodes { diffHunk commit { oid } originalCommit { oid } } } + ${COMMENT_PAGE.replace(', after: $cursor', '')}` + +const THREAD_PAGE = `pageInfo { hasNextPage endCursor } nodes { ${THREAD_FIELDS} }` + +/** First thread page with the head and the viewer; variables `owner` and `name` are strings, `number` and `first` integers. */ +export const REVIEW_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!) { + viewer { login } + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id headRefOid reviewThreads(first: $first) { ${THREAD_PAGE} } } } +}` + +/** Following thread page with the head it was read at; variables `id` and `cursor` are strings, `first` an integer. */ +export const REVIEW_THREADS_QUERY = `query($id: ID!, $cursor: String!, $first: Int!) { + node(id: $id) { ... on PullRequest { headRefOid reviewThreads(first: $first, after: $cursor) { ${THREAD_PAGE} } } } +}` + +/** Following comment page of one thread; variables `id` and `cursor` are strings. */ +export const THREAD_COMMENTS_QUERY = `query($id: ID!, $cursor: String!) { + node(id: $id) { ... on PullRequestReviewThread { ${COMMENT_PAGE} } } +}` + +/** Bounds of one review read. */ +export interface ReviewLimits { + /** Most threads read. */ + readonly maxReviewThreads: number + /** Most JSON bytes of all pages together; a page that would pass it ends the read as truncated. */ + readonly maxOutputBytes: number +} + +const pageInfoSchema = z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }) + +const commitSchema = z.object({ oid: z.string().refine(isCommit) }).nullable() + +const commentSchema = z.object({ + id: z.string(), + state: z.string(), + createdAt: z.string(), + url: z.string().nullable(), + body: z.string(), + author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string().nullable() }).nullable(), +}) + +const commentPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(commentSchema) }) + +const threadSchema = z.object({ + id: z.string(), + isOutdated: z.boolean(), + isResolved: z.boolean(), + path: z.string(), + diffSide: z.string(), + startDiffSide: z.string().nullable(), + line: z.number().int().nullable(), + startLine: z.number().int().nullable(), + originalLine: z.number().int().nullable(), + originalStartLine: z.number().int().nullable(), + subjectType: z.string(), + root: z.object({ nodes: z.array(z.object({ diffHunk: z.string(), commit: commitSchema, originalCommit: commitSchema })) }), + comments: commentPageSchema, +}) + +const threadPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(threadSchema) }) + +const firstPageSchema = z.object({ data: z.object({ + viewer: z.object({ login: z.string() }).nullable(), + repository: z.object({ pullRequest: z.object({ id: z.string(), headRefOid: z.string(), reviewThreads: threadPageSchema }) }), +}) }) + +const nextPageSchema = z.object({ data: z.object({ node: z.object({ headRefOid: z.string(), reviewThreads: threadPageSchema }) }) }) + +const commentsPageSchema = z.object({ data: z.object({ node: z.object({ comments: commentPageSchema }) }) }) + +type ThreadNode = z.infer +type CommentNode = z.infer + +/** + * Read the review threads of a pull request while its head is `headSha`. The first page also names the head and the + * signed-in user; every following thread page names the head again, so a head that moves during the read returns + * `stale`. Threads are read up to `maxReviewThreads`, each with all of its comments. A page that would bring the + * pages read, measured as JSON, past `maxOutputBytes` is dropped with the threads after it, and the result is marked truncated. + * @param read - runs one GraphQL request. + * @param ref - pull request, with an owner and name already checked for inlining. + * @param headSha - head commit the threads must be read at. + * @param limits - thread and byte bounds. + * @param signal - cancellation of the read. + * @returns the threads, or why GitHub did not provide them at `headSha`. + */ +export async function readReviewThreads( + read: GraphqlReader, ref: ChangeRequestRef, headSha: string, limits: ReviewLimits, signal: AbortSignal, +): Promise { + const { host, owner, name } = checkedRepository(ref) + const graphql = async (variables: readonly string[]): Promise<{ readonly body: unknown; readonly bytes: number } | ForgeFailure> => { + const output = await read(['api', 'graphql', '--hostname', host, ...variables], signal) + if (!output.ok) return output.failure + return { body: output.body, bytes: Buffer.byteLength(JSON.stringify(output.body)) } + } + const unexpected: ForgeFailure = { code: 'unavailable', message: 'unexpected GitHub response to the review thread read' } + const stale = (head: string): ForgeFailure | undefined => head.toLowerCase() === headSha.toLowerCase() ? undefined + : { code: 'stale', message: `the pull request head moved from ${headSha} to ${head}` } + const first = await graphql(['-f', `query=${REVIEW_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, + '-F', `number=${ref.number}`, '-F', `first=${Math.min(PAGE_SIZE, limits.maxReviewThreads)}`]) + if ('code' in first) return first + const parsed = firstPageSchema.safeParse(first.body) + if (!parsed.success) return unexpected + const pull = parsed.data.data.repository.pullRequest + const moved = stale(pull.headRefOid) + if (moved !== undefined) return moved + let bytes = first.bytes + let page = pull.reviewThreads + const threads: ForgeReviewThread[] = [] + const viewer = parsed.data.data.viewer?.login ?? null + const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated }) + const cursors = new Set() + for (;;) { + for (const node of page.nodes.slice(0, limits.maxReviewThreads - threads.length)) { + const comments = [...node.comments.nodes] + const commentCursors = new Set() + let more = node.comments.pageInfo + while (more.hasNextPage) { + const cursor = more.endCursor + if (cursor === null || commentCursors.has(cursor)) return { code: 'unavailable', message: 'GitHub review comment pagination did not advance' } + commentCursors.add(cursor) + const next = await graphql(['-f', `query=${THREAD_COMMENTS_QUERY}`, '-f', `id=${node.id}`, '-f', `cursor=${cursor}`]) + if ('code' in next) return next + const comment = commentsPageSchema.safeParse(next.body) + if (!comment.success) return unexpected + bytes += next.bytes + if (bytes > limits.maxOutputBytes) return result(true) + comments.push(...comment.data.data.node.comments.nodes) + more = comment.data.data.node.comments.pageInfo + } + if (comments.length > 0) threads.push(toThread(node, comments, headSha)) + } + if (threads.length >= limits.maxReviewThreads || !page.pageInfo.hasNextPage) return result(page.pageInfo.hasNextPage) + const cursor = page.pageInfo.endCursor + if (cursor === null || cursors.has(cursor)) return { code: 'unavailable', message: 'GitHub review thread pagination did not advance' } + cursors.add(cursor) + const next = await graphql(['-f', `query=${REVIEW_THREADS_QUERY}`, '-f', `id=${pull.id}`, '-f', `cursor=${cursor}`, + '-F', `first=${Math.min(PAGE_SIZE, limits.maxReviewThreads - threads.length)}`]) + if ('code' in next) return next + const following = nextPageSchema.safeParse(next.body) + if (!following.success) return unexpected + const movedLater = stale(following.data.data.node.headRefOid) + if (movedLater !== undefined) return movedLater + bytes += next.bytes + if (bytes > limits.maxOutputBytes) return result(true) + page = following.data.data.node.reviewThreads + } +} + +/** + * Map one thread. GitHub keeps a thread's `line` on the commit of its first comment, which stays an older commit for + * a comment written on a commit the head no longer is; such a thread, like one GitHub marks outdated or leaves + * without a line, has no position at `head`. A range whose start is on the other side of the diff keeps its last line. + */ +function toThread(node: ThreadNode, comments: readonly CommentNode[], head: string): ForgeReviewThread { + const root = node.root.nodes[0] + const line = node.subjectType !== 'FILE' + const sameSide = node.startDiffSide === null || node.startDiffSide === node.diffSide + const placed = line && !node.isOutdated && node.line !== null && root?.commit?.oid.toLowerCase() === head.toLowerCase() + return { + id: node.id as ForgeThreadId, + path: node.path, + side: node.diffSide === 'LEFT' ? 'L' : 'R', + subject: line ? 'line' : 'file', + current: placed && node.line !== null ? anchor(head, sameSide ? node.startLine : null, node.line) : null, + original: line && node.originalLine !== null && root?.originalCommit != null + ? anchor(root.originalCommit.oid, sameSide ? node.originalStartLine : null, node.originalLine) : null, + diffHunk: root === undefined || root.diffHunk === '' ? null : root.diffHunk, + resolved: node.isResolved, + comments: comments.map(toComment), + } +} + +function anchor(commit: string, start: number | null, end: number): ForgeAnchor { + return { commit, startLine: start !== null && start >= 1 && start <= end ? start : end, endLine: end } +} + +function toComment(node: CommentNode): ForgeReviewComment { + return { + id: node.id as ForgeCommentId, + author: node.author === null ? null : { login: node.author.login, avatarUrl: node.author.avatarUrl, bot: node.author.__typename === 'Bot' }, + body: node.body, + createdAt: node.createdAt, + pending: node.state === 'PENDING', + url: node.url, + } +} diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts new file mode 100644 index 0000000000..f90c18b0a0 --- /dev/null +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' +import type { GhOutput, GhRunner } from '../src/gh.ts' +import { GitHubProvider } from '../src/provider.ts' +import type { GitHubLimits } from '../src/provider.ts' +import { REVIEW_QUERY, REVIEW_THREADS_QUERY, THREAD_COMMENTS_QUERY } from '../src/threads.ts' +import { demo, pull } from './fixtures.ts' + +const head = 'c'.repeat(40) +const older = 'a'.repeat(40) +const pushed = 'd'.repeat(40) +const signal = new AbortController().signal + +function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput { + return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr } +} + +/** Answer each `gh` call through `handler`, recording the arguments. */ +function fakeGh(handler: (args: readonly string[]) => GhOutput): { run: GhRunner; calls: string[][] } { + const calls: string[][] = [] + return { calls, run: async (args) => { calls.push([...args]); return handler(args) } } +} + +function provider(run: GhRunner, limits: Partial = {}): GitHubProvider { + return new GitHubProvider(run, ['github.com'], { maxOutputBytes: 8_388_608, maxReviewThreads: 1_000, maxCommits: 250, ...limits }) +} + +function page(nodes: readonly unknown[], next: string | null = null): Record { + return { pageInfo: { hasNextPage: next !== null, endCursor: next }, nodes } +} + +/** One review comment node as the thread query selects it. */ +function commentNode(id: string, overrides: Record = {}): Record { + return { + id, state: 'SUBMITTED', createdAt: '2026-11-05T08:00:00Z', url: `https://github.com/octo/demo/pull/7#discussion_${id}`, body: `Body of ${id}`, + author: { __typename: 'User', login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4' }, + ...overrides, + } +} + +/** One review thread node on a new-side line at the head, with one comment. */ +function threadNode(id: string, overrides: Record = {}, root: Record = {}): Record { + return { + id, isOutdated: false, isResolved: false, path: 'src/a.ts', diffSide: 'RIGHT', startDiffSide: null, + line: 12, startLine: null, originalLine: 12, originalStartLine: null, subjectType: 'LINE', + root: { nodes: [{ diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', commit: { oid: head }, originalCommit: { oid: head }, ...root }] }, + comments: page([commentNode(`${id}-1`)]), + ...overrides, + } +} + +function firstPage(threads: Record, headRefOid = head, viewer: unknown = { login: 'octo' }): Record { + return { data: { viewer, repository: { pullRequest: { id: 'pull-7', headRefOid, reviewThreads: threads } } } } +} + +function nextPage(threads: Record, headRefOid = head): Record { + return { data: { node: { headRefOid, reviewThreads: threads } } } +} + +/** Answer the first thread page with `first`, later thread pages from `later` by cursor, and comment pages from `comments` by cursor. */ +function review( + first: Record | GhOutput, + later: Readonly>> = {}, + comments: Readonly>> = {}, +): ReturnType { + return fakeGh((args) => { + const cursor = args.find(arg => arg.startsWith('cursor='))?.slice('cursor='.length) ?? '' + if (args.includes(`query=${REVIEW_QUERY}`)) return 'exitCode' in first ? first : output(first) + if (args.includes(`query=${REVIEW_THREADS_QUERY}`)) return output(later[cursor] ?? { data: { node: null } }) + if (args.includes(`query=${THREAD_COMMENTS_QUERY}`)) return output(comments[cursor] ?? { data: { node: null } }) + throw new Error(`unexpected gh ${args.join(' ')}`) + }) +} + +describe('GitHub provider review threads', () => { + it('reads the first page with the head, the viewer, and the pull request as variables, and maps a thread placed at the head', async () => { + const gh = review(firstPage(page([threadNode('t1', { isResolved: true })]))) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toEqual({ + headSha: head, + viewer: 'octo', + truncated: false, + threads: [{ + id: 't1', path: 'src/a.ts', side: 'R', subject: 'line', + current: { commit: head, startLine: 12, endLine: 12 }, + original: { commit: head, startLine: 12, endLine: 12 }, + diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', + resolved: true, + comments: [{ + id: 't1-1', body: 'Body of t1-1', createdAt: '2026-11-05T08:00:00Z', pending: false, + url: 'https://github.com/octo/demo/pull/7#discussion_t1-1', + author: { login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4', bot: false }, + }], + }], + }) + expect(gh.calls).toEqual([['api', 'graphql', '--hostname', 'github.com', '-f', `query=${REVIEW_QUERY}`, + '-f', `owner=${demo.owner}`, '-f', `name=${demo.name}`, '-F', 'number=7', '-F', 'first=100']]) + }) + + it('places a thread GitHub moved to the head at its current lines and keeps the lines it was written on', async () => { + const gh = review(firstPage(page([threadNode('moved', { line: 136, originalLine: 135 }, { originalCommit: { oid: older } })]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [{ + current: { commit: head, startLine: 136, endLine: 136 }, original: { commit: older, startLine: 135, endLine: 135 }, + }] }) + }) + + it.each([ + ['GitHub marks it outdated without a line', { isOutdated: true, line: null }, {}], + ['GitHub marks it outdated but keeps a line', { isOutdated: true, line: 303, startLine: 294 }, {}], + ['GitHub reports no line', { line: null }, {}], + ['its comment is on a commit other than the head', {}, { commit: { oid: older }, originalCommit: { oid: older } }], + ['its comment has no commit', {}, { commit: null }], + ] as const)('gives a thread no current position when %s', async (_label, thread, root) => { + const gh = review(firstPage(page([threadNode('old', { originalLine: 9, ...thread }, root)]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [{ current: null, original: { startLine: 9, endLine: 9 } }] }) + }) + + it('maps a file thread without positions or hunk, an old-side range, and a range that starts on the other side', async () => { + const gh = review(firstPage(page([ + threadNode('file', { subjectType: 'FILE', line: null, originalLine: null }, { diffHunk: '' }), + threadNode('left', { diffSide: 'LEFT', startDiffSide: 'LEFT', startLine: 3, line: 5, originalStartLine: 3, originalLine: 5 }), + threadNode('cross', { diffSide: 'RIGHT', startDiffSide: 'LEFT', startLine: 4, line: 6, originalStartLine: 4, originalLine: 6 }), + threadNode('reversed', { startLine: 8, line: 6, originalStartLine: 0, originalLine: 6 }), + ]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [ + { id: 'file', subject: 'file', side: 'R', current: null, original: null, diffHunk: null }, + { id: 'left', side: 'L', current: { startLine: 3, endLine: 5 }, original: { startLine: 3, endLine: 5 } }, + { id: 'cross', side: 'R', current: { startLine: 6, endLine: 6 }, original: { startLine: 6, endLine: 6 } }, + { id: 'reversed', current: { startLine: 6, endLine: 6 }, original: { startLine: 6, endLine: 6 } }, + ] }) + }) + + it('maps pending comments, bots, deleted authors, a missing viewer, and drops a thread without comments', async () => { + const gh = review(firstPage(page([ + threadNode('t1', { comments: page([ + commentNode('c1', { author: { __typename: 'Bot', login: 'ci', avatarUrl: null } }), + commentNode('c2', { state: 'PENDING', author: null, url: null }), + ]) }), + threadNode('empty', { comments: page([]) }, { diffHunk: '' }), + ]), head, null)) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ viewer: null, threads: [{ id: 't1', comments: [ + { id: 'c1', author: { login: 'ci', avatarUrl: null, bot: true }, pending: false }, + { id: 'c2', author: null, pending: true, url: null }, + ] }] }) + expect(result).toMatchObject({ threads: { length: 1 } }) + }) + + it('reads the following thread and comment pages, asking only for the threads left under maxReviewThreads', async () => { + const gh = review( + firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')), + { 'threads-1': nextPage(page([threadNode('t2')], 'threads-2')) }, + { 'comments-1': { data: { node: { comments: page([commentNode('c2')]) } } } }, + ) + + const result = await provider(gh.run, { maxReviewThreads: 2 }).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ truncated: true, threads: [ + { id: 't1', comments: [{ id: 'c1' }, { id: 'c2' }] }, { id: 't2' }, + ] }) + expect(gh.calls.slice(1)).toEqual([ + ['api', 'graphql', '--hostname', 'github.com', '-f', `query=${THREAD_COMMENTS_QUERY}`, '-f', 'id=t1', '-f', 'cursor=comments-1'], + ['api', 'graphql', '--hostname', 'github.com', '-f', `query=${REVIEW_THREADS_QUERY}`, '-f', 'id=pull-7', '-f', 'cursor=threads-1', '-F', 'first=1'], + ]) + expect(gh.calls[0]).toContain('first=2') + }) + + it('reports a head other than the requested commit as stale, on the first page or on a later one', async () => { + const moved = review(firstPage(page([threadNode('t1')]), pushed)) + await expect(provider(moved.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }) + + const later = review(firstPage(page([threadNode('t1')], 'threads-1')), { 'threads-1': nextPage(page([threadNode('t2')]), pushed) }) + await expect(provider(later.run).readReview(pull(7), head.toUpperCase(), signal)) + .resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head.toUpperCase()} to ${pushed}` }) + }) + + it('keeps the threads read before a page that passes maxOutputBytes and marks the result truncated', async () => { + const first = firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')) + const size = JSON.stringify(first).length + const comments = { 'comments-1': { data: { node: { comments: page([commentNode('c2')]) } } } } + const threads = { 'threads-1': nextPage(page([threadNode('t2')])) } + + const byComments = review(first, threads, comments) + await expect(provider(byComments.run, { maxOutputBytes: size + 10 }).readReview(pull(7), head, signal)) + .resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true }) + + const commentBytes = JSON.stringify(comments['comments-1']).length + const byThreads = review(first, threads, comments) + await expect(provider(byThreads.run, { maxOutputBytes: size + commentBytes + 10 }).readReview(pull(7), head, signal)) + .resolves.toMatchObject({ threads: [{ id: 't1' }], truncated: true }) + }) + + it.each([ + ['a thread page without a cursor', firstPage({ pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] }), {}, {}, + 'GitHub review thread pagination did not advance'], + ['a thread page that repeats its cursor', firstPage(page([], 'threads-1')), { 'threads-1': nextPage(page([], 'threads-1')) }, {}, + 'GitHub review thread pagination did not advance'], + ['a comment page that repeats its cursor', firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })])), {}, + { 'comments-1': { data: { node: { comments: page([], 'comments-1') } } } }, 'GitHub review comment pagination did not advance'], + ['a comment page without a cursor', firstPage(page([threadNode('t1', { comments: { pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] } })])), + {}, {}, 'GitHub review comment pagination did not advance'], + ['an unexpected first page', { data: { repository: { pullRequest: null } } }, {}, {}, 'unexpected GitHub response to the review thread read'], + ['an unexpected thread page', firstPage(page([], 'threads-1')), {}, {}, 'unexpected GitHub response to the review thread read'], + ['an unexpected comment page', firstPage(page([threadNode('t1', { comments: page([], 'comments-1') })])), {}, {}, + 'unexpected GitHub response to the review thread read'], + ] as const)('reports %s as unavailable', async (_label, first, later, comments, message) => { + const gh = review(first, later, comments) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message }) + }) + + it('passes on the failure of any page read and of a response that is not JSON', async () => { + const missing = review(output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.')) + await expect(provider(missing.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'not-found', message: 'gh: Could not resolve to a PullRequest with the number of 7.' }) + + const garbled = review(output('not json')) + await expect(provider(garbled.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' }) + + const refused = review(output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] })) + await expect(provider(refused.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' }) + + const failing = fakeGh(args => args.includes(`query=${REVIEW_QUERY}`) + ? output(firstPage(page([threadNode('t1', { comments: page([], 'comments-1') })], 'threads-1'))) + : output('', 1, 'gh: Server Error (HTTP 502)')) + await expect(provider(failing.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'gh: Server Error (HTTP 502)' }) + + const failingThreads = fakeGh(args => args.includes(`query=${REVIEW_QUERY}`) + ? output(firstPage(page([threadNode('t1')], 'threads-1'))) + : output('', 1, 'gh: Server Error (HTTP 502)')) + await expect(provider(failingThreads.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'unavailable', message: 'gh: Server Error (HTTP 502)' }) + }) + + it('refuses an unconfigured host without starting gh and a head that is not a commit object name', async () => { + const gh = review(firstPage(page([]))) + const elsewhere = { ...demo, host: 'ghe.other.example' } + + await expect(provider(gh.run).readReview(pull(7, elsewhere), head, signal)) + .resolves.toEqual({ code: 'unavailable', message: 'forge-github: host "ghe.other.example" is not configured' }) + await expect(provider(gh.run).readReview(pull(7), 'main', signal)).rejects.toThrow('forge-github: invalid head commit "main"') + expect(gh.calls).toEqual([]) + }) +}) diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index f52f77e818..88068c3aee 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -7,7 +7,8 @@ import { changeRequestKey } from './keys.ts' import { nextPollDelay } from './schedule.ts' import type { ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, ForgeCommitList, - ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, + MergeRequest, } from './types.ts' export { changeRequestKey } from './keys.ts' @@ -44,7 +45,7 @@ interface Watch { /** Epoch milliseconds of the next poll; undefined while in flight or after a terminal state. */ dueAt: number | undefined failures: number - /** Head commit whose `stale` diff read last polled at once, so further stale reads at it wait for the regular poll. */ + /** Head commit whose `stale` diff or review read last polled at once, so further stale reads at it wait for the regular poll. */ staleHead: string | undefined } @@ -252,11 +253,24 @@ export class ForgeService extends Service { async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { const result = await this.invoke(this.registration(ref.repository.forge), signal, (provider, operation) => provider.diff(ref, headSha, operation)) - const watch = this.watches.get(changeRequestKey(ref)) - if (typeof result !== 'string' && result.code === 'stale' && watch !== undefined && watch.staleHead !== headSha) { - watch.staleHead = headSha - this.refresh(ref) - } + if (typeof result !== 'string') this.staleRead(ref, headSha, result) + return result + } + + /** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ + async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + const result = await this.invoke(this.registration(ref.repository.forge), signal, + (provider, operation) => provider.readReview?.(ref, headSha, operation) + ?? Promise.resolve({ code: 'unsupported', message: 'This provider does not support review reads' } as const)) + if ('code' in result) this.staleRead(ref, headSha, result) return result } @@ -384,6 +398,14 @@ export class ForgeService extends Service { return this.watches.get(changeRequestKey(ref))?.latest } + /** Poll a watched change request at once after the first `stale` read at `headSha`; later ones wait for the regular poll. */ + private staleRead(ref: ChangeRequestRef, headSha: string, failure: ForgeFailure): void { + const watch = this.watches.get(changeRequestKey(ref)) + if (failure.code !== 'stale' || watch === undefined || watch.staleHead === headSha) return + watch.staleHead = headSha + this.refresh(ref) + } + private provider(forge: string): ForgeProvider { return this.registration(forge).provider } diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 18c8b02e6d..71bd7270e0 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -116,9 +116,9 @@ export interface ForgeFailure { * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails * the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -163,6 +163,86 @@ export type ForgeReviewRequest = { | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } ) +/** Opaque host identity of one review thread. */ +export type ForgeThreadId = Branded<'ForgeThreadId'> + +/** Opaque host identity of one review comment. */ +export type ForgeCommentId = Branded<'ForgeCommentId'> + +/** Consecutive lines of one file at one commit. */ +export interface ForgeAnchor { + /** Full object name of the commit whose file the lines number. */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} + +/** Account that wrote a review comment. */ +export interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} + +/** One comment of a review thread. */ +export interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null +} + +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +export interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} + +/** Review threads of a change request read at one head commit. */ +export interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean +} + /** Result of a review write; unknown outcomes must be checked on the host before another submission. */ export type ForgeReviewResult = | { readonly ok: true; readonly url: string } @@ -220,6 +300,12 @@ export interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index 7a5d84ab7b..a87103be4e 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' +import type { ForgeReviewData } from '../src/types.ts' import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' let ctx: Context | undefined @@ -125,6 +126,21 @@ describe('forge registry', () => { expect(compare).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), 'b'.repeat(40), expect.any(AbortSignal)) }) + it('delegates review reads to the provider of their forge and reports a provider without them as unsupported', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + await provide(ctx, provider) + const signal = new AbortController().signal + + await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)) + .resolves.toEqual({ code: 'unsupported', message: 'This provider does not support review reads' }) + const review: ForgeReviewData = { headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false } + const readReview = vi.fn(async () => review) + Object.assign(provider, { readReview }) + await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)).resolves.toBe(review) + expect(readReview).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), expect.any(AbortSignal)) + }) + it('delegates change request actions to the provider of their forge and returns its outcome', async () => { ctx = await forgeContext() const provider = new FakeProvider() diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 4537d82670..5903fb42fa 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' -import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository } from '../src/types.ts' +import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository, ForgeReviewData } from '../src/types.ts' import { FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' const config = { activeIntervalMs: 1_000, idleIntervalMs: 5_000, maxBackoffMs: 6_000, fetchTimeoutMs: 500 } @@ -266,6 +266,29 @@ describe('forge watch', () => { expect(provider.fetches).toHaveLength(4) }) + it('polls at once after a review read finds a newer head, sharing the once-per-head bound with diff reads', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + const readReview = vi.fn(async (): Promise => ({ code: 'unavailable', message: 'down' })) + Object.assign(provider, { readReview }) + const context = await setup(provider) + collect(context) + await vi.advanceTimersByTimeAsync(0) + const signal = new AbortController().signal + + await context.forge.readReview(ref(1), 'a'.repeat(40), signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(1) + readReview.mockResolvedValue({ code: 'stale', message: 'the head moved' }) + await expect(context.forge.readReview(ref(1), 'a'.repeat(40), signal)).resolves.toEqual({ code: 'stale', message: 'the head moved' }) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(2) + provider.readResult = { code: 'stale', message: 'the head moved' } + await context.forge.diff(ref(1), 'a'.repeat(40), signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(2) + }) + it('coalesces refreshes during a fetch into one immediate subsequent fetch', async () => { const provider = new FakeProvider() const release = held(provider, target => snapshot(target)) From a39bcaec124a20091a284554793a9f3c9a6eda69 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 13:58:57 +0800 Subject: [PATCH 079/149] feat(forge-panel): overlay GitHub review threads on the changes view Threads read at the shown head sit under the row of their last line, or after the collapsed lines that hold it; resolved threads fold into one row, file threads open their file, and outdated threads gather after the lines with the end of their recorded hunk and the file at the commit they were written on. Chosen commits place only new-side threads whose lines number the last chosen commit. The file tree and file headers count unresolved threads, and a thread list filters unresolved, outdated, resolved, and awaiting-my-reply threads by file and opens each at its place. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. --- .../src/client/ChangedFiles.module.css | 2 + .../src/client/ChangedFiles.tsx | 6 +- .../src/client/ChangesTab.module.css | 58 +++ .../client-ui-forge/src/client/ChangesTab.tsx | 88 ++++- .../client-ui-forge/src/client/DiffFile.tsx | 58 ++- .../src/client/ReviewThreads.module.css | 219 ++++++++++++ .../src/client/ReviewThreads.tsx | 265 ++++++++++++++ .../src/client/ThreadList.module.css | 168 +++++++++ .../client-ui-forge/src/client/ThreadList.tsx | 163 +++++++++ .../client-ui-forge/src/client/locales.ts | 336 ++++++++++++++++++ .../client-ui-forge/src/client/mount.ts | 18 +- .../src/client/review-threads.ts | 171 +++++++++ .../src/client/thread-state.ts | 124 +++++++ .../tests/changes-tab.client.spec.tsx | 187 +++++++++- .../tests/mount.client.spec.ts | 17 +- .../tests/review-thread-cards.client.spec.tsx | 214 +++++++++++ .../tests/review-threads.client.spec.ts | 132 +++++++ .../tests/thread-fixtures.client.ts | 43 +++ 18 files changed, 2247 insertions(+), 22 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ThreadList.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ThreadList.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/review-threads.ts create mode 100644 packages/experimental/client-ui-forge/src/client/thread-state.ts create mode 100644 packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css index 4a57098d20..2006c2b546 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css @@ -5,6 +5,8 @@ .row { box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%; min-width: 0; min-height: 30px; padding: 4px 6px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: inherit; font: inherit; text-align: start; cursor: pointer; } .row svg { flex: none; } .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Unresolved host review threads of a file, in the thread count chip treatment of the file header. */ +.threads { flex: none; min-width: 16px; padding: 0 5px; box-sizing: border-box; border-radius: var(--dsw-radius-xs); background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); color: var(--dsw-alias-state-business-primary); font-size: 11px; line-height: 16px; text-align: center; } .selected { background: var(--dsw-alias-interactive-bg-hover); } .chevron { display: inline-flex; flex: 0 0 12px; } .collapsed { transform: rotate(-90deg); } diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx index 995bae454d..eb24d62b51 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx @@ -25,6 +25,8 @@ export interface ChangedFilesProps extends PropsLocale { readonly jump: (path: string) => void /** A menu row participates in the shared menu's keyboard walk. */ readonly menu?: boolean + /** Unresolved host review threads by file path; files without any are absent. */ + readonly threadCounts?: ReadonlyMap } function Rows({ nodes, depth, props }: { @@ -32,10 +34,11 @@ function Rows({ nodes, depth, props }: { readonly depth: number readonly props: ChangedFilesProps }): ReactNode { - const { state, toggle, jump, menu, t } = props + const { state, toggle, jump, menu, threadCounts, t } = props return nodes.map((node) => { const directory = node.kind === 'directory' const expanded = directory && (state.query.trim() !== '' || state.collapsed[node.path] !== true) + const threads = node.kind === 'file' ? threadCounts?.get(node.path) ?? 0 : 0 const label = node.kind === 'file' && node.file.previousPath !== null ? t('diff.renamedPath', { from: node.file.previousPath, path: node.path }) : node.path return
@@ -54,6 +57,7 @@ function Rows({ nodes, depth, props }: { {directory ? : } {node.name} + {threads > 0 && {threads}} {node.kind === 'file' && } diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index f664c4b2fa..23452bbc00 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -333,6 +333,7 @@ } .body { + position: relative; display: flex; flex: 1; min-height: 0; @@ -813,3 +814,60 @@ .code[data-single-side]:not([data-wrap]) .line { grid-template-columns: var(--forge-number-width) 1.4em max-content; } .reviewFile { padding: 12px 0; } .reviewFile > h3 { margin: 0 10px; font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } + +/* The unresolved thread count of a file opens the thread list at the file. */ +.threadCount { + flex: none; + height: 20px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-xs); + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + cursor: pointer; +} + +.threadCount:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +@media (hover: hover) { + .threadCount:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); } +} + +/* Opens the thread list; pressed while the list shows. */ +.threadsToggle { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + white-space: nowrap; + cursor: pointer; +} + +.threadsToggle[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); } + +@media (hover: hover) { + .threadsToggle:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + +.threadsTotal { + padding: 0 5px; + border-radius: var(--dsw-radius-xs); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index f57bb937bb..5763304362 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -27,7 +27,9 @@ import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeCommitRange, ForgeDiffFile, ForgeLocalCommits, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, } from '../types.ts' -import type { ForgeCommitList, ForgeReviewRequest, ForgeReviewResult } from '@deepseek-ai/dsh-experimental-forge/types' +import type { + ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, +} from '@deepseek-ai/dsh-experimental-forge/types' import { sameReviewSource, type ReviewQueue } from './review-queue.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' import type { ReviewActions } from './ReviewComposer.tsx' @@ -47,6 +49,10 @@ import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' import { ChangeRequestGlyph } from './glyphs.tsx' import type { ForgePanelKey, NS } from './locales.ts' import { RefreshGlyph } from './RefreshGlyph.tsx' +import type { FileThreads, ThreadActions, ThreadFocus } from './ReviewThreads.tsx' +import type { ThreadFilter } from './review-threads.ts' +import { usePlacedThreads, useThreadRead, type PlacedThread } from './thread-state.ts' +import { ThreadList } from './ThreadList.tsx' import type { PanelSnapshot } from './source.ts' import { numberOf, openChangeRequestOf, TONE_LABEL, toneOf } from './status.ts' import css from './ChangesTab.module.css' @@ -86,6 +92,10 @@ export interface ChangesTabInjected { readonly viewedFiles: ViewedFiles /** Read one file of a change request at a commit. */ readonly changeRequestText: (url: string, commit: string, path: string) => Promise> + /** Read the review threads of a change request at the head of its latest status; `signal` aborts the read. */ + readonly changeRequestReview: (url: string, headSha: string, signal: AbortSignal) => Promise> + /** Quote a whole review thread of the change request at `url` in the main input without submitting it. */ + readonly addThreadToInput: (thread: ForgeReviewThread, url: string, range: string | null) => boolean /** Open a URL outside the app. */ readonly openLink: (url: string) => void /** Copy a path to the clipboard and report the outcome. */ @@ -423,8 +433,8 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { */ export function ChangesTab({ useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, localCommits, changeRequestDiff, - changeRequestText, changeRequestCommits, viewedFiles, - openLink, copyPath, createChangeRequest, addReviewToInput, reviewQueue, submitReview, report, t, + changeRequestText, changeRequestCommits, changeRequestReview, viewedFiles, + openLink, copyPath, createChangeRequest, addReviewToInput, addThreadToInput, reviewQueue, submitReview, report, t, }: ChangesTabProps): ReactNode { const { tab } = useTabInfo() const { navigation, signal } = tab @@ -560,6 +570,22 @@ export function ChangesTab({ return () => { controller.abort() } }, [tab.visible, read, refused, waiting, source, localScope, changeCommits, dataKey, localDiff, changeRequestDiff]) const data = loaded !== null && loaded.key === dataKey ? loaded.data : null + // Threads show only while they were read at the head of the latest status and, for the whole change request, at + // the head the shown diff was read at, so their positions number the shown files. + const prHead = entry?.snapshot?.headSha ?? null + const prAuthor = entry?.snapshot?.author ?? null + const threadRead = useThreadRead({ visible: tab.visible, url: source.kind === 'change' ? source.url : null, head: prHead, + updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, read: changeRequestReview }) + const review = data?.kind === 'change' && threadRead.review !== null && threadRead.review.headSha === prHead + && (data.commits !== null || data.headSha === prHead) ? threadRead.review : null + const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) + const [threadList, setThreadList] = useState<{ + readonly open: boolean + readonly filter: ThreadFilter | null + /** File whose group the list scrolls to, or null. */ + readonly file: string | null + }>({ open: false, filter: 'unresolved', file: null }) + const [focus, setFocus] = useState(null) const allFiles = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item, @@ -594,10 +620,16 @@ export function ChangesTab({ if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id) return } - sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + const section = sections.current.get(wanted) + section?.scrollIntoView({ block: 'start' }) + // A thread the reader opened from the thread list centers inside its file, which the jump expanded and revealed. + /* v8 ignore next 2 -- a focused thread's file is drawn and its card unfolded, so both are found. */ + const card = focus === null ? undefined : [...section?.querySelectorAll('[data-forge-thread]') ?? []] + .find(element => element.dataset.forgeThread === focus.id) + card?.scrollIntoView({ block: 'center' }) if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } setWanted(null) - }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) + }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id, focus]) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null // A chosen base branch that is gone or shares no history with HEAD offers the automatic fork point instead. @@ -676,6 +708,32 @@ export function ChangesTab({ .filter(path => !data?.files.some(file => file.path === path)) const orphanRecords = absentPaths.map(path => ) + const threadActions: ThreadActions | null = review === null || data?.kind !== 'change' ? null : { + viewer: review.viewer, + author: prAuthor, + addToInput: (thread, range) => addThreadToInput(thread, data.url, range), + openLink, + readText: (commit, path) => changeRequestText(data.url, commit, path), + inputFailed: () => { report('thread.inputFailed') }, + focused: focus, + } + const threadsOf = (path: string): FileThreads | undefined => { + const group = placed.files.get(path) + return threadActions === null || group === undefined ? undefined + : { ...group, actions: threadActions, openList: () => { setThreadList(value => ({ ...value, open: true, file: path })) } } + } + /** Show a thread from the list: all commits for a thread the chosen ones do not hold, then its file, unfolded and centered. */ + const openThread = ({ thread, place }: PlacedThread): void => { + const url = thread.comments[0]?.url ?? null + if (!allFiles.some(file => file.path === thread.path)) { + if (url !== null) openLink(url) + return + } + if (place.kind === 'elsewhere') actions.committed(tab.id, null) + setFocus(value => ({ id: thread.id, revision: (value?.revision ?? 0) + 1 })) + setRevealed(value => new Set([...value, thread.path])) + jump(thread.path) + } const jump = (path: string): void => { setTreeState(value => ({ ...value, selected: path })) setChosen(value => ({ ...value, [path]: true })) @@ -820,6 +878,7 @@ export function ChangesTab({ badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} actions={fileActions(data, file)} + threads={threadsOf(path)} viewed={viewedStateOf(marks, path, file.digest)} onViewed={(viewed) => { markViewed(data, file, viewed) }} sectionRef={(element) => { @@ -839,6 +898,7 @@ export function ChangesTab({ filter: (query) => { setTreeState(value => ({ ...value, query })) }, toggle: (path) => { setTreeState(value => ({ ...value, collapsed: { ...value.collapsed, [path]: value.collapsed[path] !== true } })) }, jump, + ...review === null ? {} : { threadCounts: placed.unresolved }, } // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. const anyExpanded = files.some(isExpanded) @@ -887,6 +947,13 @@ export function ChangesTab({
) diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index ad46c66132..0e7c561125 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -18,6 +18,8 @@ import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } fr import { codeSideAt, readCodeSelection } from './code-selection.ts' import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' +import { OutdatedThreads, ThreadCard, type FileThreads, type LineThread, type ThreadActions } from './ReviewThreads.tsx' +import { gapOf } from './review-threads.ts' import type { FileReviewState } from './review-queue.ts' import { ReviewComposer, type ReviewActions } from './ReviewComposer.tsx' import reviewCss from './ReviewComposer.module.css' @@ -77,6 +79,8 @@ export interface DiffFileProps { readonly viewed: ViewedState /** Mark the file viewed, which also collapses it, or clear the mark. */ readonly onViewed: (viewed: boolean) => void + /** Review threads of the file from the code host, or undefined while none are read. */ + readonly threads?: FileThreads | undefined /** Receives the section element, which scrolling to the file targets. */ readonly sectionRef: (element: HTMLElement | null) => void /** Forge panel copy. */ @@ -144,8 +148,8 @@ function pairKind(pair: SplitRow): 'context' | 'change' { } /** The hunks of one file with their collapsed and expanded unchanged lines. */ -function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { +function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, threads, t }: Pick & { readonly comments: FileReviewState }): ReactNode { const lines = typeof text === 'object' ? text : undefined const columns = split && file.additions > 0 && file.deletions > 0 const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null @@ -313,6 +317,17 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme remaining.delete(thread) } } + // A host thread follows the row of its last line, or the collapsed unchanged lines that hold it. + const unplaced = new Set(threads === undefined ? [] : threads.lines.map(item => ({ ...item, actions: threads.actions }))) + const threadCard = (item: LineThread & { readonly actions: ThreadActions }, columns: boolean): ReactNode => + + const appendThreads = (old: number | null, next: number | null): void => { + for (const item of unplaced) { + if ((item.place.side === 'L' ? old : next) !== item.place.endLine) continue + rows.push(threadCard(item, columns)) + unplaced.delete(item) + } + } const endsAt = (old: number | null, next: number | null): boolean => selection !== null && (selection.comment.side === 'L' ? old : next) === selection.comment.endLine for (const [at, segment] of prepared.entries()) { @@ -322,6 +337,11 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme {segment.count === null ? t('diff.unchangedRest') : t('diff.unchanged', { count: segment.count.toLocaleString('en-US') })} ) + for (const item of unplaced) { + if (gapOf(file, item.place.side, item.place.endLine) !== segment.index) continue + rows.push(threadCard(item, columns)) + unplaced.delete(item) + } } else if (columns) { for (const [index, pair] of splitRows(segment.rows).entries()) { rows.push(
) } + for (const item of unplaced) rows.push(threadCard(item, false)) if (!inserted) rows.push(editor) return
{rows}
} -function Body(props: DiffFileProps): ReactNode { - const { file, large, revealed, onReveal, review, t } = props +/** The file's lines or the note that replaces them, with its saved comments; host threads on lines follow when no lines show. */ +function Content(props: DiffFileProps): ReactNode { + const { file, large, revealed, onReveal, review, threads, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) - if (file.binary) return <>

{t('diff.binary')}

{records} - if (file.oversized) return <>

{t('diff.fileTooLarge')}

{records} - if (file.hunks.length === 0) return records + const lineThreads = threads?.lines.map(item => ) + if (file.binary) return <>

{t('diff.binary')}

{records}{lineThreads} + if (file.oversized) return <>

{t('diff.fileTooLarge')}

{records}{lineThreads} + if (file.hunks.length === 0) return <>{records}{lineThreads} if (large && !revealed) { // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. return ( <>{records} + {records}{lineThreads} ) } return } +/** Host threads on the whole file, the lines, then the host threads that later commits made outdated. */ +function Body(props: DiffFileProps): ReactNode { + const { threads, t } = props + return <> + {threads?.file.map(thread => )} + + {threads !== undefined && } + +} + /** File actions live only while their owning tab is visible. */ function FileMenu({ name, actions, t }: { readonly name: string @@ -443,7 +479,7 @@ function FileMenu({ name, actions, t }: { * @returns the file section. */ export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props + const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, threads, sectionRef, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) return (
{viewed === 'changed' && {t('viewed.changed')}} + {threads !== undefined && threads.unresolved > 0 && ( + + )} {badge !== '' && {badge}} {!file.binary && } diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css new file mode 100644 index 0000000000..00ed916016 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -0,0 +1,219 @@ +/* Review thread cards share the inline review form's card material; comment rows follow the conversation's 13px detail size. */ +.card, +.folded { + box-sizing: border-box; + width: calc(100% - 20px); + max-width: 720px; + margin: 10px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); + white-space: normal; +} + +.card { padding: 10px 12px; } + +.card[data-split][data-side="R"], +.folded[data-split][data-side="R"] { + margin-inline-start: clamp(10px, calc(100% - 740px), calc(50% + 10px)); +} + +.card[data-resolved] { background: var(--dsw-alias-bg-layer-2); } + +.folded { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 12px; + color: var(--dsw-alias-label-secondary); +} + +.folded > svg { flex: none; color: var(--dsw-alias-state-success-primary); } + +.foldedText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.place { + min-width: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.spacer { flex: 1; } + +/* Text actions inside a card: no fill, accent ink, the same height as the tags beside them. */ +.link { + display: inline-flex; + flex: none; + align-items: center; + gap: 2px; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.link:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; } + +.link:focus-visible { + border-radius: var(--dsw-radius-xs); + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +@media (hover: hover) { + .link:not(:disabled):hover { text-decoration: underline; } +} + +.turned { transform: rotate(-90deg); } + +.comments { + display: flex; + flex-direction: column; + gap: 12px; +} + +.comment { + display: flex; + gap: 8px; + min-width: 0; +} + +.avatar { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font-weight: 500; + line-height: 1; + user-select: none; +} + +.avatar img { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.commentMain { + flex: 1; + min-width: 0; +} + +.commentHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 20px; +} + +.login { font-weight: 500; overflow-wrap: anywhere; } + +.time { color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +.body { + min-width: 0; + margin-top: 2px; + overflow-wrap: anywhere; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 10px -8px -4px; +} + +/* The end of the recorded hunk, drawn like the diff rows it came from. */ +.excerpt, +.original { + margin-bottom: 10px; + overflow-x: auto; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-sm); + font: var(--dsw-font-markdown-code-block); +} + +.excerptLine, +.originalLine { + min-height: 20px; + padding: 0 10px; + line-height: 20px; + white-space: pre; +} + +.excerptHeader { color: var(--dsw-alias-label-tertiary); } + +.added { background: var(--dsw-alias-file-diff-added-bg); } + +.deleted { background: var(--dsw-alias-file-diff-deleted-bg); } + +.original { + max-height: 320px; + overflow: auto; +} + +.originalLine { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 12px; +} + +.originalNumber { + min-width: 3ch; + color: var(--dsw-alias-label-tertiary); + text-align: right; + user-select: none; +} + +.marked { background: var(--dsw-alias-interactive-bg-hover); } + +.originalNote { + padding: 8px 10px; + color: var(--dsw-alias-label-secondary); + font-family: Inter, var(--dsw-font-family); +} + +/* Outdated threads of one file, after its lines. */ +.outdated { + border-top: 0.5px solid var(--dsw-alias-border-l3); + padding-bottom: 2px; +} + +.outdatedHeader { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx new file mode 100644 index 0000000000..2ba58c24c3 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx @@ -0,0 +1,265 @@ +/** + * Read-only review threads of a change request inside the changes view: a thread card at its lines or at the top of + * its file, a resolved thread folded into one row, and the outdated threads of a file in a section after its lines. + */ + +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconRightUpOutlineRegular, MarkdownText, Tag, type MarkdownLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { formatAgo } from './format.ts' +import { anchorRange, hunkExcerpt, type ThreadPlace } from './review-threads.ts' +import type { NS } from './locales.ts' +import css from './ReviewThreads.module.css' + +type Translate = PropsLocale['t'] + +/** Reads, actions, and identities every thread card of one changes view shares. */ +export interface ThreadActions { + /** Login of the signed-in user, or null when the host names none. */ + readonly viewer: string | null + /** Login of the change request's author, or null when the host hides it. */ + readonly author: string | null + /** Quote the whole thread in the main input without sending it; false when the input refused it. */ + readonly addToInput: (thread: ForgeReviewThread, range: string | null) => boolean + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Read a file of the change request's repository at a commit. */ + readonly readText: (commit: string, path: string) => Promise> + /** Report that the input refused a thread. */ + readonly inputFailed: () => void + /** The latest jump to a thread, which unfolds that thread and its section until the reader folds them again; null before any. */ + readonly focused: ThreadFocus | null +} + +/** One jump to a thread; each jump has its own revision, so a jump to the same thread unfolds it again. */ +export interface ThreadFocus { + readonly id: ForgeThreadId + readonly revision: number +} + +/** A host thread shown on lines of one side. */ +export interface LineThread { + readonly thread: ForgeReviewThread + readonly place: Extract +} + +/** Host threads of one file and how its section reaches them. */ +export interface FileThreads { + /** Threads on lines, which follow the row of their last line. */ + readonly lines: readonly LineThread[] + /** Threads on the whole file, shown above its lines. */ + readonly file: readonly ForgeReviewThread[] + /** Threads no longer on the head's lines, shown after them. */ + readonly outdated: readonly ForgeReviewThread[] + /** Unresolved threads of the file in every place, including threads the chosen commits do not show. */ + readonly unresolved: number + /** Reads, actions, and identities shared by the cards. */ + readonly actions: ThreadActions + /** Open the thread list at this file. */ + readonly openList: () => void +} + +/** The commit a thread was written on, abbreviated, or null for a file thread. */ +function writtenOn(thread: ForgeReviewThread): string | null { + return thread.original === null ? null : thread.original.commit.slice(0, 7) +} + +/** + * Build the Markdown chrome copy of comment bodies. + * @param t - forge panel copy. + * @returns labels for code fences and footnotes. + */ +export function useMarkdownLabels(t: Translate): MarkdownLabels { + return useMemo(() => ({ + code: { copyLabel: t('thread.copy'), copiedLabel: t('thread.copied'), + toolbarLabels: { codeLabel: t('thread.codeBlock'), wrapLabel: t('thread.wrap'), unwrapLabel: t('thread.unwrap') } }, + footnotes: t('thread.footnotes'), + }), [t]) +} + +/** + * Render an author's avatar, or the first letter of the login when the image is missing or fails. + * @param props - the comment's author and the avatar size. + * @returns the round avatar. + */ +export function Avatar({ author, size = 20 }: { readonly author: ForgeReviewComment['author']; readonly size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + const login = author?.login ?? '?' + return +} + +function Comment({ comment, actions, labels, now, t }: { + readonly comment: ForgeReviewComment + readonly actions: ThreadActions + readonly labels: MarkdownLabels + readonly now: number + readonly t: Translate +}): ReactNode { + const login = comment.author?.login ?? null + return
+ +
+
+ {login ?? t('thread.deletedAuthor')} + {formatAgo(comment.createdAt, now, t) ?? comment.createdAt} + {login !== null && login === actions.author && {t('thread.author')}} + {comment.author?.bot === true && {t('thread.bot')}} + {comment.pending && {t('review.pending')}} +
+ {/* Raw HTML renders nothing, as GitHub hides comments and unknown tags, and never enters the DOM. */} +
+
+
+} + +/** + * Render one thread: its place, every comment, and the read-only actions. A resolved thread folds into one row + * until the reader unfolds it or jumps to it. + * @param props - the thread, where it shows, the shared actions, the layout, and copy. + * @returns the thread card or its folded row. + */ +export function ThreadCard({ thread, place, actions, split = false, t }: PropsLocale & { + readonly thread: ForgeReviewThread + readonly place: ThreadPlace + readonly actions: ThreadActions + /** Whether the card sits under the side of a side-by-side row that its lines number. */ + readonly split?: boolean +}): ReactNode { + const [open, setOpen] = useState(false) + // The jump the reader folded the thread after, which no longer unfolds it. + const [foldedAt, setFoldedAt] = useState(null) + const labels = useMarkdownLabels(t) + const now = Date.now() + const focused = actions.focused?.id === thread.id && actions.focused.revision !== foldedAt + const unfolded = open || !thread.resolved || focused + const side = place.kind === 'line' ? place.side : thread.side + const range = place.kind === 'line' ? anchorRange(place.side, place) : null + const commit = writtenOn(thread) + const first = thread.comments[0] + const url = first?.url ?? null + if (!unfolded) { + return
+ + {t('thread.resolvedRow', { author: first?.author?.login ?? t('thread.deletedAuthor') })} + +
+ } + const original = place.kind === 'outdated' && thread.original !== null ? thread.original : null + return
+
+ + {original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) + : range ?? t('thread.fileLevel')} + {commit !== null && <> · {t('thread.onCommit', { commit })}} + + {place.kind === 'outdated' && {t('thread.outdatedTag')}} + {thread.resolved && {t('thread.filter.resolved')}} + + {thread.resolved && } + {original !== null && thread.side === 'R' && } +
+ {place.kind === 'outdated' && thread.diffHunk !== null && original !== null + && } +
+ {thread.comments.map(comment => )} +
+
+ + {url !== null && } +
+
+} + +/** The end of a recorded diff hunk: the lines a thread was written on and a few before them. */ +function Excerpt({ hunk, side, lines }: { readonly hunk: string; readonly side: 'L' | 'R'; readonly lines: number }): ReactNode { + const excerpt = hunkExcerpt(hunk, side, lines) + return
+ {excerpt.header !== null &&
{excerpt.header}
} + {excerpt.lines.map((line, index) =>
{line}
)} +
+} + +/** + * Show the whole file at the commit a thread was written on, with the thread's lines marked, under the thread header. + * Only a new-side thread offers it: its lines number that commit's file. + */ +function OriginalFile({ thread, commit, actions, t }: PropsLocale & { + readonly thread: ForgeReviewThread + readonly commit: string + readonly actions: ThreadActions +}): ReactNode { + const [state, setState] = useState<{ readonly lines: readonly string[] } | 'closed' | 'loading' | { readonly failed: string }>('closed') + const marked = useRef(null) + useEffect(() => { marked.current?.scrollIntoView({ block: 'center' }) }, [state]) + const toggle = (): void => { + if (state !== 'closed' && state !== 'loading') { setState('closed'); return } + setState('loading') + void actions.readText(commit, thread.path).then((result) => { + setState(result.ok ? { lines: result.value.split('\n') } : { failed: result.error.message }) + }) + } + const open = state !== 'closed' + const range = thread.original + return <> + + {open &&
+ {state === 'loading' ?
{t('thread.originalLoading')}
+ : 'failed' in state ?
{t('thread.originalFailed', { message: state.failed })}
+ : state.lines.map((line, index) => { + const number = index + 1 + const inside = range !== null && number >= range.startLine && number <= range.endLine + return
+ {number}{line} +
+ })} +
} + +} + +/** + * Render the outdated threads of a file after its lines, as one section the reader can fold. + * @param props - the threads, the shared actions, and copy. + * @returns the section, or nothing without outdated threads. + */ +export function OutdatedThreads({ threads, actions, t }: PropsLocale & { + readonly threads: readonly ForgeReviewThread[] + readonly actions: ThreadActions +}): ReactNode { + // The section is folded after the jump with this revision, or -1 before any jump, or null while unfolded. + const [folded, setFolded] = useState(null) + if (threads.length === 0) return null + const jump = actions.focused + const open = folded === null || (jump !== null && jump.revision !== folded && threads.some(thread => thread.id === jump.id)) + return
+
+ {t('thread.outdatedSection', { count: threads.length })} + + +
+ {open && threads.map(thread => )} +
+} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.module.css b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css new file mode 100644 index 0000000000..c38bf4b549 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css @@ -0,0 +1,168 @@ +/* The thread list docks at the end of the changes body on wide panes and floats over the files on narrow ones. */ +.pane { + display: flex; + flex: none; + flex-direction: column; + box-sizing: border-box; + width: 320px; + min-height: 0; + border-inline-start: 0.5px solid var(--dsw-alias-border-l3); + background: var(--forge-changes-bg, var(--dsw-alias-bg-base)); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} + +@container forge-changes (max-width: 759px) { + .pane { + position: absolute; + z-index: 3; + inset-block: 0; + inset-inline-end: 0; + width: min(320px, 100%); + box-shadow: var(--dsw-shadow-l2, 0 4px 16px rgb(0 0 0 / 12%)); + } +} + +.header { + display: flex; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 0 6px 0 12px; +} + +.title { font-weight: 500; } + +.total { + padding: 0 6px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-card-fill); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.spacer { flex: 1; } + +.close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +@media (hover: hover) { + .close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px 10px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.rows { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 6px; +} + +.group + .group { margin-top: 6px; } + +.path { + margin: 0; + padding: 8px 6px 4px; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item { + display: flex; + gap: 8px; + box-sizing: border-box; + width: 100%; + padding: 8px 6px; + border: 0; + border-radius: var(--dsw-radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} + +@media (hover: hover) { + .item:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + +.item:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -1px; +} + +.itemMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.meta { color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +.excerpt { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow-wrap: anywhere; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.tags:empty { display: none; } + +.note { + padding: 24px 12px; + color: var(--dsw-alias-label-secondary); + text-align: center; +} + +.footer { + padding: 10px 12px; + border-top: 0.5px solid var(--dsw-alias-border-l3); + color: var(--dsw-alias-label-secondary); + font-size: 12px; +} + +.link { + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + cursor: pointer; +} + +@media (hover: hover) { + .link:hover { text-decoration: underline; } +} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.tsx b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx new file mode 100644 index 0000000000..66a968c788 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx @@ -0,0 +1,163 @@ +/** + * The thread list of a change request beside its changes: every thread grouped by file in file tree order, narrowed + * by one filter, each opening the place it shows at. + */ + +import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react' +import { IconCloseOutlineRegular, Pill, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { Avatar } from './ReviewThreads.tsx' +import { anchorRange, awaitsViewer, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts' +import type { NS } from './locales.ts' +import type { PlacedThread } from './thread-state.ts' +import css from './ThreadList.module.css' + +type Translate = PropsLocale['t'] + +/** Props of the thread list. */ +export interface ThreadListProps extends PropsLocale { + /** Every thread read, in host order, with where the current changes show it. */ + readonly entries: readonly PlacedThread[] + /** Changed file paths in file tree order; threads on other paths follow them. */ + readonly order: readonly string[] + /** Login of the signed-in user. */ + readonly viewer: string | null + /** Whether the host has more threads than were read. */ + readonly truncated: boolean + /** Host name the threads were read from, such as `github.com`. */ + readonly host: string + /** Web page of the change request. */ + readonly url: string + /** The active filter, or null for every thread. */ + readonly filter: ThreadFilter | null + /** Choose a filter, or null for every thread. */ + readonly choose: (filter: ThreadFilter | null) => void + /** File whose group the list scrolls to when it changes, or null. */ + readonly file: string | null + /** Open the place of a thread. */ + readonly open: (entry: PlacedThread) => void + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Close the list. */ + readonly close: () => void +} + +/** Sort key of a thread inside its file: file threads first, then by first line, outdated and elsewhere last. */ +function rank(place: ThreadPlace): number { + switch (place.kind) { + case 'file': return -1 + case 'line': return place.startLine + case 'outdated': + case 'elsewhere': return Number.MAX_SAFE_INTEGER + /* v8 ignore next -- closed ThreadPlace union exhaustiveness guard */ + default: return assertNever(place, 'ThreadPlace') + } +} + +function meta(thread: ForgeReviewThread, place: ThreadPlace, t: Translate): string { + const replies = thread.comments.length - 1 + const parts: string[] = [] + if (thread.subject === 'file') parts.push(t('thread.fileLevel')) + else if (place.kind === 'line') parts.push(anchorRange(place.side, place)) + else if (thread.original !== null) { + parts.push(t('thread.originally', { range: anchorRange(thread.side, thread.original) }), t('thread.onCommit', { commit: thread.original.commit.slice(0, 7) })) + } + if (replies > 0) parts.push(t('thread.replies', { count: replies })) + return parts.join(' · ') +} + +/** + * The first comment as one line of plain text: without raw HTML, strong, strikethrough, and code marks, quote or + * heading marks, and link targets. + */ +function plainExcerpt(body: string): string { + return body.replace(/<[^>]*>/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(?:#{1,6}|>)\s*/gm, '') + .replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim() +} + +function Item({ entry, viewer, open, t }: { + readonly entry: PlacedThread + readonly viewer: string | null + readonly open: (entry: PlacedThread) => void + readonly t: Translate +}): ReactNode { + const { thread, place } = entry + const first = thread.comments[0] + return +} + +/** + * Render the thread list. + * @param props - threads, their places, the file order, the filter, and the list's actions. + * @returns the list pane. + */ +export function ThreadList(props: ThreadListProps): ReactNode { + const { entries, order, viewer, filter, t } = props + const threads = useMemo(() => entries.map(entry => entry.thread), [entries]) + const counts = useMemo(() => Object.fromEntries(THREAD_FILTERS.map(item => [item, + threads.filter(thread => passesFilter(thread, item, viewer)).length])) as Record, [threads, viewer]) + const groups = useMemo(() => { + const position = new Map(order.map((path, index) => [path, index])) + const byPath = new Map() + for (const entry of entries) { + if (!passesFilter(entry.thread, filter, viewer)) continue + const list = byPath.get(entry.thread.path) + if (list === undefined) byPath.set(entry.thread.path, [entry]) + else list.push(entry) + } + return [...byPath].sort(([a], [b]) => (position.get(a) ?? order.length) - (position.get(b) ?? order.length) || a.localeCompare(b)) + .map(([path, list]) => [path, list.toSorted((a, b) => rank(a.place) - rank(b.place))] as const) + }, [entries, order, filter, viewer]) + const rows = useRef(null) + useLayoutEffect(() => { + const container = rows.current + /* v8 ignore next -- the ref is attached before layout effects run. */ + if (props.file === null || container === null) return + for (const group of container.querySelectorAll('[data-forge-thread-group]')) { + if (group.dataset.forgeThreadGroup === props.file) group.scrollIntoView({ block: 'start' }) + } + }, [props.file]) + return +} diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 81cb739802..96955093d2 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -308,6 +308,48 @@ export const zh = { 'toast.expandStale': '文件在读取变更后又有修改,刷新后再展开', 'toast.prompted': '已发送:{text}', 'toast.promptFailed': '无法发送消息', + 'thread.list': '讨论', + 'thread.listAria': '讨论列表,共 {count} 个', + 'thread.close': '关闭讨论列表', + 'thread.filterAria': '筛选讨论', + 'thread.filter.unresolved': '未解决', + 'thread.filter.outdated': '已过期', + 'thread.filter.resolved': '已解决', + 'thread.filter.mine': '需要我回复', + 'thread.none': '没有符合筛选条件的讨论', + 'thread.empty': '还没有评审讨论', + 'thread.total': '{host} 上共 {count} 个讨论', + 'thread.truncated': '讨论太多,只显示了前 {count} 个', + 'thread.openAll': '在 GitHub 中查看', + 'thread.replies': '{count} 条回复', + 'thread.fileLevel': '文件级讨论', + 'thread.onCommit': '在 {commit} 上', + 'thread.originally': '原在 {range}', + 'thread.outdatedTag': '已过期', + 'thread.elsewhereTag': '不在所选提交中', + 'thread.author': '作者', + 'thread.bot': 'bot', + 'thread.deletedAuthor': '已删除的账号', + 'thread.resolvedRow': '已解决的讨论 · {author}', + 'thread.expand': '展开', + 'thread.collapse': '收起', + 'thread.outdatedSection': '{count} 条讨论已过期:所在代码已被后续提交修改', + 'thread.viewOriginal': '查看当时的文件', + 'thread.hideOriginal': '收起当时的文件', + 'thread.originalLoading': '正在读取当时的文件…', + 'thread.originalFailed': '无法读取当时的文件:{message}', + 'thread.openOnHost': '在 GitHub 中查看', + 'thread.unresolvedCount': '{count} 条未解决', + 'thread.unresolvedAria': '{name} 有 {count} 条未解决的讨论,打开讨论列表', + 'thread.treeCount': '{count} 条未解决的讨论', + 'thread.loadFailed': '无法读取 GitHub 讨论:{message}', + 'thread.inputFailed': '输入框暂时无法添加这个讨论,请稍后重试', + 'thread.copy': '复制', + 'thread.copied': '复制成功', + 'thread.codeBlock': '代码块', + 'thread.wrap': '自动换行', + 'thread.unwrap': '取消自动换行', + 'thread.footnotes': '脚注', } as const /** Key of the forge panel dictionaries. */ @@ -618,6 +660,48 @@ export const en: Record = { 'toast.expandStale': 'The file changed after its changes were read; refresh before expanding', 'toast.prompted': 'Sent: {text}', 'toast.promptFailed': 'The message could not be sent', + 'thread.list': 'Threads', + 'thread.listAria': 'Review threads, {count} in total', + 'thread.close': 'Close the thread list', + 'thread.filterAria': 'Filter threads', + 'thread.filter.unresolved': 'Unresolved', + 'thread.filter.outdated': 'Outdated', + 'thread.filter.resolved': 'Resolved', + 'thread.filter.mine': 'Awaiting my reply', + 'thread.none': 'No threads match this filter', + 'thread.empty': 'No review threads yet', + 'thread.total': '{count} threads on {host}', + 'thread.truncated': 'Too many threads; showing the first {count}', + 'thread.openAll': 'View on GitHub', + 'thread.replies': '{count} replies', + 'thread.fileLevel': 'File thread', + 'thread.onCommit': 'on {commit}', + 'thread.originally': 'Originally {range}', + 'thread.outdatedTag': 'Outdated', + 'thread.elsewhereTag': 'Not in the chosen commits', + 'thread.author': 'Author', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Deleted account', + 'thread.resolvedRow': 'Resolved thread · {author}', + 'thread.expand': 'Expand', + 'thread.collapse': 'Collapse', + 'thread.outdatedSection': '{count} outdated threads: later commits changed their code', + 'thread.viewOriginal': 'View file at that commit', + 'thread.hideOriginal': 'Hide file at that commit', + 'thread.originalLoading': 'Reading the file at that commit…', + 'thread.originalFailed': 'Could not read the file at that commit: {message}', + 'thread.openOnHost': 'View on GitHub', + 'thread.unresolvedCount': '{count} unresolved', + 'thread.unresolvedAria': '{name} has {count} unresolved threads; open the thread list', + 'thread.treeCount': '{count} unresolved threads', + 'thread.loadFailed': 'Could not read GitHub threads: {message}', + 'thread.inputFailed': 'The input could not take this thread; try again later', + 'thread.copy': 'Copy', + 'thread.copied': 'Copied', + 'thread.codeBlock': 'Code block', + 'thread.wrap': 'Wrap lines', + 'thread.unwrap': 'Do not wrap lines', + 'thread.footnotes': 'Footnotes', } declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -932,6 +1016,48 @@ export const ja: Record = { 'toast.expandStale': '変更の読み取り後にファイルが更新されています。更新してから展開してください', 'toast.prompted': '送信しました:{text}', 'toast.promptFailed': 'メッセージを送信できませんでした', + 'thread.list': 'スレッド', + 'thread.listAria': 'レビュースレッド(全 {count} 件)', + 'thread.close': 'スレッド一覧を閉じる', + 'thread.filterAria': 'スレッドを絞り込む', + 'thread.filter.unresolved': '未解決', + 'thread.filter.outdated': '古い', + 'thread.filter.resolved': '解決済み', + 'thread.filter.mine': '自分の返信待ち', + 'thread.none': 'この条件に一致するスレッドはありません', + 'thread.empty': 'レビュースレッドはまだありません', + 'thread.total': '{host} 上のスレッド {count} 件', + 'thread.truncated': 'スレッドが多いため、最初の {count} 件のみ表示しています', + 'thread.openAll': 'GitHub で表示', + 'thread.replies': '返信 {count} 件', + 'thread.fileLevel': 'ファイルへのスレッド', + 'thread.onCommit': '{commit} 上', + 'thread.originally': '元の位置 {range}', + 'thread.outdatedTag': '古い', + 'thread.elsewhereTag': '選択したコミットにない', + 'thread.author': '作成者', + 'thread.bot': 'bot', + 'thread.deletedAuthor': '削除されたアカウント', + 'thread.resolvedRow': '解決済みのスレッド · {author}', + 'thread.expand': '展開', + 'thread.collapse': '折りたたむ', + 'thread.outdatedSection': '古いスレッド {count} 件:後のコミットで該当コードが変更されました', + 'thread.viewOriginal': '当時のファイルを表示', + 'thread.hideOriginal': '当時のファイルを閉じる', + 'thread.originalLoading': '当時のファイルを読み込み中…', + 'thread.originalFailed': '当時のファイルを読み込めません:{message}', + 'thread.openOnHost': 'GitHub で表示', + 'thread.unresolvedCount': '未解決 {count} 件', + 'thread.unresolvedAria': '{name} に未解決のスレッドが {count} 件あります。スレッド一覧を開く', + 'thread.treeCount': '未解決のスレッド {count} 件', + 'thread.loadFailed': 'GitHub のスレッドを読み込めません:{message}', + 'thread.inputFailed': '入力欄にこのスレッドを追加できません。しばらくしてから再試行してください', + 'thread.copy': 'コピー', + 'thread.copied': 'コピーしました', + 'thread.codeBlock': 'コードブロック', + 'thread.wrap': '行を折り返す', + 'thread.unwrap': '行を折り返さない', + 'thread.footnotes': '脚注', } /** Spanish copy with the same keys. */ @@ -1239,6 +1365,48 @@ export const es: Record = { 'toast.expandStale': 'El archivo cambió después de leer sus cambios; actualiza antes de expandir', 'toast.prompted': 'Enviado: {text}', 'toast.promptFailed': 'No se pudo enviar el mensaje', + 'thread.list': 'Hilos', + 'thread.listAria': 'Hilos de revisión, {count} en total', + 'thread.close': 'Cerrar la lista de hilos', + 'thread.filterAria': 'Filtrar hilos', + 'thread.filter.unresolved': 'Sin resolver', + 'thread.filter.outdated': 'Desactualizados', + 'thread.filter.resolved': 'Resueltos', + 'thread.filter.mine': 'Esperan mi respuesta', + 'thread.none': 'Ningún hilo coincide con este filtro', + 'thread.empty': 'Aún no hay hilos de revisión', + 'thread.total': '{count} hilos en {host}', + 'thread.truncated': 'Hay demasiados hilos; se muestran los primeros {count}', + 'thread.openAll': 'Ver en GitHub', + 'thread.replies': '{count} respuestas', + 'thread.fileLevel': 'Hilo del archivo', + 'thread.onCommit': 'en {commit}', + 'thread.originally': 'Originalmente {range}', + 'thread.outdatedTag': 'Desactualizado', + 'thread.elsewhereTag': 'No está en los commits elegidos', + 'thread.author': 'Autor', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Cuenta eliminada', + 'thread.resolvedRow': 'Hilo resuelto · {author}', + 'thread.expand': 'Expandir', + 'thread.collapse': 'Contraer', + 'thread.outdatedSection': '{count} hilos desactualizados: commits posteriores cambiaron su código', + 'thread.viewOriginal': 'Ver el archivo en ese commit', + 'thread.hideOriginal': 'Ocultar el archivo de ese commit', + 'thread.originalLoading': 'Leyendo el archivo de ese commit…', + 'thread.originalFailed': 'No se pudo leer el archivo de ese commit: {message}', + 'thread.openOnHost': 'Ver en GitHub', + 'thread.unresolvedCount': '{count} sin resolver', + 'thread.unresolvedAria': '{name} tiene {count} hilos sin resolver; abrir la lista de hilos', + 'thread.treeCount': '{count} hilos sin resolver', + 'thread.loadFailed': 'No se pudieron leer los hilos de GitHub: {message}', + 'thread.inputFailed': 'El campo de entrada no pudo recibir este hilo; inténtalo más tarde', + 'thread.copy': 'Copiar', + 'thread.copied': 'Copiado', + 'thread.codeBlock': 'Bloque de código', + 'thread.wrap': 'Ajustar líneas', + 'thread.unwrap': 'No ajustar líneas', + 'thread.footnotes': 'Notas a pie de página', } /** French copy with the same keys. */ @@ -1546,6 +1714,48 @@ export const fr: Record = { 'toast.expandStale': 'Le fichier a changé depuis la lecture des modifications ; actualisez avant de développer', 'toast.prompted': 'Envoyé : {text}', 'toast.promptFailed': 'Impossible d’envoyer le message', + 'thread.list': 'Fils', + 'thread.listAria': 'Fils de revue, {count} au total', + 'thread.close': 'Fermer la liste des fils', + 'thread.filterAria': 'Filtrer les fils', + 'thread.filter.unresolved': 'Non résolus', + 'thread.filter.outdated': 'Obsolètes', + 'thread.filter.resolved': 'Résolus', + 'thread.filter.mine': 'Attendent ma réponse', + 'thread.none': 'Aucun fil ne correspond à ce filtre', + 'thread.empty': 'Aucun fil de revue pour le moment', + 'thread.total': '{count} fils sur {host}', + 'thread.truncated': 'Trop de fils ; seuls les {count} premiers sont affichés', + 'thread.openAll': 'Voir sur GitHub', + 'thread.replies': '{count} réponses', + 'thread.fileLevel': 'Fil du fichier', + 'thread.onCommit': 'sur {commit}', + 'thread.originally': 'À l’origine {range}', + 'thread.outdatedTag': 'Obsolète', + 'thread.elsewhereTag': 'Hors des commits choisis', + 'thread.author': 'Auteur', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Compte supprimé', + 'thread.resolvedRow': 'Fil résolu · {author}', + 'thread.expand': 'Développer', + 'thread.collapse': 'Réduire', + 'thread.outdatedSection': '{count} fils obsolètes : des commits ultérieurs ont modifié leur code', + 'thread.viewOriginal': 'Voir le fichier à ce commit', + 'thread.hideOriginal': 'Masquer le fichier à ce commit', + 'thread.originalLoading': 'Lecture du fichier à ce commit…', + 'thread.originalFailed': 'Impossible de lire le fichier à ce commit : {message}', + 'thread.openOnHost': 'Voir sur GitHub', + 'thread.unresolvedCount': '{count} non résolus', + 'thread.unresolvedAria': '{name} a {count} fils non résolus ; ouvrir la liste des fils', + 'thread.treeCount': '{count} fils non résolus', + 'thread.loadFailed': 'Impossible de lire les fils GitHub : {message}', + 'thread.inputFailed': 'Le champ de saisie n’a pas pu recevoir ce fil ; réessayez plus tard', + 'thread.copy': 'Copier', + 'thread.copied': 'Copié', + 'thread.codeBlock': 'Bloc de code', + 'thread.wrap': 'Activer le retour à la ligne', + 'thread.unwrap': 'Désactiver le retour à la ligne', + 'thread.footnotes': 'Notes de bas de page', } /** German copy with the same keys. */ @@ -1853,6 +2063,48 @@ export const de: Record = { 'toast.expandStale': 'Die Datei wurde nach dem Lesen der Änderungen verändert; vor dem Aufklappen aktualisieren', 'toast.prompted': 'Gesendet: {text}', 'toast.promptFailed': 'Nachricht konnte nicht gesendet werden', + 'thread.list': 'Threads', + 'thread.listAria': 'Review-Threads, insgesamt {count}', + 'thread.close': 'Threadliste schließen', + 'thread.filterAria': 'Threads filtern', + 'thread.filter.unresolved': 'Ungelöst', + 'thread.filter.outdated': 'Veraltet', + 'thread.filter.resolved': 'Gelöst', + 'thread.filter.mine': 'Warten auf meine Antwort', + 'thread.none': 'Keine Threads entsprechen diesem Filter', + 'thread.empty': 'Noch keine Review-Threads', + 'thread.total': '{count} Threads auf {host}', + 'thread.truncated': 'Zu viele Threads; die ersten {count} werden angezeigt', + 'thread.openAll': 'Auf GitHub ansehen', + 'thread.replies': '{count} Antworten', + 'thread.fileLevel': 'Thread zur Datei', + 'thread.onCommit': 'auf {commit}', + 'thread.originally': 'Ursprünglich {range}', + 'thread.outdatedTag': 'Veraltet', + 'thread.elsewhereTag': 'Nicht in den gewählten Commits', + 'thread.author': 'Autor', + 'thread.bot': 'Bot', + 'thread.deletedAuthor': 'Gelöschtes Konto', + 'thread.resolvedRow': 'Gelöster Thread · {author}', + 'thread.expand': 'Aufklappen', + 'thread.collapse': 'Zuklappen', + 'thread.outdatedSection': '{count} veraltete Threads: Spätere Commits haben ihren Code geändert', + 'thread.viewOriginal': 'Datei in diesem Commit ansehen', + 'thread.hideOriginal': 'Datei in diesem Commit ausblenden', + 'thread.originalLoading': 'Datei in diesem Commit wird gelesen…', + 'thread.originalFailed': 'Die Datei in diesem Commit konnte nicht gelesen werden: {message}', + 'thread.openOnHost': 'Auf GitHub ansehen', + 'thread.unresolvedCount': '{count} ungelöst', + 'thread.unresolvedAria': '{name} hat {count} ungelöste Threads; Threadliste öffnen', + 'thread.treeCount': '{count} ungelöste Threads', + 'thread.loadFailed': 'GitHub-Threads konnten nicht gelesen werden: {message}', + 'thread.inputFailed': 'Das Eingabefeld konnte diesen Thread nicht aufnehmen; später erneut versuchen', + 'thread.copy': 'Kopieren', + 'thread.copied': 'Kopiert', + 'thread.codeBlock': 'Codeblock', + 'thread.wrap': 'Zeilen umbrechen', + 'thread.unwrap': 'Zeilen nicht umbrechen', + 'thread.footnotes': 'Fußnoten', } /** Russian copy with the same keys. */ @@ -2160,6 +2412,48 @@ export const ru: Record = { 'toast.expandStale': 'Файл изменился после чтения изменений; обновите перед разворачиванием', 'toast.prompted': 'Отправлено: {text}', 'toast.promptFailed': 'Не удалось отправить сообщение', + 'thread.list': 'Обсуждения', + 'thread.listAria': 'Обсуждения ревью, всего {count}', + 'thread.close': 'Закрыть список обсуждений', + 'thread.filterAria': 'Фильтр обсуждений', + 'thread.filter.unresolved': 'Нерешённые', + 'thread.filter.outdated': 'Устаревшие', + 'thread.filter.resolved': 'Решённые', + 'thread.filter.mine': 'Ждут моего ответа', + 'thread.none': 'Нет обсуждений, подходящих под фильтр', + 'thread.empty': 'Обсуждений ревью пока нет', + 'thread.total': 'Обсуждений на {host}: {count}', + 'thread.truncated': 'Обсуждений слишком много; показаны первые {count}', + 'thread.openAll': 'Открыть на GitHub', + 'thread.replies': 'Ответов: {count}', + 'thread.fileLevel': 'Обсуждение файла', + 'thread.onCommit': 'в {commit}', + 'thread.originally': 'Изначально {range}', + 'thread.outdatedTag': 'Устарело', + 'thread.elsewhereTag': 'Нет в выбранных коммитах', + 'thread.author': 'Автор', + 'thread.bot': 'бот', + 'thread.deletedAuthor': 'Удалённая учётная запись', + 'thread.resolvedRow': 'Решённое обсуждение · {author}', + 'thread.expand': 'Развернуть', + 'thread.collapse': 'Свернуть', + 'thread.outdatedSection': 'Устаревших обсуждений: {count}. Их код изменили более поздние коммиты', + 'thread.viewOriginal': 'Открыть файл в том коммите', + 'thread.hideOriginal': 'Скрыть файл в том коммите', + 'thread.originalLoading': 'Чтение файла в том коммите…', + 'thread.originalFailed': 'Не удалось прочитать файл в том коммите: {message}', + 'thread.openOnHost': 'Открыть на GitHub', + 'thread.unresolvedCount': 'Нерешённых: {count}', + 'thread.unresolvedAria': 'В {name} нерешённых обсуждений: {count}. Открыть список обсуждений', + 'thread.treeCount': 'Нерешённых обсуждений: {count}', + 'thread.loadFailed': 'Не удалось прочитать обсуждения GitHub: {message}', + 'thread.inputFailed': 'Не удалось добавить обсуждение в поле ввода; повторите попытку позже', + 'thread.copy': 'Копировать', + 'thread.copied': 'Скопировано', + 'thread.codeBlock': 'Блок кода', + 'thread.wrap': 'Переносить строки', + 'thread.unwrap': 'Не переносить строки', + 'thread.footnotes': 'Сноски', } /** Portuguese copy with the same keys. */ @@ -2467,4 +2761,46 @@ export const pt: Record = { 'toast.expandStale': 'O arquivo mudou após a leitura das alterações; atualize antes de expandir', 'toast.prompted': 'Enviado: {text}', 'toast.promptFailed': 'Não foi possível enviar a mensagem', + 'thread.list': 'Discussões', + 'thread.listAria': 'Discussões de revisão, {count} no total', + 'thread.close': 'Fechar a lista de discussões', + 'thread.filterAria': 'Filtrar discussões', + 'thread.filter.unresolved': 'Não resolvidas', + 'thread.filter.outdated': 'Desatualizadas', + 'thread.filter.resolved': 'Resolvidas', + 'thread.filter.mine': 'Aguardando minha resposta', + 'thread.none': 'Nenhuma discussão corresponde a este filtro', + 'thread.empty': 'Ainda não há discussões de revisão', + 'thread.total': '{count} discussões no {host}', + 'thread.truncated': 'Há discussões demais; mostrando as primeiras {count}', + 'thread.openAll': 'Ver no GitHub', + 'thread.replies': '{count} respostas', + 'thread.fileLevel': 'Discussão do arquivo', + 'thread.onCommit': 'em {commit}', + 'thread.originally': 'Originalmente {range}', + 'thread.outdatedTag': 'Desatualizada', + 'thread.elsewhereTag': 'Fora dos commits escolhidos', + 'thread.author': 'Autor', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Conta excluída', + 'thread.resolvedRow': 'Discussão resolvida · {author}', + 'thread.expand': 'Expandir', + 'thread.collapse': 'Recolher', + 'thread.outdatedSection': '{count} discussões desatualizadas: commits posteriores alteraram o código delas', + 'thread.viewOriginal': 'Ver o arquivo naquele commit', + 'thread.hideOriginal': 'Ocultar o arquivo daquele commit', + 'thread.originalLoading': 'Lendo o arquivo naquele commit…', + 'thread.originalFailed': 'Não foi possível ler o arquivo naquele commit: {message}', + 'thread.openOnHost': 'Ver no GitHub', + 'thread.unresolvedCount': '{count} não resolvidas', + 'thread.unresolvedAria': '{name} tem {count} discussões não resolvidas; abrir a lista de discussões', + 'thread.treeCount': '{count} discussões não resolvidas', + 'thread.loadFailed': 'Não foi possível ler as discussões do GitHub: {message}', + 'thread.inputFailed': 'O campo de entrada não pôde receber esta discussão; tente novamente mais tarde', + 'thread.copy': 'Copiar', + 'thread.copied': 'Copiado', + 'thread.codeBlock': 'Bloco de código', + 'thread.wrap': 'Quebrar linhas', + 'thread.unwrap': 'Não quebrar linhas', + 'thread.footnotes': 'Notas de rodapé', } diff --git a/packages/experimental/client-ui-forge/src/client/mount.ts b/packages/experimental/client-ui-forge/src/client/mount.ts index 34fee1a16b..84f58d4be7 100644 --- a/packages/experimental/client-ui-forge/src/client/mount.ts +++ b/packages/experimental/client-ui-forge/src/client/mount.ts @@ -7,19 +7,21 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' -import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBranchOutlineRegular, type QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge/remote' +import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelChangeRequest } from '../types.ts' import { ReviewQueue } from './review-queue.ts' import { ViewedFiles } from './viewed-files.ts' import { formatReviewComment, reviewQuoteSummary, type ReviewSelection } from './review.ts' +import { formatThreadQuote, threadQuoteSummary } from './review-threads.ts' import { ActionToast } from './ActionToast.tsx' import { CHANGES_ID, changesDefinition } from './changes-address.ts' import { createChangesStore } from './changes-store.ts' @@ -147,16 +149,22 @@ function registerUi(ctx: Context): void { () => { notices.report('toast.promptFailed') }, ) } - const addReviewToInput = (sessionId: SessionId, selection: ReviewSelection): boolean => { + /** Insert one quote into the Session's main input, numbering it after the quotes of the same heading already there. */ + const quoteInInput = (sessionId: SessionId, heading: RegExp, quote: (index: number) => string, summary: QuoteSummary): boolean => { const scope = ctx.sessions.scope(sessionId) const conversation = scope?.get('conversation') if (scope === undefined || conversation === undefined) return false const input = conversation.input.for(scope) - const numbers = Array.from(input.state.getSnapshot().draft.matchAll(/(?:^|\n)(?:> )?## User Comment (\d+)/g), match => Number(match[1])) - if (!input.insertQuote(formatReviewComment(selection, Math.max(0, ...numbers) + 1), reviewQuoteSummary(selection.comment))) return false + const numbers = Array.from(input.state.getSnapshot().draft.matchAll(heading), match => Number(match[1])) + if (!input.insertQuote(quote(Math.max(0, ...numbers) + 1), summary)) return false input.focus() return true } + const addReviewToInput = (sessionId: SessionId, selection: ReviewSelection): boolean => quoteInInput(sessionId, + /(?:^|\n)(?:> )?## User Comment (\d+)/g, index => formatReviewComment(selection, index), reviewQuoteSummary(selection.comment)) + const addThreadToInput = (sessionId: SessionId, thread: ForgeReviewThread, url: string, range: string | null): boolean => + quoteInInput(sessionId, /(?:^|\n)(?:> )?## Review Thread (\d+)/g, index => formatThreadQuote(thread, url, index), + threadQuoteSummary(thread, range)) /** How toasts name a change request: its number, else its title, else its URL. */ const nameOf = (entry: ForgePanelChangeRequest): string => { const number = numberOf(entry) @@ -242,6 +250,8 @@ function registerUi(ctx: Context): void { viewedFiles: store.viewed, changeRequestText: (url, commit, path) => ctx.remote.forgePanel.changeRequestText(sessionId, url, commit, path), addReviewToInput: selection => addReviewToInput(sessionId, selection), + addThreadToInput: (thread, url, range) => addThreadToInput(sessionId, thread, url, range), + changeRequestReview: (url, headSha, signal) => ctx.remote.forgePanel.changeRequestReview(sessionId, url, headSha, signal), reviewQueue: ({ id, signal }) => { let queue = store.reviews.get(signal) if (queue === undefined) { diff --git a/packages/experimental/client-ui-forge/src/client/review-threads.ts b/packages/experimental/client-ui-forge/src/client/review-threads.ts new file mode 100644 index 0000000000..831b2acc0a --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/review-threads.ts @@ -0,0 +1,171 @@ +/** + * Where the changes view shows each review thread of a change request, which threads each filter of the thread list + * keeps, and the composer quote of a whole thread. + */ + +import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ForgeAnchor, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ForgeCommitRange, ForgeDiffFile } from '../types.ts' + +/** The changes a view shows, which decides where a thread can sit. */ +export interface ThreadView { + /** Head commit the threads were read at. */ + readonly head: string + /** Commit the shown changes end at: the head, or the last chosen commit. */ + readonly end: string + /** Chosen consecutive commits, or null for the whole change request. */ + readonly commits: ForgeCommitRange | null +} + +/** + * Where a thread shows in the changes view: on lines of one side, at the top of its file, in its file's outdated + * section, or nowhere in the file because the chosen commits do not hold the lines it is on. + */ +export type ThreadPlace = + | { readonly kind: 'line'; readonly side: 'L' | 'R'; readonly startLine: number; readonly endLine: number } + | { readonly kind: 'file' } + | { readonly kind: 'outdated' } + | { readonly kind: 'elsewhere' } + +/** One filter of the thread list. */ +export type ThreadFilter = 'unresolved' | 'outdated' | 'resolved' | 'mine' + +/** Filters of the thread list in display order. */ +export const THREAD_FILTERS: readonly ThreadFilter[] = ['unresolved', 'outdated', 'resolved', 'mine'] + +/** + * Place a thread in a view. The whole change request shows a thread at its position on the head. Chosen commits show + * only a new-side thread whose lines number the file at the last chosen commit: its position on the head when the + * commits end at the head, else the lines it was written on at that commit. The old side of chosen commits starts + * at the parent of the first commit, which no thread position numbers. + * @param thread - the thread. + * @param view - the shown changes. + * @returns where the thread shows. + */ +export function placeThread(thread: ForgeReviewThread, view: ThreadView): ThreadPlace { + if (thread.subject === 'file') return { kind: 'file' } + if (view.commits === null) return thread.current === null ? { kind: 'outdated' } : onLines(thread.side, thread.current) + if (thread.side === 'R' && thread.current !== null && view.end === view.head) return onLines('R', thread.current) + if (thread.side === 'R' && thread.original !== null && thread.original.commit === view.end) return onLines('R', thread.original) + return thread.current === null ? { kind: 'outdated' } : { kind: 'elsewhere' } +} + +function onLines(side: 'L' | 'R', anchor: ForgeAnchor): ThreadPlace { + return { kind: 'line', side, startLine: anchor.startLine, endLine: anchor.endLine } +} + +/** + * Name a side and lines, such as `R12` or `L3–5`. + * @param side - old or new side. + * @param anchor - the lines. + * @returns the range label. + */ +export function anchorRange(side: 'L' | 'R', anchor: Pick): string { + return `${side}${anchor.startLine}${anchor.endLine === anchor.startLine ? '' : `–${anchor.endLine}`}` +} + +/** + * Find the collapsed unchanged lines that hold a line: the index of the hunk they precede, or the hunk count after the last hunk. + * @param file - the diff file. + * @param side - side the line numbers. + * @param line - the line. + * @returns the gap index, or undefined when a hunk shows the line. + */ +export function gapOf(file: ForgeDiffFile, side: 'L' | 'R', line: number): number | undefined { + for (const [index, hunk] of file.hunks.entries()) { + const start = side === 'L' ? hunk.oldStart : hunk.newStart + const count = side === 'L' ? hunk.oldLines : hunk.newLines + if (line < start) return index + if (line < start + count) return undefined + } + return file.hunks.length +} + +/** + * Whether the signed-in user should answer a thread: it is not resolved, the user wrote in it, and someone else wrote last. + * @param thread - the thread. + * @param viewer - login of the signed-in user, or null when the host names none. + * @returns whether the thread awaits the user's reply. + */ +export function awaitsViewer(thread: ForgeReviewThread, viewer: string | null): boolean { + if (viewer === null || thread.resolved) return false + const last = thread.comments.at(-1)?.author?.login + return last !== viewer && thread.comments.some(comment => comment.author?.login === viewer) +} + +/** + * Whether a thread passes a filter of the thread list; `outdated` keeps a line thread without a position on the head. + * @param thread - the thread. + * @param filter - the filter, or null for every thread. + * @param viewer - login of the signed-in user. + * @returns whether the list shows the thread. + */ +export function passesFilter(thread: ForgeReviewThread, filter: ThreadFilter | null, viewer: string | null): boolean { + switch (filter) { + case null: return true + case 'unresolved': return !thread.resolved + case 'outdated': return thread.subject === 'line' && thread.current === null + case 'resolved': return thread.resolved + case 'mine': return awaitsViewer(thread, viewer) + /* v8 ignore next -- closed ThreadFilter union exhaustiveness guard */ + default: return assertNever(filter, 'ThreadFilter') + } +} + +/** Lines of a recorded diff hunk shown before its last line, beside the lines the thread is on. */ +const EXCERPT_CONTEXT = 3 + +/** + * Cut a recorded diff hunk to its header and the lines that end it: the thread's lines on its side plus a few before. + * GitHub records the hunk up to the thread's last line. + * @param hunk - the recorded hunk, header first. + * @param side - side the thread's lines number. + * @param lines - how many lines the thread covers. + * @returns the header and the kept lines. + */ +export function hunkExcerpt(hunk: string, side: 'L' | 'R', lines: number): { readonly header: string | null; readonly lines: readonly string[] } { + const header = hunk.startsWith('@@') ? hunk.split('\n', 1).join('') : null + const body = (header === null ? hunk : hunk.slice(header.length + 1)).split('\n') + const other = side === 'L' ? '+' : '-' + const kept: string[] = [] + let wanted = lines + EXCERPT_CONTEXT + for (const line of body.toReversed()) { + if (wanted === 0) break + kept.push(line) + if (!line.startsWith(other)) wanted -= 1 + } + return { header, lines: kept.reverse() } +} + +/** + * Describe a composer quote of a thread by its file, range, and first comment. + * @param thread - the quoted thread. + * @param range - the thread's range label, or null for a file thread. + * @returns the composer's display of the quote. + */ +export function threadQuoteSummary(thread: ForgeReviewThread, range: string | null): QuoteSummary { + const name = thread.path.slice(thread.path.lastIndexOf('/') + 1) + return { label: range === null ? name : `${name} ${range}`, title: thread.path, detail: range ?? thread.path, body: thread.comments[0]?.body ?? '' } +} + +/** + * Format a whole thread for the ordinary, logged user message. Comment bodies are quoted line by line, so their + * Markdown cannot add headings to the message. + * @param thread - the thread. + * @param url - web page of the change request. + * @param index - one-based thread number in the composer. + * @returns Markdown content for an atomic composer quote. + */ +export function formatThreadQuote(thread: ForgeReviewThread, url: string, index: number): string { + const anchor = thread.current ?? thread.original + const where = thread.subject === 'file' || anchor === null ? 'Lines: whole file' + : `Side: ${thread.side}\nLines: ${anchor.startLine === anchor.endLine ? anchor.startLine : `${anchor.startLine}-${anchor.endLine}`}\nCommit: ${anchor.commit}` + const state = [thread.resolved ? 'resolved' : 'unresolved', ...thread.subject === 'line' && thread.current === null ? ['outdated'] : []].join(', ') + const fence = thread.diffHunk === null ? '' : '`'.repeat(Math.max(3, ...Array.from(thread.diffHunk.matchAll(/`+/g), match => match[0].length + 1))) + const hunk = thread.diffHunk === null ? '' : `Diff hunk:\n${fence}diff\n${thread.diffHunk}\n${fence}\n` + const comments = thread.comments.map(comment => `${comment.author === null ? 'Unknown author' : `@${comment.author.login}`} (${comment.createdAt}):\n` + + comment.body.split('\n').map(line => `> ${line}`).join('\n')).join('\n\n') + const link = thread.comments[0]?.url ?? url + return `## Review Thread ${index}\nFile: ${thread.path}\n${where}\nState: ${state}\nSource: ${link}\n${hunk}Comments:\n${comments}` +} diff --git a/packages/experimental/client-ui-forge/src/client/thread-state.ts b/packages/experimental/client-ui-forge/src/client/thread-state.ts new file mode 100644 index 0000000000..742cc8b1bd --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/thread-state.ts @@ -0,0 +1,124 @@ +/** Reads the review threads of the change request a changes view shows and places them in its files. */ + +import { useEffect, useMemo, useState } from 'react' +import type { ForgeReviewData, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { LineThread } from './ReviewThreads.tsx' +import { placeThread, type ThreadPlace, type ThreadView } from './review-threads.ts' + +/** What the changes view knows about its change request when it reads threads. */ +export interface ThreadReadInput { + /** Whether the view is visible; a hidden view reads nothing. */ + readonly visible: boolean + /** Binding URL of the change request, or null for local changes. */ + readonly url: string | null + /** Head commit of the latest status, or null before one. */ + readonly head: string | null + /** Update time of the latest status, or null before one. */ + readonly updatedAt: string | null + /** Epoch milliseconds of the latest status, so every status asks the Host again; the Host answers from memory until the threads age. */ + readonly statusAt: number | null + /** Changes on each refresh. */ + readonly attempt: number + /** Read the threads at a head commit. */ + readonly read: (url: string, headSha: string, signal: AbortSignal) => Promise> +} + +/** The last threads read and why the latest read failed. */ +export interface ThreadReadState { + /** Threads of the last successful read for the URL, at any head, or null. */ + readonly review: ForgeReviewData | null + /** Message of the latest failed read, or null. */ + readonly failure: string | null + /** Whether the provider cannot read reviews, so the view shows no threads and no failure. */ + readonly unsupported: boolean +} + +/** + * Read the threads at the head of each status while the view is visible. A read refused because the head moved + * keeps the previous threads; the next status names the new head and reads again. + * @param input - the change request and the reader. + * @returns the last threads and the latest failure. + */ +export function useThreadRead(input: ThreadReadInput): ThreadReadState { + const { visible, url, head, updatedAt, statusAt, attempt, read } = input + const [state, setState] = useState( + { url: null, review: null, failure: null, unsupported: false }) + useEffect(() => { + if (!visible || url === null || head === null) return + const controller = new AbortController() + void read(url, head, controller.signal).then((result) => { + if (controller.signal.aborted) return + if (result.ok) { + setState({ url, review: result.value, failure: null, unsupported: false }) + return + } + const { code, message } = result.error + if (code === 'forgePanel/stale-head') return + setState(previous => ({ url, review: previous.url === url ? previous.review : null, + failure: code === 'forgePanel/review-unsupported' ? null : message, unsupported: code === 'forgePanel/review-unsupported' })) + }) + return () => { controller.abort() } + }, [visible, url, head, updatedAt, statusAt, attempt, read]) + return state.url === url ? state : { review: null, failure: null, unsupported: false } +} + +/** Threads of one file grouped by where they show, and how many of them are unresolved. */ +export interface PlacedFileThreads { + readonly lines: readonly LineThread[] + readonly file: readonly ForgeReviewThread[] + readonly outdated: readonly ForgeReviewThread[] + readonly unresolved: number +} + +/** One thread and where the shown changes place it. */ +export interface PlacedThread { + readonly thread: ForgeReviewThread + readonly place: ThreadPlace +} + +/** Every thread with its place and the threads of each file. */ +export interface PlacedThreads { + /** Every thread in host order with its place. */ + readonly entries: readonly PlacedThread[] + /** Threads of each path. */ + readonly files: ReadonlyMap + /** Unresolved threads by path, for the file tree. */ + readonly unresolved: ReadonlyMap +} + +const NONE: PlacedThreads = { entries: [], files: new Map(), unresolved: new Map() } + +/** + * Place the threads of a review in the shown changes. + * @param review - threads read at the head of the latest status, or null. + * @param view - the shown changes, or null before they load. + * @returns places, per-file groups, and unresolved counts. + */ +export function usePlacedThreads(review: ForgeReviewData | null, view: ThreadView | null): PlacedThreads { + const commits = view?.commits ?? null + const end = view?.end ?? null + return useMemo(() => { + if (review === null || end === null) return NONE + const entries: PlacedThread[] = [] + const files = new Map() + const unresolved = new Map() + for (const thread of review.threads) { + const place = placeThread(thread, { head: review.headSha, end, commits }) + entries.push({ thread, place }) + let group = files.get(thread.path) + if (group === undefined) { + group = { lines: [], file: [], outdated: [], unresolved: 0 } + files.set(thread.path, group) + } + if (place.kind === 'line') group.lines.push({ thread, place }) + else if (place.kind === 'file') group.file.push(thread) + else if (place.kind === 'outdated') group.outdated.push(thread) + if (!thread.resolved) { + group.unresolved += 1 + unresolved.set(thread.path, group.unresolved) + } + } + return { entries, files, unresolved } + }, [review, end, commits]) +} diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index a4b277e34c..9cbb1f9656 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -23,8 +23,11 @@ import baseCss from '../src/client/BaseMenu.module.css' import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeDiffFile, ForgeLocalDiff, ForgePanelChangeRequest, ForgePanelState, } from '../src/types.ts' -import type { ForgeCommitList } from '@deepseek-ai/dsh-experimental-forge/types' +import type { + ChangeRequestKey, ForgeCommitList, ForgeReviewData, ForgeReviewThread, ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge/types' import { entry, ready, snapshot } from './client-fixtures.client.ts' +import { reviewComment, reviewThread } from './thread-fixtures.client.ts' const t = makeTranslate(en) const SESSION = SessionId('s1') @@ -138,6 +141,8 @@ const REFUSED = { staleHead: { ok: false, error: new RemoteError('forgePanel/stale-head', 'the head moved', {}) }, hostRefused: { ok: false, error: new RemoteError('forgePanel/diff-failed', 'diff too large', {}) }, failed: { ok: false, error: new RemoteError('gateway/bad-request', 'failed', {}) }, + reviewUnsupported: { ok: false, error: new RemoteError('forgePanel/review-unsupported', 'no review reads', {}) }, + reviewFailed: { ok: false, error: new RemoteError('forgePanel/review-failed', 'GitHub is down', {}) }, } satisfies Record> /** @@ -239,6 +244,8 @@ function mount(options: MountOptions = {}) { reviewQueue: vi.fn().mockReturnValue(new ReviewQueue('test-review')), submitReview: vi.fn().mockResolvedValue({ ok: true, value: { ok: true, url: URL7 } }), addReviewToInput: vi.fn().mockReturnValue(true), + addThreadToInput: vi.fn().mockReturnValue(true), + changeRequestReview: vi.fn(async () => REFUSED.reviewUnsupported), createChangeRequest: vi.fn(), report: vi.fn(), } @@ -1632,3 +1639,181 @@ describe('ChangesTab commits', () => { expect(m.view.queryByRole('button', { name: /^Commits shown/ })).toBeNull() }) }) + +describe('ChangesTab review threads', () => { + const source: ChangesSource = { kind: 'change', url: URL7 } + const OLD = 'b'.repeat(40) + const LARGE = file('logs/large.txt', { status: 'added', additions: 1001, + hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 1001, lines: Array.from({ length: 1001 }, (_, index) => `+${index}`) }] }) + const FILES = [APP, NOTES, GONE, LOGO, BIG, MOVED, LARGE] + const line = (id: string, path: string, side: 'L' | 'R', at: number, overrides: Partial = {}): ForgeReviewThread => reviewThread({ + id: id as ForgeThreadId, path, side, + current: { commit: HEAD, startLine: at, endLine: at }, original: { commit: HEAD, startLine: at, endLine: at }, + comments: [reviewComment({ body: `Comment ${id}` })], ...overrides, + }) + const THREADS: ForgeReviewThread[] = [ + line('on-row', 'src/app.ts', 'R', 4, { comments: [reviewComment({ body: 'Comment on-row', author: { login: 'octo', avatarUrl: null, bot: false } }), + reviewComment({ body: 'Reply', author: { login: 'lin', avatarUrl: null, bot: false } })] }), + line('old-side', 'src/app.ts', 'L', 11), + line('in-gap', 'src/app.ts', 'R', 8), + line('resolved', 'src/app.ts', 'R', 5, { resolved: true }), + line('outdated', 'src/app.ts', 'R', 0, { current: null, original: { commit: OLD, startLine: 9, endLine: 9 } }), + reviewThread({ id: 'file-thread' as ForgeThreadId, path: 'docs/notes.txt', subject: 'file', current: null, original: null, diffHunk: null, + comments: [reviewComment({ body: 'Comment file-thread' })] }), + line('beyond', 'docs/gone.txt', 'L', 3), + line('binary', 'assets/logo.png', 'R', 1), + line('oversized', 'logs/big.log', 'R', 1), + line('renamed', 'lib/b.txt', 'R', 1), + line('large', 'logs/large.txt', 'R', 1), + line('missing', 'src/missing.ts', 'R', 1), + line('missing-no-url', 'src/missing.ts', 'R', 2, { comments: [reviewComment({ url: null })] }), + ] + const REVIEW: ForgeReviewData = { headSha: HEAD, viewer: 'octo', threads: THREADS, truncated: false } + const reviewed = (review: ForgeReviewData = REVIEW) => (faces: Faces): void => { + faces.changeRequestDiff.mockImplementation(async () => ok({ ...CHANGE, files: FILES })) + faces.changeRequestReview.mockImplementation(async () => ok(review)) + } + /** Binding of #7 at the head whose latest status settled at `fetchedAt`. */ + const polledAt = (fetchedAt: number): ForgePanelChangeRequest => { + const bound = entry(7, snapshot(7)) + return { ...bound, update: { key: 'github:github.com/octo/demo#7' as ChangeRequestKey, fetchedAt, snapshot: snapshot(7) } } + } + const card = (m: ReturnType, id: string): HTMLElement | null => m.view.container.querySelector(`[data-forge-thread="${id}"]`) + /** Code of the row a thread follows, on the side its lines number. */ + const following = (m: ReturnType, id: string, side: 'L' | 'R' = 'R'): string | null => + card(m, id)?.previousElementSibling?.querySelector(side === 'L' ? '[data-old-line]' : '[data-new-line]')?.textContent ?? null + + it('reads threads at the head of the latest status and places them on rows, gaps, files, and outdated sections', async () => { + const m = mount({ source, setup: reviewed() }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledWith(URL7, HEAD, expect.any(AbortSignal)) + const app = m.section('src/app.ts') + expect(following(m, 'on-row')).toBe('const y = 4') + expect(following(m, 'old-side', 'L')).toBe('const w = 12') + expect(card(m, 'in-gap')?.previousElementSibling?.getAttribute('data-forge-diff-gap')).toBe('1') + expect(card(m, 'resolved')?.textContent).toContain('Resolved thread · reviewer') + expect(within(app).getByRole('button', { name: 'app.ts has 4 unresolved threads; open the thread list' }).textContent).toBe('4 unresolved') + expect(app.querySelector('[data-forge-outdated-threads]')?.textContent).toContain('1 outdated threads') + fireEvent.click(within(card(m, 'outdated')!).getByRole('button', { name: en['thread.viewOriginal'] })) + expect(m.faces.changeRequestText).toHaveBeenLastCalledWith(URL7, OLD, 'src/app.ts') + expect(m.section('docs/notes.txt').firstElementChild?.nextElementSibling?.getAttribute('data-forge-thread')).toBe('file-thread') + expect(m.section('docs/gone.txt').querySelector(`.${css.code}`)?.lastElementChild?.getAttribute('data-forge-thread')).toBe('beyond') + for (const id of ['binary', 'oversized', 'renamed', 'large']) expect(card(m, id)).not.toBeNull() + expect(within(m.view.getByRole('navigation')).getByTitle('4 unresolved threads').textContent).toBe('4') + fireEvent.click(within(app).getByRole('button', { name: '4 unchanged lines' })) + await settle() + expect(following(m, 'in-gap')).toBe('const a7 = 8') + fireEvent.click(within(card(m, 'on-row')!).getByRole('button', { name: en['review.addToInput'] })) + expect(m.faces.addThreadToInput).toHaveBeenCalledWith(THREADS[0], URL7, 'R4') + m.faces.addThreadToInput.mockReturnValue(false) + fireEvent.click(within(card(m, 'on-row')!).getByRole('button', { name: en['review.addToInput'] })) + expect(m.faces.report).toHaveBeenLastCalledWith('thread.inputFailed') + }) + + it('opens the thread list, filters it, and opens each thread at its place', async () => { + const m = mount({ source, setup: reviewed({ ...REVIEW, truncated: true }) }) + await settle() + fireEvent.click(m.view.getByRole('button', { name: 'Review threads, 13 in total' })) + const list = m.view.getByRole('complementary', { name: 'Review threads, 13 in total' }) + expect(within(list).getAllByRole('button', { pressed: true }).map(button => button.textContent)).toEqual(['Unresolved 12']) + expect(within(list).getByText(/^Too many threads; showing the first 13/)).toBeDefined() + expect([...list.querySelectorAll('[data-forge-thread-group]')].map(group => group.getAttribute('data-forge-thread-group'))) + .toEqual(['assets/logo.png', 'docs/gone.txt', 'docs/notes.txt', 'lib/b.txt', 'logs/big.log', 'logs/large.txt', 'src/app.ts', 'src/missing.ts']) + scrolled.mockClear() + fireEvent.click(list.querySelector('[data-forge-thread-item="on-row"]')!) + expect(scrolled.mock.contexts).toContain(card(m, 'on-row')) + fireEvent.click(within(list).getByRole('button', { name: 'Resolved 1' })) + fireEvent.click(list.querySelector('[data-forge-thread-item="resolved"]')!) + expect(card(m, 'resolved')?.tagName).toBe('SECTION') + fireEvent.click(within(list).getByRole('button', { name: 'Resolved 1' })) + fireEvent.click(list.querySelector('[data-forge-thread-item="missing"]')!) + expect(m.faces.openLink).toHaveBeenLastCalledWith(THREADS[11]?.comments[0]?.url) + m.faces.openLink.mockClear() + fireEvent.click(list.querySelector('[data-forge-thread-item="missing-no-url"]')!) + expect(m.faces.openLink).not.toHaveBeenCalled() + fireEvent.click(within(list).getByRole('button', { name: en['thread.openAll'] })) + expect(m.faces.openLink).toHaveBeenLastCalledWith(URL7) + fireEvent.click(within(list).getByRole('button', { name: 'Awaiting my reply 1' })) + expect([...list.querySelectorAll('[data-forge-thread-item]')].map(item => item.getAttribute('data-forge-thread-item'))).toEqual(['on-row']) + fireEvent.click(within(list).getByRole('button', { name: en['thread.close'] })) + expect(m.view.queryByRole('complementary')).toBeNull() + scrolled.mockClear() + fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: /^app\.ts has 4 unresolved threads/ })) + expect(scrolled.mock.contexts).toContain(m.view.container.querySelector('[data-forge-thread-group="src/app.ts"]')) + }) + + it('places only the threads whose lines number the last chosen commit, and shows the others after choosing all commits', async () => { + const two = '2'.repeat(40) + const threads = [line('at-head', 'src/app.ts', 'R', 4), line('written-on-two', 'src/app.ts', 'R', 3, { current: null, original: { commit: two, startLine: 3, endLine: 3 } })] + const m = mount({ source, setup: (faces) => { + faces.changeRequestDiff.mockImplementation(async (_url, commits) => ok({ ...CHANGE, headSha: commits?.to ?? HEAD })) + faces.changeRequestReview.mockImplementation(async () => ok({ ...REVIEW, threads })) + } }) + await settle() + m.store.actions.committed(TAB, { from: two, to: two }) + await settle() + expect(card(m, 'at-head')).toBeNull() + expect(following(m, 'written-on-two')).toBe('const a3 = 3') + fireEvent.click(m.view.getByRole('button', { name: 'Review threads, 2 in total' })) + const list = m.view.getByRole('complementary') + fireEvent.click(within(list).getByRole('button', { name: 'Unresolved 2' })) + expect(list.querySelector('[data-forge-thread-item="at-head"]')?.textContent).toContain(en['thread.elsewhereTag']) + fireEvent.click(list.querySelector('[data-forge-thread-item="at-head"]')!) + expect(m.store.getSnapshot().byTab[TAB]?.commits).toBeNull() + await settle() + expect(following(m, 'at-head')).toBe('const y = 4') + }) + + it('shows threads only at the head the diff and the latest status name, and reports a failed read with a retry', async () => { + const m = mount({ source, setup: (faces) => { faces.changeRequestReview.mockResolvedValue(REFUSED.reviewFailed) } }) + await settle() + expect(m.view.getByText('Could not read GitHub threads: GitHub is down')).toBeDefined() + m.faces.changeRequestReview.mockResolvedValue(ok({ ...REVIEW, threads: [line('on-row', 'src/app.ts', 'R', 4)] })) + fireEvent.click(within(m.view.getByText('Could not read GitHub threads: GitHub is down').closest('[role="status"]')!).getByRole('button', { name: en.retry })) + await settle() + expect(card(m, 'on-row')).not.toBeNull() + m.faces.changeRequestReview.mockResolvedValue(REFUSED.staleHead) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(2)] }), disconnected: false }) }) + await settle() + expect(card(m, 'on-row')).not.toBeNull() + m.faces.changeRequestReview.mockResolvedValue(REFUSED.reviewFailed) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(3)] }), disconnected: false }) }) + await settle() + expect(card(m, 'on-row')).not.toBeNull() + expect(m.view.getByText('Could not read GitHub threads: GitHub is down')).toBeDefined() + act(() => { m.panel.set({ state: ready({ changeRequests: [entry(7, snapshot(7, { headSha: OLD }))] }), disconnected: false }) }) + await settle() + expect(card(m, 'on-row')).toBeNull() + }) + + it('names no author when the host hides the change request\'s author', async () => { + const m = mount({ source, state: ready({ changeRequests: [entry(7, snapshot(7, { author: null }))] }), setup: reviewed({ ...REVIEW, + threads: [line('on-row', 'src/app.ts', 'R', 4, { comments: [reviewComment({ author: { login: 'octo', avatarUrl: null, bot: false } })] })] }) }) + await settle() + expect(within(card(m, 'on-row')!).queryByText(en['thread.author'])).toBeNull() + }) + + it('reads no threads for local changes or a hidden view, and shows none for a provider without review reads', async () => { + const local = mount() + await settle() + expect(local.faces.changeRequestReview).not.toHaveBeenCalled() + cleanup() + const m = mount({ source }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledOnce() + expect(m.view.queryByRole('button', { name: /^Review threads/ })).toBeNull() + expect(m.view.queryByText(/^Could not read GitHub threads/)).toBeNull() + let answer: (value: RemoteResult) => void = () => {} + m.faces.changeRequestReview.mockImplementation(() => new Promise((resolve) => { answer = resolve })) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(2)] }), disconnected: false }) }) + expect(m.faces.changeRequestReview).toHaveBeenCalledTimes(2) + act(() => { m.visibility.set(false) }) + await act(async () => { answer(ok(REVIEW)) }) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(3)] }), disconnected: false }) }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledTimes(2) + act(() => { m.visibility.set(true) }) + await settle() + expect(card(m, 'on-row')).toBeNull() + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts index 177a4784bd..46b484be84 100644 --- a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts @@ -12,7 +12,7 @@ import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SidebarRightTabDefinition, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { - ChangeRequestSnapshot, ForgeCommitList, ForgeReviewRequest, ForgeReviewResult, + ChangeRequestSnapshot, ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, } from '@deepseek-ai/dsh-experimental-forge/types' import { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' @@ -31,6 +31,7 @@ import { SessionHoverForge } from '../src/client/SessionHoverForge.tsx' import { SessionRowForge } from '../src/client/SessionRowForge.tsx' import type { NoticeSource } from '../src/client/source.ts' import type { ReviewSelection } from '../src/client/review.ts' +import { reviewThread } from './thread-fixtures.client.ts' import { WorktreeProgress, type WorktreeProgressInjected } from '../src/client/WorktreeProgress.tsx' import { WorktreeSettings } from '../src/client/WorktreeSettings.tsx' import type { WorktreeSettingsInjected, WorktreeSettings as WorktreeSettingsFields } from '../src/client/worktree-settings-form.ts' @@ -183,6 +184,8 @@ async function base() { changeRequestCommits: vi.fn(async (_sessionId: SessionId, _url: string, _signal?: AbortSignal): Read => ({ ok: true, value: COMMITS })), changeRequestText: vi.fn(async (_sessionId: SessionId, _url: string, _commit: string, _path: string): Read => ({ ok: true, value: 'b\n' })), + changeRequestReview: vi.fn(async (_sessionId: SessionId, _url: string, headSha: string, _signal?: AbortSignal): Read => + ({ ok: true, value: { headSha, viewer: 'octo', threads: [], truncated: false } })), submitReview: vi.fn(async (_sessionId: SessionId, _url: string, _request: ForgeReviewRequest): Read => ({ ok: true, value: { ok: true, url: 'https://github.com/review' } })), } ctx.provide('remote.forgePanel', forgePanel) @@ -750,9 +753,16 @@ describe('mountForgePanel changes view', () => { expect(insertQuote.mock.calls[0]?.[1]).toEqual({ label: 'a.ts R1', title: 'a.ts', detail: 'R1', body: 'Check this' }) expect(resolve).toHaveBeenCalledWith(scope) expect(focus).toHaveBeenCalledTimes(2) + const thread = reviewThread({ path: 'src/a.ts' }) + expect(view.addThreadToInput(thread, 'https://github.com/octo/demo/pull/7', 'R3')).toBe(true) + expect(view.addThreadToInput(thread, 'https://github.com/octo/demo/pull/7', 'R3')).toBe(true) + expect(insertQuote.mock.calls.slice(2).map(([text, summary]) => [text.split('\n')[0], summary?.label])) + .toEqual([['## Review Thread 1', 'a.ts R3'], ['## Review Thread 2', 'a.ts R3']]) + insertQuote.mockClear() + focus.mockClear() insertQuote.mockReturnValueOnce(false) expect(view.addReviewToInput(selection)).toBe(false) - expect(focus).toHaveBeenCalledTimes(2) + expect(focus).not.toHaveBeenCalled() expect(send).not.toHaveBeenCalled() const request: ForgeReviewRequest = { kind: 'review', headSha: 'a', event: 'comment', body: 'Summary', comments: [] } expect(await view.submitReview('https://github.com/pr', request)).toMatchObject({ ok: true, value: { ok: true } }) @@ -823,6 +833,9 @@ describe('mountForgePanel changes view', () => { expect(f.forgePanel.localCommits).toHaveBeenCalledWith(S1, 'refs/heads/topic', signal) expect(await view.changeRequestCommits('https://github.com/octo/demo/pull/7', signal)).toEqual({ ok: true, value: COMMITS }) expect(f.forgePanel.changeRequestCommits).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7', signal) + expect(await view.changeRequestReview('https://github.com/octo/demo/pull/7', 'a'.repeat(40), signal)) + .toEqual({ ok: true, value: { headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false } }) + expect(f.forgePanel.changeRequestReview).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7', 'a'.repeat(40), signal) expect(view.viewedFiles).toBe(f.changes(S1).viewedFiles) expect(view.viewedFiles).not.toBe(f.changes(S2).viewedFiles) view.reconnect() diff --git a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx new file mode 100644 index 0000000000..b6173e7853 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +/** Host review thread cards, the outdated section, and the thread list rendered on their own. */ +import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { en } from '../src/client/locales.ts' +import { Avatar, OutdatedThreads, ThreadCard, type ThreadActions } from '../src/client/ReviewThreads.tsx' +import { ThreadList, type ThreadListProps } from '../src/client/ThreadList.tsx' +import { reviewComment, reviewThread } from './thread-fixtures.client.ts' + +const t = makeTranslate(en) +const OLD = 'b'.repeat(40) +const scrolled = vi.fn() + +beforeEach(() => { + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrolled }) +}) + +afterEach(() => { + cleanup() + scrolled.mockReset() + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') +}) + +function actions(overrides: Partial = {}): ThreadActions { + return { + viewer: 'octo', author: 'octo', focused: null, + addToInput: vi.fn(() => true), openLink: vi.fn(), readText: vi.fn(async (): Promise> => ({ ok: true, value: 'a\nb\nc' })), + inputFailed: vi.fn(), + ...overrides, + } +} + +const outdated = (overrides: Partial = {}): ForgeReviewThread => reviewThread({ + id: 'old' as ForgeThreadId, current: null, original: { commit: OLD, startLine: 2, endLine: 3 }, diffHunk: '@@ -1 +1,3 @@\n a\n+b\n+c', ...overrides, +}) + +describe('Avatar', () => { + it('shows the image, or the login\'s first letter when there is none or it fails, or a mark for a deleted account', () => { + const view = render(<> + ) + const image = view.container.querySelector('img')! + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + fireEvent.error(image) + expect([...view.container.querySelectorAll('span')].map(span => span.textContent)).toEqual(['O', 'L', '?']) + }) +}) + +describe('ThreadCard', () => { + it('shows each comment with its author, time, role tags, and Markdown without raw HTML', () => { + const thread = reviewThread({ comments: [ + reviewComment({ author: { login: 'octo', avatarUrl: null, bot: false }, body: '**bold** raw', createdAt: 'not a time' }), + reviewComment({ author: { login: 'ci', avatarUrl: null, bot: true }, pending: true }), + reviewComment({ author: null }), + ] }) + const view = render() + const card = view.container.querySelector('[data-forge-thread]')! + expect(card.getAttribute('data-split')).toBe('') + expect(card.querySelector('strong')?.textContent).toBe('bold') + expect(card.textContent).not.toContain('') + expect(card.textContent).not.toContain('hidden') + expect(within(card as HTMLElement).getByText('not a time')).toBeDefined() + expect(card.querySelectorAll('[data-forge-thread-comment]')).toHaveLength(3) + expect(within(card as HTMLElement).getAllByText(en['thread.author'])).toHaveLength(1) + expect(within(card as HTMLElement).getByText(en['thread.bot'])).toBeDefined() + expect(within(card as HTMLElement).getByText(en['review.pending'])).toBeDefined() + expect(within(card as HTMLElement).getByText(en['thread.deletedAuthor'])).toBeDefined() + }) + + it('quotes the thread, reports a refused quote, and opens its page', () => { + const shared = actions({ addToInput: vi.fn(() => false) }) + const thread = reviewThread() + const view = render() + fireEvent.click(view.getByRole('button', { name: en['review.addToInput'] })) + expect(shared.addToInput).toHaveBeenCalledWith(thread, 'R3') + expect(shared.inputFailed).toHaveBeenCalledOnce() + fireEvent.click(view.getByRole('button', { name: en['thread.openOnHost'] })) + expect(shared.openLink).toHaveBeenCalledWith(thread.comments[0]?.url) + }) + + it('names a file thread and offers no page without one', () => { + const view = render() + expect(view.getByRole('region', { name: en['thread.fileLevel'] }).textContent).toContain(en['thread.fileLevel']) + expect(view.queryByRole('button', { name: en['thread.openOnHost'] })).toBeNull() + }) + + it('folds a resolved thread into one row until unfolded or jumped to, and folds it again after a jump', () => { + const thread = reviewThread({ resolved: true, comments: [] }) + const place = { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const + const view = render() + expect(view.container.textContent).toContain('Resolved thread · Deleted account') + fireEvent.click(view.getByRole('button', { name: en['thread.expand'] })) + expect(view.getByRole('region', { name: 'R3' })).toBeDefined() + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.queryByRole('region')).toBeNull() + view.rerender() + expect(view.getByRole('region', { name: 'R3' })).toBeDefined() + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.queryByRole('region')).toBeNull() + view.rerender() + expect(view.getByRole('region', { name: 'R3' })).toBeDefined() + }) + + it('shows an outdated thread at its original lines with the end of its hunk and the file at that commit', async () => { + let answer: (value: RemoteResult) => void = () => {} + const shared = actions({ readText: vi.fn(() => new Promise>((resolve) => { answer = resolve })) }) + const view = render() + const card = view.getByRole('region', { name: en['thread.fileLevel'] }) + expect(card.textContent).toContain(`Originally R2–3 · on ${OLD.slice(0, 7)}`) + expect([...card.querySelectorAll('[class*="excerptLine"]')].map(line => line.textContent)).toEqual(['@@ -1 +1,3 @@', ' a', '+b', '+c']) + fireEvent.click(view.getByRole('button', { name: en['thread.viewOriginal'] })) + expect(shared.readText).toHaveBeenCalledWith(OLD, 'src/a.ts') + expect(view.getByText(en['thread.originalLoading'])).toBeDefined() + await act(async () => { answer({ ok: true, value: 'one\ntwo\nthree\nfour' }) }) + const file = view.getByRole('region', { name: en['thread.viewOriginal'] }) + expect([...file.querySelectorAll('[class*="marked"]')].map(line => line.textContent)).toEqual(['2two', '3three']) + expect(scrolled.mock.contexts).toContain(file.querySelectorAll('[class*="marked"]')[0]) + fireEvent.click(view.getByRole('button', { name: en['thread.hideOriginal'] })) + expect(view.queryByRole('region', { name: en['thread.viewOriginal'] })).toBeNull() + const failing = { ...shared, readText: vi.fn(async (): Promise> => ({ ok: false, error: new RemoteError('forgePanel/diff-failed', 'gone', {}) })) } + view.rerender() + fireEvent.click(view.getByRole('button', { name: en['thread.viewOriginal'] })) + await act(async () => { await Promise.resolve() }) + expect(view.getByRole('alert').textContent).toBe('Could not read the file at that commit: gone') + }) + + it('offers no file at that commit for an old-side thread and no excerpt without a recorded hunk', () => { + const view = render() + expect(view.queryByRole('button', { name: en['thread.viewOriginal'] })).toBeNull() + expect(view.container.querySelector('[class*="excerpt"]')).toBeNull() + }) +}) + +describe('OutdatedThreads', () => { + it('renders nothing without threads, folds on request, and opens again for a later jump to one of its threads', () => { + const empty = render() + expect(empty.container.textContent).toBe('') + cleanup() + const view = render() + expect(view.container.textContent).toContain('1 outdated threads') + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.container.querySelector('[data-forge-thread]')).toBeNull() + view.rerender() + expect(view.container.querySelector('[data-forge-thread]')).toBeNull() + view.rerender() + expect(view.container.querySelector('[data-forge-thread]')).not.toBeNull() + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.container.querySelector('[data-forge-thread]')).toBeNull() + fireEvent.click(view.getByRole('button', { name: en['thread.expand'] })) + expect(view.container.querySelector('[data-forge-thread]')).not.toBeNull() + }) +}) + +describe('ThreadList', () => { + function list(overrides: Partial = {}) { + const props: ThreadListProps = { + entries: [], order: [], viewer: 'octo', truncated: false, host: 'github.com', url: 'https://github.com/octo/demo/pull/7', filter: null, + file: null, choose: vi.fn(), open: vi.fn(), openLink: vi.fn(), close: vi.fn(), t, ...overrides, + } + return { props, view: render() } + } + + it('says when there are no threads, when none pass the filter, and how many the host has', () => { + expect(list().view.getByText(en['thread.empty'])).toBeDefined() + cleanup() + const { view } = list({ entries: [{ thread: reviewThread(), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } }], filter: 'resolved' }) + expect(view.getByText(en['thread.none'])).toBeDefined() + expect(view.getByText(/^1 threads on github\.com/)).toBeDefined() + }) + + it('orders each file\'s threads from file threads to lines to outdated ones and describes each', () => { + const entries = [ + { thread: outdated({ id: 'gone' as ForgeThreadId, comments: [reviewComment(), reviewComment()] }), place: { kind: 'outdated' } as const }, + { thread: reviewThread({ id: 'late' as ForgeThreadId }), place: { kind: 'line', side: 'R', startLine: 9, endLine: 9 } as const }, + { thread: reviewThread({ id: 'away' as ForgeThreadId, current: null, original: null }), place: { kind: 'elsewhere' } as const }, + { thread: reviewThread({ id: 'whole' as ForgeThreadId, subject: 'file', current: null, original: null }), place: { kind: 'file' } as const }, + { thread: reviewThread({ id: 'early' as ForgeThreadId, comments: [reviewComment({ + body: '# Title\n> **Note** `code` [link](https://example.test) x', author: { login: 'ci', avatarUrl: null, bot: true } })] }), + place: { kind: 'line', side: 'L', startLine: 2, endLine: 4 } as const }, + ] + const { view, props } = list({ entries }) + const items = [...view.container.querySelectorAll('[data-forge-thread-item]')] + expect(items.map(item => item.getAttribute('data-forge-thread-item'))).toEqual(['whole', 'early', 'late', 'gone', 'away']) + expect(items.map(item => item.querySelector('[class*="meta"]')?.textContent)).toEqual([ + en['thread.fileLevel'], 'L2–4', 'R9', `Originally R2–3 · on ${OLD.slice(0, 7)} · 1 replies`, '', + ]) + expect(items[1]?.querySelector('[class*="excerpt"]')?.textContent).toBe('Title Note code link x') + expect(items[1]?.textContent).toContain(en['thread.bot']) + expect(items[3]?.textContent).toContain(en['thread.outdatedTag']) + expect(items[4]?.textContent).toContain(en['thread.elsewhereTag']) + fireEvent.click(items[3]!) + expect(props.open).toHaveBeenCalledWith(entries[0]) + fireEvent.click(view.getByRole('button', { name: 'Outdated 2' })) + expect(props.choose).toHaveBeenCalledWith('outdated') + fireEvent.click(view.getByRole('button', { name: en['thread.close'] })) + expect(props.close).toHaveBeenCalledOnce() + }) + + it('clears the active filter when it is chosen again and scrolls to a file\'s group', () => { + const entry = { thread: reviewThread({ path: 'b.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } + const other = { thread: reviewThread({ path: 'a.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } + const empty = { thread: reviewThread({ path: 'c.ts', comments: [] }), place: { kind: 'file' } as const } + const { view, props } = list({ entries: [empty, entry, other], order: ['b.ts'], filter: 'unresolved', file: 'a.ts' }) + expect([...view.container.querySelectorAll('[data-forge-thread-group]')].map(group => group.getAttribute('data-forge-thread-group'))) + .toEqual(['b.ts', 'a.ts', 'c.ts']) + expect(view.container.querySelector('[data-forge-thread-group="c.ts"] [class*="excerpt"]')?.textContent).toBe('') + expect(scrolled.mock.contexts).toEqual([view.container.querySelector('[data-forge-thread-group="a.ts"]')]) + fireEvent.click(view.getByRole('button', { name: 'Unresolved 3' })) + expect(props.choose).toHaveBeenCalledWith(null) + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts new file mode 100644 index 0000000000..2942e99f8a --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts @@ -0,0 +1,132 @@ +/** Placement, filters, excerpts, and composer quotes of host review threads. */ +import { describe, expect, it } from 'vitest' +import type { ForgeReviewComment, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import { + anchorRange, awaitsViewer, formatThreadQuote, gapOf, hunkExcerpt, passesFilter, placeThread, threadQuoteSummary, THREAD_FILTERS, +} from '../src/client/review-threads.ts' +import type { ForgeDiffFile } from '../src/types.ts' +import { reviewComment, reviewThread } from './thread-fixtures.client.ts' + +const HEAD = 'a'.repeat(40) +const OLD = 'b'.repeat(40) +const MIDDLE = 'c'.repeat(40) + +describe('placeThread', () => { + const current = reviewThread({ current: { commit: HEAD, startLine: 3, endLine: 5 }, original: { commit: OLD, startLine: 2, endLine: 4 } }) + + it('places a thread on the whole change request at its position on the head, else in the outdated section', () => { + const whole = { head: HEAD, end: HEAD, commits: null } + expect(placeThread(current, whole)).toEqual({ kind: 'line', side: 'R', startLine: 3, endLine: 5 }) + expect(placeThread(reviewThread({ side: 'L', current: { commit: HEAD, startLine: 1, endLine: 1 } }), whole)) + .toEqual({ kind: 'line', side: 'L', startLine: 1, endLine: 1 }) + expect(placeThread(reviewThread({ current: null }), whole)).toEqual({ kind: 'outdated' }) + expect(placeThread(reviewThread({ subject: 'file', current: null, original: null }), whole)).toEqual({ kind: 'file' }) + }) + + it('places a new-side thread in chosen commits only where its lines number the last chosen commit', () => { + expect(placeThread(current, { head: HEAD, end: HEAD, commits: { from: MIDDLE, to: HEAD } })) + .toEqual({ kind: 'line', side: 'R', startLine: 3, endLine: 5 }) + expect(placeThread(current, { head: HEAD, end: OLD, commits: { from: OLD, to: OLD } })) + .toEqual({ kind: 'line', side: 'R', startLine: 2, endLine: 4 }) + expect(placeThread(current, { head: HEAD, end: MIDDLE, commits: { from: MIDDLE, to: MIDDLE } })).toEqual({ kind: 'elsewhere' }) + expect(placeThread(reviewThread({ side: 'L', current: { commit: HEAD, startLine: 1, endLine: 1 } }), + { head: HEAD, end: HEAD, commits: { from: HEAD, to: HEAD } })).toEqual({ kind: 'elsewhere' }) + expect(placeThread(reviewThread({ current: null, original: null }), { head: HEAD, end: OLD, commits: { from: OLD, to: OLD } })) + .toEqual({ kind: 'outdated' }) + expect(placeThread(reviewThread({ subject: 'file', current: null, original: null }), { head: HEAD, end: OLD, commits: { from: OLD, to: OLD } })) + .toEqual({ kind: 'file' }) + }) +}) + +it('names a side and its lines', () => { + expect(anchorRange('R', { startLine: 12, endLine: 12 })).toBe('R12') + expect(anchorRange('L', { startLine: 3, endLine: 5 })).toBe('L3–5') +}) + +it('finds the collapsed unchanged lines that hold a line on either side', () => { + const file: ForgeDiffFile = { + path: 'a.ts', previousPath: null, status: 'modified', binary: false, oversized: false, untracked: false, additions: 2, deletions: 2, + digest: 'digest', layer: 'implementation', + hunks: [ + { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, + { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, + ], + } + expect(gapOf(file, 'R', 2)).toBe(0) + expect(gapOf(file, 'R', 6)).toBeUndefined() + expect(gapOf(file, 'R', 10)).toBe(1) + expect(gapOf(file, 'L', 21)).toBeUndefined() + expect(gapOf(file, 'R', 21)).toBe(1) + expect(gapOf(file, 'R', 40)).toBe(2) +}) + +describe('thread filters', () => { + const by = (login: string | null): ForgeReviewComment => + reviewComment({ author: login === null ? null : { login, avatarUrl: null, bot: false } }) + + it('awaits the viewer only on an unresolved thread the viewer wrote in and someone else wrote last', () => { + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by('lin')] }), 'octo')).toBe(true) + expect(awaitsViewer(reviewThread({ comments: [by('lin'), by('octo')] }), 'octo')).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [by('lin'), by(null)] }), 'octo')).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by(null)] }), 'octo')).toBe(true) + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by('lin')], resolved: true }), 'octo')).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by('lin')] }), null)).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [] }), 'octo')).toBe(false) + }) + + it('keeps the threads each filter names', () => { + const outdated = reviewThread({ current: null }) + const resolved = reviewThread({ resolved: true }) + const file = reviewThread({ subject: 'file', current: null, original: null }) + expect(THREAD_FILTERS).toEqual(['unresolved', 'outdated', 'resolved', 'mine']) + expect([outdated, resolved, file].map(thread => passesFilter(thread, null, null))).toEqual([true, true, true]) + expect([outdated, resolved, file].map(thread => passesFilter(thread, 'unresolved', null))).toEqual([true, false, true]) + expect([outdated, resolved, file].map(thread => passesFilter(thread, 'outdated', null))).toEqual([true, false, false]) + expect([outdated, resolved, file].map(thread => passesFilter(thread, 'resolved', null))).toEqual([false, true, false]) + expect(passesFilter(reviewThread({ comments: [by('octo'), by('lin')] }), 'mine', 'octo')).toBe(true) + }) +}) + +describe('hunkExcerpt', () => { + const hunk = '@@ -1,8 +1,9 @@\n a\n b\n-c\n+C\n+D\n d\n e' + + it('keeps the header and the thread\'s lines on its side with three before them', () => { + expect(hunkExcerpt(hunk, 'R', 1)).toEqual({ header: '@@ -1,8 +1,9 @@', lines: ['+C', '+D', ' d', ' e'] }) + expect(hunkExcerpt(hunk, 'L', 2)).toEqual({ header: '@@ -1,8 +1,9 @@', lines: [' a', ' b', '-c', '+C', '+D', ' d', ' e'] }) + }) + + it('keeps every line of a short hunk without a header', () => { + expect(hunkExcerpt(' a\n+b', 'R', 1)).toEqual({ header: null, lines: [' a', '+b'] }) + }) +}) + +describe('thread quotes', () => { + it('summarizes a quote by file name, range, and first comment', () => { + expect(threadQuoteSummary(reviewThread({ path: 'src/a.ts' }), 'R3')).toEqual({ label: 'a.ts R3', title: 'src/a.ts', detail: 'R3', body: 'First comment' }) + expect(threadQuoteSummary(reviewThread({ path: 'a.ts', comments: [] }), null)).toEqual({ label: 'a.ts', title: 'a.ts', detail: 'a.ts', body: '' }) + }) + + it('formats a line thread with its hunk, state, and every comment quoted line by line', () => { + const thread: ForgeReviewThread = reviewThread({ + current: { commit: HEAD, startLine: 3, endLine: 5 }, diffHunk: '@@ -1 +1 @@\n-```\n+````', + comments: [reviewComment({ body: '# Heading\nsecond line', url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), + reviewComment({ author: null, body: 'reply' })], + }) + expect(formatThreadQuote(thread, 'https://github.com/octo/demo/pull/7', 2)).toBe([ + '## Review Thread 2', 'File: src/a.ts', 'Side: R', 'Lines: 3-5', `Commit: ${HEAD}`, 'State: unresolved', + 'Source: https://github.com/octo/demo/pull/7#discussion_r1', 'Diff hunk:', '`````diff', '@@ -1 +1 @@\n-```\n+````', '`````', + 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> # Heading', '> second line', '', 'Unknown author (2026-11-05T08:00:00Z):', '> reply', + ].join('\n')) + }) + + it('formats an outdated single line at the lines it was written on, and a file thread without lines or hunk', () => { + const outdated = reviewThread({ current: null, resolved: true, original: { commit: OLD, startLine: 9, endLine: 9 }, diffHunk: null, + comments: [reviewComment({ url: null })] }) + expect(formatThreadQuote(outdated, 'https://github.com/octo/demo/pull/7', 1)).toBe([ + '## Review Thread 1', 'File: src/a.ts', 'Side: R', 'Lines: 9', `Commit: ${OLD}`, 'State: resolved, outdated', + 'Source: https://github.com/octo/demo/pull/7', 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> First comment', + ].join('\n')) + const file = reviewThread({ subject: 'file', current: null, original: null, diffHunk: null }) + expect(formatThreadQuote(file, 'https://github.com/octo/demo/pull/7', 1)).toContain('File: src/a.ts\nLines: whole file\nState: unresolved\n') + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts new file mode 100644 index 0000000000..fe08cb6a60 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts @@ -0,0 +1,43 @@ +/** Host review threads and comments shared by the browser-side thread tests. */ + +import type { ForgeCommentId, ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' + +let comments = 0 + +/** + * Build a comment by `reviewer` that has a web page. + * @param overrides - fields to replace. + * @returns the comment. + */ +export function reviewComment(overrides: Partial = {}): ForgeReviewComment { + comments += 1 + return { + id: `comment-${comments}` as ForgeCommentId, + author: { login: 'reviewer', avatarUrl: 'https://avatars.example/u/1', bot: false }, + body: 'First comment', + createdAt: '2026-11-05T08:00:00Z', + pending: false, + url: `https://github.com/octo/demo/pull/7#discussion_r${comments}`, + ...overrides, + } +} + +/** + * Build an unresolved new-side thread on line 3 of `src/a.ts`, written at the head `a…a` and placed there, with one comment. + * @param overrides - fields to replace. + * @returns the thread. + */ +export function reviewThread(overrides: Partial = {}): ForgeReviewThread { + return { + id: `thread-${comments + 1}` as ForgeThreadId, + path: 'src/a.ts', + side: 'R', + subject: 'line', + current: { commit: 'a'.repeat(40), startLine: 3, endLine: 3 }, + original: { commit: 'a'.repeat(40), startLine: 3, endLine: 3 }, + diffHunk: '@@ -1,3 +1,3 @@\n a\n-b\n+B', + resolved: false, + comments: [reviewComment()], + ...overrides, + } +} From fb1b204385873d0a93c259e415536e593ff45558 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:28:59 +0800 Subject: [PATCH 080/149] test(cua): record consent and pointer routing through a session --- .../experimental/cua/src/mouse-control.ts | 6 +- .../application-approval.client.spec.tsx | 7 +- snapshots/session/cua-native/session.v4.jsonl | 178 +++++++++--------- .../cua-native/system-prompt.expected.md | 3 +- .../cua-native/tool-schemas.expected.json | 19 ++ .../session/cua-ptc/system-prompt.expected.md | 3 +- .../system-prompt.expected.md | 3 +- 7 files changed, 129 insertions(+), 90 deletions(-) diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts index 306cd43a2f..947482766f 100644 --- a/packages/experimental/cua/src/mouse-control.ts +++ b/packages/experimental/cua/src/mouse-control.ts @@ -11,7 +11,11 @@ export class MouseControl { constructor(private readonly ctx: Context) {} - /** @param agent Calling Agent. @returns Whether this Session explicitly approved moving the system pointer. */ + /** + * Select the native route without prompting the user. + * @param agent Calling Agent. + * @returns Whether this Session explicitly approved moving the system pointer. + */ allowed(agent: Agent): boolean { return this.decisions.get(agent.id) === true } diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx index b6bc9d3d7c..13e79b8faa 100644 --- a/packages/experimental/cua/tests/application-approval.client.spec.tsx +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -6,7 +6,6 @@ import { afterEach, expect, it, vi } from 'vitest' import type { ApprovalCardInput } from '@deepseek-ai/dsh-client-ui-approval/client' import { PendingQuestion, createWaterfallRequest } from '@deepseek-ai/dsh-client-ui-user-questions/src/client/contract/slots.ts' import { ApplicationApproval, selectApplicationApproval, type ApplicationApprovalProps } from '../src/client/ApplicationApproval.tsx' -import { mouseControlDisplay } from '../src/locales.ts' import { en, zh } from '../src/client/locales.ts' type AskUserQuestionItem = PendingQuestion['questions'][number] @@ -87,7 +86,11 @@ it('uses the original question cancellation and refuses late approval', async () }) it('shows the mouse interruption and expiry with only session approval and refusal', () => { - const display = mouseControlDisplay + const display = { + question: { en: 'Move pointer?', zh: '允许移动鼠标指针吗?' }, + detail: { en: 'Until restart', zh: '重启后失效' }, + approve: { en: 'Allow for this session', zh: '本会话允许' }, + } const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en, detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }]) const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 10a71dc794..7f49b59d88 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -12,286 +12,296 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949731596,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949731597,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1793949731597,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949731597,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889225,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889225,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"requestMouseControl\",\"screenshot\",\"scroll\",\"selectText\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732188,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732188,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732188,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732188,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889794,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889794,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-getState"},"toolCallId":"cua-unauthorized-getState","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732206,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732206,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732206,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732206,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889811,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889811,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032889811,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889811,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-screenshot"},"toolCallId":"cua-unauthorized-screenshot","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732224,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732224,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732224,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732224,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889830,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889830,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032889830,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889830,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-click"},"toolCallId":"cua-unauthorized-click","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:11}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732241,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732241,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}}},{"type":"chunk","time":1793949732241,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732241,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889846,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889846,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}}},{"type":"chunk","time":1794032889846,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889846,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}} {"type":"tool/result","data":{"turn":1,"step":5,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-2"},"toolCallId":"cua-native-2","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"name\":\"Fixture\",\"bundleId\":\"test.fixture\",\"running\":true}"}],"isError":false,"id":"{{message:13}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732276,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732276,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}}},{"type":"chunk","time":1793949732276,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732276,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889881,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889881,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}}},{"type":"chunk","time":1794032889881,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889881,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":6,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-3"},"toolCallId":"cua-native-3","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-1\",\"tree\":\"AX scope=window offsets=UTF-16 bounds=global-points\\n- [0] AXButton \\\"Increment\\\" id=\\\"increment\\\" actions=[\\\"AXPress\\\"] bounds=[60,80,100,20]\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 0\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\n - [3] AXMenuItem \\\"Fixture command\\\"\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:15}}"}},"sourceEventSeqs":[39],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732298,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732298,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}}},{"type":"chunk","time":1793949732298,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732298,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889899,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889899,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}}},{"type":"chunk","time":1794032889899,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889899,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":7,"callId":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}} {"type":"tool/result","data":{"turn":1,"step":7,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-4"},"toolCallId":"cua-native-4","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:17}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"step/start","data":{"turn":1,"step":8}} -{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732316,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732316,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732316,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732316,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889915,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889915,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032889915,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889915,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":8,"callId":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":8,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-5"},"toolCallId":"cua-native-5","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-2\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:19}}"}},"sourceEventSeqs":[49],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":8}} {"type":"step/start","data":{"turn":1,"step":9}} -{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732333,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732333,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732333,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732333,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889932,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889932,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032889932,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889932,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":9,"callId":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":9,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-6"},"toolCallId":"cua-native-6","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:21}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":9}} {"type":"step/start","data":{"turn":1,"step":10}} -{"type":"assistant/message","data":{"turn":1,"step":10,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732405,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732405,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732405,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732405,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":10,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890010,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890010,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890010,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890010,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":10,"callId":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":10,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-7"},"toolCallId":"cua-native-7","content":[{"type":"text","text":"[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true},{\"windowId\":8,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}]"}],"isError":false,"id":"{{message:23}}"}},"sourceEventSeqs":[59],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":10}} {"type":"step/start","data":{"turn":1,"step":11}} -{"type":"assistant/message","data":{"turn":1,"step":11,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:24}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732424,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732424,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1793949732424,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732424,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":11,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:24}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890027,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890027,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1794032890027,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890027,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":11,"callId":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} {"type":"tool/result","data":{"turn":1,"step":11,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-8"},"toolCallId":"cua-native-8","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:25}}"}},"sourceEventSeqs":[64],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":11}} {"type":"step/start","data":{"turn":1,"step":12}} -{"type":"assistant/message","data":{"turn":1,"step":12,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:26}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732445,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732445,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1793949732445,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732445,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":12,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:26}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890044,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890044,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1794032890044,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890044,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":12,"callId":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} {"type":"tool/result","data":{"turn":1,"step":12,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-9"},"toolCallId":"cua-native-9","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":8,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:27}}"}},"sourceEventSeqs":[69],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":12}} {"type":"step/start","data":{"turn":1,"step":13}} -{"type":"assistant/message","data":{"turn":1,"step":13,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:28}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732481,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732481,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}}},{"type":"chunk","time":1793949732481,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732481,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":13,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:28}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890077,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890077,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}}},{"type":"chunk","time":1794032890077,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890077,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":13,"callId":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}} {"type":"tool/result","data":{"turn":1,"step":13,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-10"},"toolCallId":"cua-native-10","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:29}}"}},"sourceEventSeqs":[74],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":13}} {"type":"step/start","data":{"turn":1,"step":14}} -{"type":"assistant/message","data":{"turn":1,"step":14,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:30}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732502,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732502,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1793949732502,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732502,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":14,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:30}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890097,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890097,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1794032890097,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890097,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":14,"callId":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} {"type":"tool/result","data":{"turn":1,"step":14,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-11"},"toolCallId":"cua-native-11","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:31}}"}},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":14}} {"type":"step/start","data":{"turn":1,"step":15}} -{"type":"assistant/message","data":{"turn":1,"step":15,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:32}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732523,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732523,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732523,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732523,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":15,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:32}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890116,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890116,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890116,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890116,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":15,"callId":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":15,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-show"},"toolCallId":"cua-native-show","content":[{"type":"text","text":"{\"dispatched\":true,\"windowId\":7,\"foregroundChanged\":false}"}],"isError":false,"id":"{{message:33}}"}},"sourceEventSeqs":[84],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":15}} {"type":"step/start","data":{"turn":1,"step":16}} -{"type":"assistant/message","data":{"turn":1,"step":16,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:34}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732546,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732546,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732546,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732546,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":16,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:34}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890134,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890134,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890134,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890134,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":16,"callId":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":16,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-first"},"toolCallId":"cua-native-activate-first","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":true,\"foreground\":{\"requestId\":\"cua-native-activate-first\",\"targetPid\":9,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true}}"}],"isError":false,"id":"{{message:35}}"}},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":16}} {"type":"step/start","data":{"turn":1,"step":17}} -{"type":"assistant/message","data":{"turn":1,"step":17,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:36}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732573,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732573,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732573,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732573,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":17,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:36}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890151,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890151,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890151,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890151,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":17,"callId":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":17,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-repeat"},"toolCallId":"cua-native-activate-repeat","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":false,\"foreground\":{\"requestId\":\"cua-native-activate-repeat\",\"targetPid\":9,\"before\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":false}}"}],"isError":false,"id":"{{message:37}}"}},"sourceEventSeqs":[94],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":17}} {"type":"step/start","data":{"turn":1,"step":18}} -{"type":"assistant/message","data":{"turn":1,"step":18,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:38}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732598,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732598,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}}},{"type":"chunk","time":1793949732598,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732598,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":18,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:38}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890168,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890168,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}}},{"type":"chunk","time":1794032890168,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890168,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":18,"callId":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}} {"type":"tool/result","data":{"turn":1,"step":18,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-transition"},"toolCallId":"cua-native-transition","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:39}}"}},"sourceEventSeqs":[99],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":18}} {"type":"step/start","data":{"turn":1,"step":19}} -{"type":"assistant/message","data":{"turn":1,"step":19,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:40}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732622,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732622,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732622,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732622,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":19,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:40}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890186,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890186,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890186,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890186,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":19,"callId":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":19,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-read"},"toolCallId":"cua-native-stale-read","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Accessibility tree changed during observation; read fresh getAccessibilityTree"}],"isError":true,"id":"{{message:41}}"}},"sourceEventSeqs":[104],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":19}} {"type":"step/start","data":{"turn":1,"step":20}} -{"type":"assistant/message","data":{"turn":1,"step":20,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:42}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732643,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732643,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732643,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732643,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":20,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:42}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890203,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890203,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890203,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890203,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":20,"callId":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":20,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-index"},"toolCallId":"cua-native-stale-index","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Read fresh state before acting"}],"isError":true,"id":"{{message:43}}"}},"sourceEventSeqs":[109],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":20}} {"type":"step/start","data":{"turn":1,"step":21}} -{"type":"assistant/message","data":{"turn":1,"step":21,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:44}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732663,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732663,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1793949732663,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732663,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":21,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:44}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890219,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890219,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1794032890219,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890219,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":21,"callId":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}} {"type":"tool/result","data":{"turn":1,"step":21,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-other-window"},"toolCallId":"cua-native-other-window","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:45}}"}},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":21}} {"type":"step/start","data":{"turn":1,"step":22}} -{"type":"assistant/message","data":{"turn":1,"step":22,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:46}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732687,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732687,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732687,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732687,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":22,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:46}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890236,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890236,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890236,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890236,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":22,"callId":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":22,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-read"},"toolCallId":"cua-native-fresh-read","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-5\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:47}}"}},"sourceEventSeqs":[119],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":22}} {"type":"step/start","data":{"turn":1,"step":23}} -{"type":"assistant/message","data":{"turn":1,"step":23,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:48}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732710,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732710,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732710,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732710,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":23,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:48}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890253,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890253,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890253,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890253,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":23,"callId":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":23,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-click"},"toolCallId":"cua-native-fresh-click","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:49}}"}},"sourceEventSeqs":[124],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":23}} {"type":"step/start","data":{"turn":1,"step":24}} -{"type":"assistant/message","data":{"turn":1,"step":24,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:50}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732733,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732733,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732733,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732733,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":24,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:50}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890269,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890269,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890269,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890269,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":24,"callId":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":24,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-verify"},"toolCallId":"cua-native-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-6\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:51}}"}},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":24}} {"type":"step/start","data":{"turn":1,"step":25}} -{"type":"assistant/message","data":{"turn":1,"step":25,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:52}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732756,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732756,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1793949732756,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732756,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":25,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:52}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890285,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890285,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1794032890285,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890285,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":25,"callId":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} {"type":"tool/result","data":{"turn":1,"step":25,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-observe"},"toolCallId":"cua-native-geometry-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:53}}"}},"sourceEventSeqs":[134],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":25}} {"type":"step/start","data":{"turn":1,"step":26}} -{"type":"assistant/message","data":{"turn":1,"step":26,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:54}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732777,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732777,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1793949732777,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732777,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":26,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:54}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890301,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890301,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1794032890301,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890301,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":26,"callId":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} {"type":"tool/result","data":{"turn":1,"step":26,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-capture"},"toolCallId":"cua-native-geometry-capture","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:55}}"}},"sourceEventSeqs":[139],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":26}} {"type":"step/start","data":{"turn":1,"step":27}} -{"type":"assistant/message","data":{"turn":1,"step":27,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:56}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732798,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732798,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}}},{"type":"chunk","time":1793949732798,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732798,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":27,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:56}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890319,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890319,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}}},{"type":"chunk","time":1794032890319,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890319,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":27,"callId":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":27,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-click"},"toolCallId":"cua-native-geometry-click","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:57}}"}},"sourceEventSeqs":[144],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":27}} {"type":"step/start","data":{"turn":1,"step":28}} -{"type":"assistant/message","data":{"turn":1,"step":28,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:58}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732822,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732822,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}}},{"type":"chunk","time":1793949732822,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732822,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":28,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:58}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890336,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890336,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}}},{"type":"chunk","time":1794032890336,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890336,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":28,"callId":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":28,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-scroll"},"toolCallId":"cua-native-geometry-scroll","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:59}}"}},"sourceEventSeqs":[149],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":28}} {"type":"step/start","data":{"turn":1,"step":29}} -{"type":"assistant/message","data":{"turn":1,"step":29,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:60}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732845,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732845,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}}},{"type":"chunk","time":1793949732845,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732845,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":29,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:60}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890353,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890353,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}}},{"type":"chunk","time":1794032890353,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890353,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":29,"callId":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":29,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-drag"},"toolCallId":"cua-native-geometry-drag","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:61}}"}},"sourceEventSeqs":[154],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":29}} {"type":"step/start","data":{"turn":1,"step":30}} -{"type":"assistant/message","data":{"turn":1,"step":30,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:62}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732870,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732870,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732870,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732870,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":30,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:62}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890372,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890372,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890372,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890372,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":30,"callId":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":30,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-ax"},"toolCallId":"cua-native-geometry-ax","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:63}}"}},"sourceEventSeqs":[159],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":30}} {"type":"step/start","data":{"turn":1,"step":31}} -{"type":"assistant/message","data":{"turn":1,"step":31,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:64}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732900,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732900,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1793949732900,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732900,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":31,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:64}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890389,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890389,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1794032890389,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890389,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":31,"callId":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} {"type":"tool/result","data":{"turn":1,"step":31,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-verify"},"toolCallId":"cua-native-geometry-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-8\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:65}}"}},"sourceEventSeqs":[164],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":31}} {"type":"step/start","data":{"turn":1,"step":32}} -{"type":"assistant/message","data":{"turn":1,"step":32,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:66}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732924,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732924,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}}},{"type":"chunk","time":1793949732924,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732924,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":32,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:66}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890407,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890407,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}}},{"type":"chunk","time":1794032890407,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890407,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":32,"callId":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}} {"type":"tool/result","data":{"turn":1,"step":32,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-arm"},"toolCallId":"cua-capture-busy-arm","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:67}}"}},"sourceEventSeqs":[169],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":32}} {"type":"step/start","data":{"turn":1,"step":33}} -{"type":"assistant/message","data":{"turn":1,"step":33,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:68}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732947,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732947,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732947,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732947,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":33,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:68}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890424,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890424,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890424,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890424,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":33,"callId":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":33,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy"},"toolCallId":"cua-capture-busy","content":[{"type":"text","text":"Error: CAPTURE_BUSY: A previous system capture is still pending; other native operations remain available\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-capture-busy\",\"stage\":\"helper.failed\",\"elapsedMs\":1,\"errorCode\":\"CAPTURE_BUSY\"}"}],"isError":true,"id":"{{message:69}}"}},"sourceEventSeqs":[174],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":33}} {"type":"step/start","data":{"turn":1,"step":34}} -{"type":"assistant/message","data":{"turn":1,"step":34,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:70}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732969,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732969,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732969,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732969,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":34,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:70}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890441,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890441,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890441,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890441,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":34,"callId":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":34,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-ax"},"toolCallId":"cua-capture-busy-ax","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-9\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:71}}"}},"sourceEventSeqs":[179],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":34}} {"type":"step/start","data":{"turn":1,"step":35}} -{"type":"assistant/message","data":{"turn":1,"step":35,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:72}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732988,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732988,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}}},{"type":"chunk","time":1793949732988,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732988,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":35,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:72}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890457,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890457,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}}},{"type":"chunk","time":1794032890457,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890457,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":35,"callId":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}} {"type":"tool/result","data":{"turn":1,"step":35,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-callback"},"toolCallId":"cua-capture-callback","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:73}}"}},"sourceEventSeqs":[184],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":35}} {"type":"step/start","data":{"turn":1,"step":36}} -{"type":"assistant/message","data":{"turn":1,"step":36,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:74}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733007,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733007,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733007,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733007,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":36,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:74}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890475,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890475,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890475,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890475,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":36,"callId":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":36,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-recovered"},"toolCallId":"cua-capture-recovered","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:75}}"}},"sourceEventSeqs":[189],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":36}} {"type":"step/start","data":{"turn":1,"step":37}} -{"type":"assistant/message","data":{"turn":1,"step":37,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:76}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733041,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733041,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949733041,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733041,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":37,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:76}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890537,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890537,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890537,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890537,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":37,"callId":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":37,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-enter"},"toolCallId":"cua-hover-enter","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:77}}"}},"sourceEventSeqs":[194],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":37}} {"type":"step/start","data":{"turn":1,"step":38}} -{"type":"assistant/message","data":{"turn":1,"step":38,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:78}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733060,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733060,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733060,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733060,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":38,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:78}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890554,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890554,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890554,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890554,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":38,"callId":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":38,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-observe"},"toolCallId":"cua-hover-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- AXStaticText \\\"Hover information\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:79}}"}},"sourceEventSeqs":[199],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":38}} {"type":"step/start","data":{"turn":1,"step":39}} -{"type":"assistant/message","data":{"turn":1,"step":39,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:80}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733087,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733087,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733087,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733087,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":39,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:80}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890576,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890576,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890576,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890576,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":39,"callId":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":39,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-capture"},"toolCallId":"cua-hover-capture","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:81}}"}},"sourceEventSeqs":[204],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":39}} {"type":"step/start","data":{"turn":1,"step":40}} -{"type":"assistant/message","data":{"turn":1,"step":40,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:82}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733130,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733130,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733130,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733130,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":40,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:82}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890612,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890612,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890612,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890612,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":40,"callId":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":40,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-popup"},"toolCallId":"cua-hover-popup","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:83}}"}},"sourceEventSeqs":[209],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":40}} {"type":"step/start","data":{"turn":1,"step":41}} -{"type":"assistant/message","data":{"turn":1,"step":41,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:84}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733147,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733147,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733147,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733147,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":41,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:84}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890629,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890629,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890629,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890629,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":41,"callId":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":41,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-click"},"toolCallId":"cua-hover-click","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:85}}"}},"sourceEventSeqs":[214],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":41}} {"type":"step/start","data":{"turn":1,"step":42}} -{"type":"assistant/message","data":{"turn":1,"step":42,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:86}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733164,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733164,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733164,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733164,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":42,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:86}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890645,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890645,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890645,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890646,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":42,"callId":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":42,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-exit"},"toolCallId":"cua-hover-exit","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:87}}"}},"sourceEventSeqs":[219],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":42}} {"type":"step/start","data":{"turn":1,"step":43}} -{"type":"assistant/message","data":{"turn":1,"step":43,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:88}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733183,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733183,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733183,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733183,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":43,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:88}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890662,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890662,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890662,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890662,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":43,"callId":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":43,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-unavailable"},"toolCallId":"cua-hover-unavailable","content":[{"type":"text","text":"Error: HOVER_UNAVAILABLE: No AX alternate UI at this target. No input was sent and the pointer did not move."}],"isError":true,"id":"{{message:89}}"}},"sourceEventSeqs":[224],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":43}} {"type":"step/start","data":{"turn":1,"step":44}} -{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733201,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733201,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733201,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733201,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-verify"},"toolCallId":"cua-hover-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-11\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890679,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890679,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}} +{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-pointer-request"},"toolCallId":"cua-pointer-request","content":[{"type":"text","text":"true"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":44}} {"type":"step/start","data":{"turn":1,"step":45}} -{"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733220,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733220,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}}},{"type":"chunk","time":1793949733220,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733220,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":45,"callId":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":45,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-background"},"toolCallId":"cua-paste-background","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:93}}"}},"sourceEventSeqs":[234],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890696,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890696,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":45,"callId":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":45,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-system-hover"},"toolCallId":"cua-system-hover","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"system-pointer\",\"systemPointerMoved\":true}"}],"isError":false,"id":"{{message:93}}"}},"sourceEventSeqs":[234],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":45}} {"type":"step/start","data":{"turn":1,"step":46}} -{"type":"assistant/message","data":{"turn":1,"step":46,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:94}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733238,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733238,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1793949733238,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733238,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":46,"callId":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":46,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-unread"},"toolCallId":"cua-paste-unread","content":[{"type":"text","text":"Error: PASTE_NOT_CONSUMED: Paste command was dispatched, but clipboard data was not requested within the read deadline. Bring the target app to the foreground and focus its text input. Inspect the contents before deciding whether to retry; a delayed paste may still run."}],"isError":true,"id":"{{message:95}}"}},"sourceEventSeqs":[239],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":46,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:94}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890712,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890712,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890712,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890712,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":46,"callId":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":46,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-verify"},"toolCallId":"cua-hover-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-11\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:95}}"}},"sourceEventSeqs":[239],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":46}} {"type":"step/start","data":{"turn":1,"step":47}} -{"type":"assistant/message","data":{"turn":1,"step":47,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:96}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733260,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733260,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733260,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733260,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":47,"callId":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":47,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-inspect"},"toolCallId":"cua-paste-inspect","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-12\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:97}}"}},"sourceEventSeqs":[244],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":47,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:96}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890729,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890729,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}}},{"type":"chunk","time":1794032890729,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890730,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":47,"callId":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":47,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-background"},"toolCallId":"cua-paste-background","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:97}}"}},"sourceEventSeqs":[244],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":47}} {"type":"step/start","data":{"turn":1,"step":48}} -{"type":"assistant/message","data":{"turn":1,"step":48,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:98}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733280,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733280,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949733280,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733280,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":48,"callId":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":48,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-activate"},"toolCallId":"cua-paste-activate","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":true,\"foreground\":{\"requestId\":\"cua-paste-activate\",\"targetPid\":9,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true}}"}],"isError":false,"id":"{{message:99}}"}},"sourceEventSeqs":[249],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":48,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:98}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890746,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890746,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1794032890746,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890747,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":48,"callId":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":48,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-unread"},"toolCallId":"cua-paste-unread","content":[{"type":"text","text":"Error: PASTE_NOT_CONSUMED: Paste command was dispatched, but clipboard data was not requested within the read deadline. Bring the target app to the foreground and focus its text input. Inspect the contents before deciding whether to retry; a delayed paste may still run."}],"isError":true,"id":"{{message:99}}"}},"sourceEventSeqs":[249],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":48}} {"type":"step/start","data":{"turn":1,"step":49}} -{"type":"assistant/message","data":{"turn":1,"step":49,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:100}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733300,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733300,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733300,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733300,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":49,"callId":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":49,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-focus"},"toolCallId":"cua-paste-focus","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-13\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:101}}"}},"sourceEventSeqs":[254],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":49,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:100}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890765,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890765,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890765,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890765,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":49,"callId":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":49,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-inspect"},"toolCallId":"cua-paste-inspect","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-12\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:101}}"}},"sourceEventSeqs":[254],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":49}} {"type":"step/start","data":{"turn":1,"step":50}} -{"type":"assistant/message","data":{"turn":1,"step":50,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:102}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733320,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733320,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1793949733320,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733320,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":50,"callId":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":50,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-consumed"},"toolCallId":"cua-paste-consumed","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:103}}"}},"sourceEventSeqs":[259],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":50,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:102}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890781,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890781,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890781,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890781,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":50,"callId":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":50,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-activate"},"toolCallId":"cua-paste-activate","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":true,\"foreground\":{\"requestId\":\"cua-paste-activate\",\"targetPid\":9,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true}}"}],"isError":false,"id":"{{message:103}}"}},"sourceEventSeqs":[259],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":50}} {"type":"step/start","data":{"turn":1,"step":51}} -{"type":"assistant/message","data":{"turn":1,"step":51,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:104}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733340,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733340,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733340,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733340,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":51,"callId":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":51,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-verify"},"toolCallId":"cua-paste-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-14\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"fixture paste\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:105}}"}},"sourceEventSeqs":[264],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":51,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:104}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890797,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890797,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890797,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890797,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":51,"callId":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":51,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-focus"},"toolCallId":"cua-paste-focus","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-13\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:105}}"}},"sourceEventSeqs":[264],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":51}} {"type":"step/start","data":{"turn":1,"step":52}} -{"type":"assistant/message","data":{"turn":1,"step":52,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:106}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733361,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733361,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}}},{"type":"chunk","time":1793949733361,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733361,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":52,"callId":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":52,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-arm"},"toolCallId":"cua-debug-arm","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:107}}"}},"sourceEventSeqs":[269],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":52,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:106}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890813,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890813,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1794032890813,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890813,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":52,"callId":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":52,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-consumed"},"toolCallId":"cua-paste-consumed","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:107}}"}},"sourceEventSeqs":[269],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":52}} {"type":"step/start","data":{"turn":1,"step":53}} -{"type":"assistant/message","data":{"turn":1,"step":53,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:108}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733379,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733379,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733379,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733379,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":53,"callId":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":53,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-screenshot"},"toolCallId":"cua-debug-screenshot","content":[{"type":"text","text":"Error: NATIVE_PROTOCOL: Helper returned an invalid response; acquire fresh application handles\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-debug-screenshot\",\"stage\":\"capture-image.begin\",\"elapsedMs\":1}"}],"isError":true,"id":"{{message:109}}"}},"sourceEventSeqs":[274],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":53,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:108}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890830,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890830,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890830,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890830,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":53,"callId":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":53,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-verify"},"toolCallId":"cua-paste-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-14\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"fixture paste\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:109}}"}},"sourceEventSeqs":[274],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":53}} {"type":"step/start","data":{"turn":1,"step":54}} -{"type":"assistant/message","data":{"turn":1,"step":54,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:110}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733403,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733403,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}}},{"type":"chunk","time":1793949733403,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733403,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":54,"callId":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":54,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-denied"},"toolCallId":"cua-consent-denied","content":[{"type":"text","text":"Error: APP_ACCESS_DENIED: The user denied access to Denied Fixture. Do not retry or use another tool to bypass this decision."}],"isError":true,"id":"{{message:111}}"}},"sourceEventSeqs":[279],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":54,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:110}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890846,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890846,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}}},{"type":"chunk","time":1794032890846,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890846,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":54,"callId":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":54,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-arm"},"toolCallId":"cua-debug-arm","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:111}}"}},"sourceEventSeqs":[279],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":54}} {"type":"step/start","data":{"turn":1,"step":55}} -{"type":"assistant/message","data":{"turn":1,"step":55,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:112}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733577,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733577,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}}},{"type":"chunk","time":1793949733577,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733577,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":55,"callId":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":55,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revocable"},"toolCallId":"cua-consent-revocable","content":[{"type":"text","text":"{\"appHandle\":\"revoked-app\",\"name\":\"Revocable Fixture\",\"running\":true}"}],"isError":false,"id":"{{message:113}}"}},"sourceEventSeqs":[284],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":55,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:112}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890862,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890862,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890862,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890862,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":55,"callId":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":55,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-screenshot"},"toolCallId":"cua-debug-screenshot","content":[{"type":"text","text":"Error: NATIVE_PROTOCOL: Helper returned an invalid response; acquire fresh application handles\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-debug-screenshot\",\"stage\":\"capture-image.begin\",\"elapsedMs\":1}"}],"isError":true,"id":"{{message:113}}"}},"sourceEventSeqs":[284],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":55}} {"type":"step/start","data":{"turn":1,"step":56}} -{"type":"assistant/message","data":{"turn":1,"step":56,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:114}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733745,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733745,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}}},{"type":"chunk","time":1793949733745,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733745,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":56,"callId":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":56,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revoked"},"toolCallId":"cua-consent-revoked","content":[{"type":"text","text":"Error: APP_ACCESS_REVOKED: Application access was revoked; stop using this application"}],"isError":true,"id":"{{message:115}}"}},"sourceEventSeqs":[289],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":56,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:114}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890881,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890881,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}}},{"type":"chunk","time":1794032890881,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890881,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":56,"callId":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":56,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-denied"},"toolCallId":"cua-consent-denied","content":[{"type":"text","text":"Error: APP_ACCESS_DENIED: The user denied access to Denied Fixture. Do not retry or use another tool to bypass this decision."}],"isError":true,"id":"{{message:115}}"}},"sourceEventSeqs":[289],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":56}} {"type":"step/start","data":{"turn":1,"step":57}} -{"type":"assistant/message","data":{"turn":1,"step":57,"message":{"role":"assistant","content":[{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:116}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733763,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1793949733763,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}}},{"type":"chunk","time":1793949733763,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733763,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":57,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:116}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032891018,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032891018,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}}},{"type":"chunk","time":1794032891018,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032891018,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":57,"callId":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":57,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revocable"},"toolCallId":"cua-consent-revocable","content":[{"type":"text","text":"{\"appHandle\":\"revoked-app\",\"name\":\"Revocable Fixture\",\"running\":true}"}],"isError":false,"id":"{{message:117}}"}},"sourceEventSeqs":[294],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":57}} +{"type":"step/start","data":{"turn":1,"step":58}} +{"type":"assistant/message","data":{"turn":1,"step":58,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:118}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032891165,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032891165,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}}},{"type":"chunk","time":1794032891165,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032891165,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":58,"callId":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":58,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revoked"},"toolCallId":"cua-consent-revoked","content":[{"type":"text","text":"Error: APP_ACCESS_REVOKED: Application access was revoked; stop using this application"}],"isError":true,"id":"{{message:119}}"}},"sourceEventSeqs":[299],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":58}} +{"type":"step/start","data":{"turn":1,"step":59}} +{"type":"assistant/message","data":{"turn":1,"step":59,"message":{"role":"assistant","content":[{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:120}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032891180,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1794032891180,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}}},{"type":"chunk","time":1794032891180,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032891180,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":59}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 0b9c1b29f6..e7132c8c0f 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -43,6 +43,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -67,7 +68,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 33f3636750..ed8a14779d 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -1413,6 +1413,25 @@ "params" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "requestMouseControl" + }, + "params": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": [ + "method", + "params" + ], + "additionalProperties": false } ], "$defs": { diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 587436227a..45154f9179 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -45,6 +45,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +70,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index dd88fa8d9e..b1769c75dc 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -45,6 +45,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +70,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From f11daf3ad3d50769b8d07416473a5537dcb9b966 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 14:24:37 +0800 Subject: [PATCH 081/149] docs(forge): document review thread reads, the thread overlay, and the unsupported failure The forge subsystem page documents ctx.forge.readReview, its thread types, and the panel's changeRequestReview cache; the package READMEs describe the GitHub thread mapping, the overlay and thread list, and the new panel Config fields. An upgrade guide covers the unsupported ForgeFailure code. The Web e2e overlays scripted threads, opens them from the thread list, and sends a quoted thread through a replayed turn. --- .../review-thread-list.expected.md | 17 +++ .../review-thread-message.expected.md | 19 +++ .../forge-panel/review-threads.expected.md | 82 +++++++++++ apps/web/tests/forge-panel.e2e.ts | 90 +++++++++++- docs/config-catalog.md | 16 +- docs/config-catalog.zh.md | 16 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/forge.i18n.yaml | 7 +- docs/subsystems/forge.md | 138 +++++++++++++++++- docs/subsystems/forge.zh.md | 138 +++++++++++++++++- .../forge-review-reads/guide.i18n.yaml | 16 ++ .../v0.2.2-rc.1/forge-review-reads/guide.md | 19 +++ .../forge-review-reads/guide.zh.md | 19 +++ .../client-ui-forge/README.i18n.yaml | 16 +- .../experimental/client-ui-forge/README.md | 13 +- .../experimental/client-ui-forge/README.zh.md | 13 +- .../client-ui-forge/src/client/ChangesTab.tsx | 2 +- .../src/client/ReviewThreads.tsx | 6 +- .../src/client/review-threads.ts | 4 +- .../tests/changes-tab.client.spec.tsx | 7 + .../tests/review-thread-cards.client.spec.tsx | 2 +- .../tests/review-threads.client.spec.ts | 4 +- .../forge-github/README.i18n.yaml | 12 +- packages/experimental/forge-github/README.md | 7 +- .../experimental/forge-github/README.zh.md | 7 +- .../experimental/forge-github/src/index.ts | 7 +- .../forge-github/tests/threads.spec.ts | 15 +- packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 2 + packages/experimental/forge/README.zh.md | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 46 +++++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 35 +++++ 34 files changed, 727 insertions(+), 59 deletions(-) create mode 100644 apps/web/tests/expected/forge-panel/review-thread-list.expected.md create mode 100644 apps/web/tests/expected/forge-panel/review-thread-message.expected.md create mode 100644 apps/web/tests/expected/forge-panel/review-threads.expected.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md diff --git a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md new file mode 100644 index 0000000000..0a2d0acbd7 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md @@ -0,0 +1,17 @@ +- complementary "讨论列表,共 5 个": + - text: 讨论 5 + - button "关闭讨论列表" + - group "筛选讨论": + - button "未解决 4" + - button "已过期 1" + - button "已解决 1" + - button "需要我回复 0" + - heading "panel.md" [level=3] + - button "文件级讨论 Add a screenshot." + - heading "panel.ts" [level=3] + - button "R2 · 1 条回复 Why two?" + - button "L15 The old fifteen was a number." + - button "R15 Settled. 已解决" + - button "原在 R9 · 在 fffffff 上 This line moved. 已过期" + - text: github.com 上共 5 个讨论 · + - button "在 GitHub 中查看" diff --git a/apps/web/tests/expected/forge-panel/review-thread-message.expected.md b/apps/web/tests/expected/forge-panel/review-thread-message.expected.md new file mode 100644 index 0000000000..0ea820a2af --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-message.expected.md @@ -0,0 +1,19 @@ +> ## Review Thread 1 +> File: src/panel.ts +> Side: R +> Lines: 2 +> Commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +> State: unresolved +> Source: https://github.com/octo/demo/pull/7#discussion_on-line_0 +> Diff hunk: +> ```diff +> @@ -1,5 +1,5 @@ +> export const n1 = 1 +> -export const n2 = 2 +> +export const n2 = 'two' +> ``` +> Comments: +> @lin (2026-10-09T00:00:00Z): +> > Why **two**? +> @octo (2026-10-09T00:00:00Z): +> > It reads better. diff --git a/apps/web/tests/expected/forge-panel/review-threads.expected.md b/apps/web/tests/expected/forge-panel/review-threads.expected.md new file mode 100644 index 0000000000..9b229274bd --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-threads.expected.md @@ -0,0 +1,82 @@ +- button "收起 panel.ts" [expanded] +- text: src/panel.ts +- button "panel.ts 有 3 条未解决的讨论,打开讨论列表": 3 条未解决 +- text: +2 −2 +- checkbox "将 panel.ts 标记为已审阅" +- button "panel.ts 的更多操作" +- button "选择第 L1 行": "1" +- text: export const n1 = 1 +- button "选择第 R1 行": "1" +- text: export const n1 = 1 +- button "选择第 L2 行": "2" +- text: export const n2 = 2 +- button "选择第 R2 行": "2" +- text: export const n2 = 'two' +- region "R2": + - text: R2 · 在 aaaaaaa 上 lin 2 小时前 + - paragraph: + - text: Why + - strong: two + - text: "?" + - text: octo 2 小时前 作者 + - paragraph: It reads better. + - button "添加到输入框" + - button "在 GitHub 中查看" +- button "选择第 L3 行": "3" +- text: export const n3 = 3 +- button "选择第 R3 行": "3" +- text: export const n3 = 3 +- button "选择第 L4 行": "4" +- text: export const n4 = 4 +- button "选择第 R4 行": "4" +- text: export const n4 = 4 +- button "选择第 L5 行": "5" +- text: export const n5 = 5 +- button "选择第 R5 行": "5" +- text: export const n5 = 5 +- button "6 行未变更" +- button "选择第 L12 行": "12" +- text: export const n12 = 12 +- button "选择第 R12 行": "12" +- text: export const n12 = 12 +- button "选择第 L13 行": "13" +- text: export const n13 = 13 +- button "选择第 R13 行": "13" +- text: export const n13 = 13 +- button "选择第 L14 行": "14" +- text: export const n14 = 14 +- button "选择第 R14 行": "14" +- text: export const n14 = 14 +- button "选择第 L15 行": "15" +- text: export const n15 = 15 +- button "选择第 R15 行": "15" +- text: export const n15 = 'fifteen' +- region "L15": + - text: L15 · 在 aaaaaaa 上 lin 2 小时前 + - paragraph: The old fifteen was a number. + - button "添加到输入框" + - button "在 GitHub 中查看" +- text: 已解决的讨论 · lin +- button "展开" +- button "选择第 L16 行": "16" +- text: export const n16 = 16 +- button "选择第 R16 行": "16" +- text: export const n16 = 16 +- button "选择第 L17 行": "17" +- text: export const n17 = 17 +- button "选择第 R17 行": "17" +- text: export const n17 = 17 +- button "选择第 L18 行": "18" +- text: export const n18 = 18 +- button "选择第 R18 行": "18" +- text: export const n18 = 18 +- button "其余未变更的行" +- text: 1 条讨论已过期:所在代码已被后续提交修改 +- button "收起" [expanded] +- region "原在 R9": + - text: 原在 R9 · 在 fffffff 上 已过期 + - button "查看当时的文件" + - text: "@@ -1,5 +1,5 @@ export const n1 = 1 -export const n2 = 2 +export const n2 = 'two' lin 2 小时前" + - paragraph: This line moved. + - button "添加到输入框" + - button "在 GitHub 中查看" diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index f66c449b52..1b0c0bc8fb 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -49,8 +49,9 @@ const run = promisify(execFile) * `pr-N.diff`, or, when `pr-N.refused` exists, fails with that JSON error body * and the matching diagnostic as GitHub's HTTP 406 refusal. The raw contents API * serves `contents.json`, keyed by `path@commit`, and the commit listing of pull - * request 7 serves `commits.json`, oldest first. Every invocation is appended to - * `calls.jsonl`. + * request 7 serves `commits.json`, oldest first. The review thread read serves + * `threads.json` as its one page of threads, with the viewer `octo`, or no threads + * without it. Every invocation is appended to `calls.jsonl`. */ const GH = `#!/usr/bin/env node import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' @@ -171,6 +172,13 @@ if (args[0] === 'api' && args[1] === 'graphql') { } } } } })) process.exit(0) } + if (query.includes('viewer { login }')) { + const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) + const threads = existsSync(join(here, 'threads.json')) ? JSON.parse(readFileSync(join(here, 'threads.json'), 'utf8')) : [] + process.stdout.write(JSON.stringify({ data: { viewer: { login: 'octo' }, repository: { pullRequest: { id: 'pull-' + number, + headRefOid: pulls[number].headRefOid, reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: threads } } } } })) + process.exit(0) + } if (query.includes('pullRequest(number: $number)')) { const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { headRefOid: pulls[number].headRefOid, baseRefOid: BASE } } } })) @@ -1844,3 +1852,81 @@ it.skipIf(MODE === 'record')('keeps local viewed marks across reloads, clears a await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }) + +/** One review thread node of pull request 7 as the GraphQL thread read returns it, its comments by `lin` then `octo`. */ +function threadNode( + id: string, fields: Record, bodies: readonly string[], commit: string = HEAD7, +): Record { + return { + id, isOutdated: false, isResolved: false, path: 'src/panel.ts', diffSide: 'RIGHT', startDiffSide: null, line: null, startLine: null, + originalLine: null, originalStartLine: null, subjectType: 'LINE', ...fields, + root: { nodes: [{ diffHunk: '@@ -1,5 +1,5 @@\n export const n1 = 1\n-export const n2 = 2\n+export const n2 = \'two\'', + commit: { oid: commit }, originalCommit: { oid: commit } }] }, + comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: bodies.map((body, index) => ({ + id: `${id}-${index}`, state: 'SUBMITTED', createdAt: '2026-10-09T00:00:00Z', url: `${PULL}#discussion_${id}_${index}`, body, + author: { __typename: 'User', login: index === 0 ? 'lin' : 'octo', avatarUrl: null }, + })) }, + } +} + +/** Threads of pull request 7: on new line 2, on old line 15, resolved on new line 15, outdated, and on the whole of `docs/panel.md`. */ +const THREADS = [ + threadNode('on-line', { line: 2, originalLine: 2 }, ['Why **two**?', 'It reads better.']), + threadNode('old-side', { diffSide: 'LEFT', line: 15, originalLine: 15 }, ['The old fifteen was a number.']), + threadNode('resolved', { isResolved: true, line: 15, originalLine: 15 }, ['Settled.']), + threadNode('outdated', { isOutdated: true, originalLine: 9 }, ['This line moved.'], 'f'.repeat(40)), + threadNode('whole-file', { path: 'docs/panel.md', subjectType: 'FILE' }, ['Add a screenshot.']), +] + +it.skipIf(MODE === 'record')('overlays the review threads of a change request and opens them from the thread list', async () => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-threads', 'follow-up') + await writeFile(join(root, 'threads.json'), JSON.stringify(THREADS)) + // Comments were written two hours before the browser's clock, so their relative times stay fixed. + await page.clock.setFixedTime(Date.parse('2026-10-09T02:00:00Z')) + const { view, sessionId } = await openReview(page, scaffold) + const panel = view.locator('[data-forge-diff-file="src/panel.ts"]') + const onLine = panel.locator('[data-forge-thread="on-line"]') + await onLine.getByText('two', { exact: true }).waitFor({ timeout: 20_000 }) + // The thread follows the row of its last line, under the new side. + expect(await onLine.evaluate(card => card.previousElementSibling?.querySelector('[data-new-line]')?.textContent)).toBe('export const n2 = \'two\'') + expect(await panel.locator('[data-forge-thread="old-side"]').evaluate(card => card.previousElementSibling?.querySelector('[data-old-line]')?.textContent)) + .toBe('export const n15 = 15') + await panel.locator('[data-forge-thread="resolved"]').getByText('已解决的讨论 · lin', { exact: true }).waitFor() + await panel.locator('[data-forge-outdated-threads]').getByText('1 条讨论已过期:所在代码已被后续提交修改', { exact: true }).waitFor() + await view.locator('[data-forge-diff-file="docs/panel.md"] [data-forge-thread="whole-file"]').getByText('文件级讨论', { exact: true }).waitFor() + await panel.getByRole('button', { name: 'panel.ts 有 3 条未解决的讨论,打开讨论列表', exact: true }).waitFor() + await view.getByRole('navigation').getByTitle('3 条未解决的讨论', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-threads.expected.md'), + await captureStableAria(page, '[data-forge-diff-file="src/panel.ts"]', scaffold.workspaceCwd), MODE) + + await view.getByRole('button', { name: '讨论列表,共 5 个', exact: true }).click() + const list = view.getByRole('complementary', { name: '讨论列表,共 5 个' }) + await expect.poll(() => list.getByRole('button', { pressed: true }).allTextContents()).toEqual(['未解决 4']) + await expect.poll(() => list.locator('[data-forge-thread-item]').evaluateAll(items => items.map(item => item.getAttribute('data-forge-thread-item')))) + .toEqual(['whole-file', 'on-line', 'old-side', 'outdated']) + await list.getByRole('button', { name: '已解决 1', exact: true }).click() + await list.locator('[data-forge-thread-item="resolved"]').click() + // Opening a resolved thread unfolds it inside its file. + await panel.locator('[data-forge-thread="resolved"]').getByText('Settled.', { exact: true }).waitFor() + await list.getByRole('button', { name: '已解决 1', exact: true }).click() + await list.getByRole('button', { name: '需要我回复 0', exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-list.expected.md'), + await captureStableAria(page, '[data-forge-thread-list]', scaffold.workspaceCwd), MODE) + + await onLine.getByRole('button', { name: '添加到输入框', exact: true }).click() + await page.locator('[data-composer-input]').first().getByText('panel.ts R2', { exact: true }).first().waitFor() + const sent = scaffold.whenTurnSettled() + await page.locator('[data-composer-input]').first().press('Enter') + await sent + await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor() + // The quoted thread reaches the model only inside the ordinary logged user message. + const quotes = (await readPersistedEvents(scaffold, sessionId)).flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' && block.text.includes('## Review Thread') ? [block.text] : []) : []) + expect(quotes).toHaveLength(1) + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-message.expected.md'), quotes.join('').trimEnd(), MODE) + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as string[]) + const reads = calls.filter(args => args.some(arg => arg.startsWith('query=') && arg.includes('viewer { login }'))) + expect(reads.length).toBeGreaterThan(0) + expect(reads[0]).toEqual(expect.arrayContaining(['owner=octo', 'name=demo', 'number=7', 'first=100'])) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d85683e33e..e68110b349 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1121,6 +1121,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } ``` @@ -1188,7 +1195,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:359`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1222,9 +1229,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ceab76dfec..1956f19ac1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1123,6 +1123,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } ``` @@ -1190,7 +1197,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:359`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1224,9 +1231,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b4cffaf1aa..d157809e13 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -47,7 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:29`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:28`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b1b58ef08a..194d956a4f 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -49,7 +49,7 @@ | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:29`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:28`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 97ebf7bece..1407e29d9d 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -14,9 +14,12 @@ /forges/change-request-actions: en: 87d2c81d9514267f zh: 1daf31c5b0127a1c +/forges/review-threads: + en: 351af1e634f70a40 + zh: 2f0bba65d3b01277 /forges/session-bindings: en: 13dfd36f2166bba0 zh: a369434f52f04d57 /forges/panel: - en: e52a5c57abc4d30e - zh: 509a40f421cc3d33 + en: 11b157e9debe81cd + zh: b197644deabe727d diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index b5156c101f..0a632c5de1 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -47,6 +47,12 @@ interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ @@ -238,9 +244,9 @@ interface ForgeFailure { * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails * the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -328,6 +334,104 @@ type ForgeReviewResult = | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } ``` +## Review threads + +`ctx.forge.readReview(ref, headSha, signal)` reads the review threads of a change request at one head commit. The provider returns `ForgeReviewData` only when the read names `headSha` as the head; otherwise it returns `stale`, which polls a watched change request again at once, as a stale diff read does. For a provider without `readReview` the service returns `unsupported`, so consumers show no threads instead of a failure. A line thread has two positions: `original` on the commit it was written on, and `current` on the head of the read, which is null once the host no longer places the thread on that commit. A consumer that shows the head's diff places a thread at `current` and shows the others with their `diffHunk`; reading the file at `original.commit` through `fileText` shows the whole file the thread was written on. The [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns how GitHub's fields map onto these positions. + +```ts type-equiv +/** Opaque host identity of one review thread. */ +type ForgeThreadId = Branded<'ForgeThreadId'> +``` + +```ts type-equiv +/** Opaque host identity of one review comment. */ +type ForgeCommentId = Branded<'ForgeCommentId'> +``` + +```ts type-equiv +/** Consecutive lines of one file at one commit. */ +interface ForgeAnchor { + /** Full object name of the commit whose file the lines number. */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} +``` + +```ts type-equiv +/** Account that wrote a review comment. */ +interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} +``` + +```ts type-equiv +/** One comment of a review thread. */ +interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null +} +``` + +```ts type-equiv +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} +``` + +```ts type-equiv +/** Review threads of a change request read at one head commit. */ +interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean +} +``` + ## Session bindings `ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results trigger head-branch discovery through `ctx.forge`; the panel also reports the branch its checkout tracks for the same discovery. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The `forgeChangeRequests` projection's client view, which the Session summary carries, lists the bindings without their status. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. @@ -617,6 +721,8 @@ type ForgeChangeRequestStatesFrame = The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`, which returns a patch only for that head commit, or the `ForgeCommitRange` it names through the provider's `compare`; both return files that the Host parsed into `ForgeDiffFile` hunks, each with its review layer and change digest. `localCommits` and `changeRequestCommits` list the commits a range can name, the latter as null when the provider does not read commits. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point. When the head moved past the latest status, the provider returns `stale` and `ctx.forge.diff` polls the change request at once, once per head commit; `changeRequestDiff` rejects with `forgePanel/stale-head` and caches nothing, and the changes view keeps loading until a later status arrives, then reads again at that status's head. Review comments therefore always address the commit whose lines the view shows. +`changeRequestReview(session, url, headSha)` reads the threads of a bound change request at the head of the diff the view shows and refuses another head with `forgePanel/stale-head`, `forgePanel/review-unsupported` for a provider without review reads, and `forgePanel/review-failed` otherwise. It keeps one read per change request in memory, keyed by head commit and the status's update time and bounded by `maxCachedReviewBytes`, and reads again once the kept read is `reviewRefreshMs` old: on GitHub a new comment or reply changes a pull request's update time, but resolving a thread does not. The changes view asks again at every status it receives while visible, and overlays the threads on the files the [panel README](../../packages/experimental/client-ui-forge/README.md#use-this-package) describes. + ```ts type-equiv /** * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order @@ -916,6 +1022,17 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** * List the commits of a change request at its current head. * @param ref - change request served by a registered provider. @@ -1186,6 +1303,23 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise +/** + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved + * since, which do not change a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ +@Remote async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise + /** * Publish a line comment or completed review for a visible Session binding. * @param session - Session retaining the change request. diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 2ca42cde71..3c4459101f 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -47,6 +47,12 @@ interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ @@ -238,9 +244,9 @@ interface ForgeFailure { * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails * the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -328,6 +334,104 @@ type ForgeReviewResult = | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } ``` +## 评审讨论 + +`ctx.forge.readReview(ref, headSha, signal)` 读取变更请求在某个 head 提交处的评审讨论。只有当读取时的 head 就是 `headSha` 时,提供方才返回 `ForgeReviewData`;否则返回 `stale`,并像 diff 读取遇到 `stale` 时一样,立即重新轮询被关注的变更请求。提供方没有实现 `readReview` 时,服务返回 `unsupported`,使用方据此不显示讨论,也不报错。行级讨论有两套位置:`original` 是写下讨论时所在提交上的行,`current` 是本次读取的 head 上的行;托管平台不再把讨论放在该提交上时,`current` 为 null。显示 head diff 的使用方把讨论放在 `current` 位置,其余讨论用 `diffHunk` 展示;用 `fileText` 读取 `original.commit` 上的文件,可以看到写下讨论时的完整文件。GitHub 各字段如何映射到这两套位置,由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。 + +```ts type-equiv +/** Opaque host identity of one review thread. */ +type ForgeThreadId = Branded<'ForgeThreadId'> +``` + +```ts type-equiv +/** Opaque host identity of one review comment. */ +type ForgeCommentId = Branded<'ForgeCommentId'> +``` + +```ts type-equiv +/** Consecutive lines of one file at one commit. */ +interface ForgeAnchor { + /** Full object name of the commit whose file the lines number. */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} +``` + +```ts type-equiv +/** Account that wrote a review comment. */ +interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} +``` + +```ts type-equiv +/** One comment of a review thread. */ +interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null +} +``` + +```ts type-equiv +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} +``` + +```ts type-equiv +/** Review threads of a change request read at one head commit. */ +interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean +} +``` + ## Session 绑定 `ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现;面板也会报告其检出所跟踪的分支,触发同样的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影的客户端视图随 Session 摘要下发,列出绑定但不含其状态。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 @@ -617,6 +721,8 @@ type ForgeChangeRequestStatesFrame = 变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更(提供方只返回描述该 head 提交的补丁),或经提供方的 `compare` 读取它指定的 `ForgeCommitRange`;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk,并带有分层与改动摘要。`localCommits` 与 `changeRequestCommits` 列出范围可以指定的提交,提供方不读取提交时后者返回 null。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。head 已越过最新状态时,提供方返回 `stale`,`ctx.forge.diff` 立即重新轮询该变更请求(每个 head 提交一次);`changeRequestDiff` 以 `forgePanel/stale-head` 拒绝且不缓存任何内容,变更视图保持加载,直到更新的状态到达后按该状态的 head 重新读取。因此评审评论始终指向视图所显示行的那个提交。 +`changeRequestReview(session, url, headSha)` 在视图所显示 diff 的 head 处读取已绑定变更请求的讨论;head 不同时以 `forgePanel/stale-head` 拒绝,提供方不支持读取讨论时返回 `forgePanel/review-unsupported`,其他失败返回 `forgePanel/review-failed`。每个变更请求在内存里保留一次读取结果,以 head 提交和状态的更新时间为键、受 `maxCachedReviewBytes` 约束,保留时间达到 `reviewRefreshMs` 后重新读取:在 GitHub 上,新评论和回复会改变 PR 的更新时间,解决讨论则不会。变更视图可见时,每收到一次状态就重新请求一次,并按[面板 README](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package) 所述把讨论叠加到文件上。 + ```ts type-equiv /** * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order @@ -916,6 +1022,17 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** * List the commits of a change request at its current head. * @param ref - change request served by a registered provider. @@ -1186,6 +1303,23 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise +/** + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved + * since, which do not change a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ +@Remote async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise + /** * Publish a line comment or completed review for a visible Session binding. * @param session - Session retaining the change request. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml new file mode 100644 index 0000000000..8377a14f8a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md +/: + en: 53dbdc67362a5851 + zh: 9c7b2c4dd31cf666 +/forge-failures-report-unsupported-reads: + en: 3e5488f3008886b7 + zh: 8123e954c41d559c +/forge-failures-report-unsupported-reads/change: + en: 77712ff12908e838 + zh: 00714f77f3b7ae80 +/forge-failures-report-unsupported-reads/migration: + en: 698a34a7e09be2be + zh: a32a64dc130f496f diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md new file mode 100644 index 0000000000..d6c6aa2962 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "ForgeFailure gains the unsupported code, which ctx.forge.readReview returns for a provider without review reads." +--- + +# Forge failures report unsupported reads + +English | [中文](guide.zh.md) + +## Change + +`ForgeFailure['code']` gains `unsupported`. `ctx.forge.readReview(ref, headSha, signal)`, new beside the optional `ForgeProvider.readReview`, returns it when the provider of the change request does not read review threads. Other reads keep their codes: a provider without `fetchChecks` still makes `ctx.forge.fetchChecks` return `unavailable`. + +This affects code that switches exhaustively over `ForgeFailure['code']`, such as a `Record` lookup or a `switch` that ends in `assertNever`. All types come from `@deepseek-ai/dsh-experimental-forge`. Providers need no change: `readReview` is optional. + +## Migration + +1. Handle `unsupported` wherever code switches exhaustively over `ForgeFailure['code']`; treat it as a capability the provider lacks, not as a failed read to retry. +2. Run your type check; it succeeds when no `ForgeFailure` code is unhandled. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md new file mode 100644 index 0000000000..75f8a1057e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "ForgeFailure 新增 unsupported 代码;提供方不支持读取评审讨论时,ctx.forge.readReview 返回它。" +--- + +# Forge 失败代码报告不支持的读取 + +[English](guide.md) | 中文 + +## 变更 + +`ForgeFailure['code']` 新增 `unsupported`。新增的 `ctx.forge.readReview(ref, headSha, signal)` 对应可选的 `ForgeProvider.readReview`;变更请求的提供方不支持读取评审讨论时,它返回 `unsupported`。其他读取的代码不变:提供方未实现 `fetchChecks` 时,`ctx.forge.fetchChecks` 仍返回 `unavailable`。 + +受影响的是对 `ForgeFailure['code']` 做穷举的代码,例如 `Record` 查表,或以 `assertNever` 结尾的 `switch`。所有类型都来自 `@deepseek-ai/dsh-experimental-forge`。提供方无需修改:`readReview` 是可选方法。 + +## 迁移 + +1. 在所有对 `ForgeFailure['code']` 做穷举的地方处理 `unsupported`,把它当作提供方缺少的能力,而不是需要重试的读取失败。 +2. 运行类型检查;没有未处理的 `ForgeFailure` 代码时即通过。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5b25741184..a0392da274 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,23 +15,23 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 05ee6d9ba0b8d2c7 - zh: 98c988e8f957511f + en: e9ecdf9670bd6324 + zh: 6f5e3027afb18a3e /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 7495fe1e0d0ee643 - zh: f820a5790ae6e2a7 + en: 78cab599dbd4aa57 + zh: 87265844f34efbc5 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b /deepseek-ai-dsh-experimental-client-ui-forge/model-experience: - en: 10101427d1e0589b - zh: c981cec12f64eb75 + en: 795766e162ebf4df + zh: 893de3302798330a /deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect: en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 5e29ac36aad15d02 - zh: e46339bb637a69ef + en: 70d01fb04317f94a + zh: 6a287a063603f61d /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 27ff8a01c3..190c23e7d0 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -41,12 +41,13 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. +- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads; the count in a header, like the Threads toolbar button, opens the thread list, which groups the threads by file in file tree order, filters Unresolved, Outdated, Resolved, or Awaiting my reply, which keeps unresolved threads the signed-in user wrote in where someone else wrote last, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list names a truncated read. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. Threads are read again at each status while the view shows; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. The diff follows the file tree’s depth-first order: directories before files, with each group sorted by displayed name in case-sensitive UTF-16 code-unit order. Compacted directory chains are compared as one complete name. Filtering or folding the tree does not reorder the diff. Click the diff content to focus it. Left and Right move to the previous and next file in diff order and expand it; navigation stops at the first and last file. Up and Down keep their normal scrolling behavior. After manual scrolling, file navigation starts from the file at the top of the viewport. Text editing, non-collapsed text selections, modified keys, menus, and file-tree controls keep their own keyboard behavior. -`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCachedReviewBytes`, `reviewRefreshMs`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). All files opens a searchable directory tree with the selected file scrolled to its middle. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. @@ -69,7 +70,7 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestReview` acts on the same visible bindings, refuses a head other than the latest status's with `forgePanel/stale-head` before reading, and reads the provider's `readReview`, mapping `stale` to `forgePanel/stale-head`, `unsupported` to `forgePanel/review-unsupported`, and other failures to `forgePanel/review-failed`. It keeps one read per change request, keyed by head commit and update time, drops the least recently read once the kept reads exceed `maxCachedReviewBytes` as JSON, and reads again once a kept read is `reviewRefreshMs` old, since resolving a GitHub thread leaves the update time unchanged. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. @@ -95,6 +96,11 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser | +| [`src/client/ReviewThreads.tsx`](src/client/ReviewThreads.tsx) | Review thread cards and the outdated section | +| [`src/client/ThreadList.tsx`](src/client/ThreadList.tsx) | Thread list | +| [`src/client/review-threads.ts`](src/client/review-threads.ts) | Thread places, list filters, hunk excerpts, and thread quotes | +| [`src/client/thread-state.ts`](src/client/thread-state.ts) | Thread reads and their places in the shown changes | +| [`src/review-cache.ts`](src/review-cache.ts) | Review threads kept in memory | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | Changed file label: name, then directory | | [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | New Session worktree option | | [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | Worktree creation and removed-worktree card | @@ -117,7 +123,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t ## Model Experience -Indirectly, through the working-directory service and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. +Indirectly, through the working-directory service and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. Add to input on a review thread contributes a frozen `## Review Thread N` quote with File, Side, Lines, the commit those lines number, State, Source, the recorded diff hunk, and every comment's author, time, and body quoted line by line. #### KV Cache effect @@ -140,6 +146,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. +- **Review thread actions** — threads are read-only: replying, resolving, and editing comments, and drafts in GitHub's pending review, are deferred to the provider's review writes. Minimized comments show like others, and avatars load from the code host's image server in the browser. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. - **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. - **Review page** — deferred: a left-navigation page that lists the user's change requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 4aca1d2c38..f7ced4299b 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -41,12 +41,13 @@ kind: "package-reference" - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 +- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数;点击文件标题上的计数或工具栏的“讨论”按钮会打开讨论列表。列表按文件树顺序分组,可按未解决、已过期、已解决和“需要我回复”筛选(“需要我回复”保留当前登录用户参与过、且最后一条评论来自他人的未解决讨论),点击讨论会跳到它的位置;若所选提交中没有该讨论,会先切换到全部提交。读取被截断时,列表会注明。讨论上只有“添加到输入框”(把整个讨论作为引用插入)和“在 GitHub 中查看”两个操作。视图显示期间,每收到一次状态就重新读取讨论;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 diff 按文件树的深度优先顺序排列:同层目录在前、文件在后,各组按显示名称以区分大小写的 UTF-16 码元顺序排序。压缩目录链作为一个完整名称参与比较。筛选或折叠文件树不会改变 diff 的顺序。点击 diff 内容区可将焦点移入。左右方向键按 diff 顺序跳到上一个、下一个文件并展开它;到达首尾时停留。上下方向键保留原有滚动行为。手动滚动后,文件跳转从视口顶部的文件继续。文本编辑、非折叠文本选区、组合键、菜单与文件树控件保留各自的键盘行为。 -`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCachedReviewBytes`、`reviewRefreshMs`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 “全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 @@ -69,7 +70,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。`changeRequestReview` 同样只作用于可见的绑定;head 与最新状态的 head 不同时,读取前就以 `forgePanel/stale-head` 拒绝;随后调用提供方的 `readReview`,把 `stale` 映射为 `forgePanel/stale-head`、`unsupported` 映射为 `forgePanel/review-unsupported`、其余失败映射为 `forgePanel/review-failed`。每个变更请求保留一次读取结果,以 head 提交和更新时间为键;保留的结果按 JSON 计超过 `maxCachedReviewBytes` 时,最久未读的先被丢弃;保留时间达到 `reviewRefreshMs` 后重新读取,因为解决 GitHub 讨论不会改变更新时间。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 @@ -95,6 +96,11 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 | +| [`src/client/ReviewThreads.tsx`](src/client/ReviewThreads.tsx) | 评审讨论卡片与已过期区域 | +| [`src/client/ThreadList.tsx`](src/client/ThreadList.tsx) | 讨论列表 | +| [`src/client/review-threads.ts`](src/client/review-threads.ts) | 讨论位置、列表筛选、hunk 摘录与讨论引用 | +| [`src/client/thread-state.ts`](src/client/thread-state.ts) | 读取讨论并确定它们在所显示变更中的位置 | +| [`src/review-cache.ts`](src/review-cache.ts) | 保存在内存中的评审讨论 | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | 变更文件的标签:文件名在前,目录在后 | | [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 | | [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 | @@ -117,7 +123,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi ## 模型体验 -间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。 +间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。对评审讨论使用“添加到输入框”时,会加入一段固定的 `## Review Thread N` 引用,包含 File、Side、Lines、这些行号所在的提交、State、Source、记录下的 diff hunk,以及逐行引用的每条评论的作者、时间和正文。 #### KV Cache 影响 @@ -140,6 +146,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 +- **评审讨论的操作**——讨论是只读的:回复、解决、编辑评论,以及 GitHub 待提交评审中的草稿,留待提供方的评审写入能力实现。被折叠隐藏的评论与其他评论显示方式相同;头像由浏览器从代码托管平台的图片服务器加载。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 - **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 - **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 5763304362..d550841145 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -947,7 +947,7 @@ export function ChangesTab({
} - {review !== null && ( + {review !== null && review.threads.length > 0 && (
} const original = place.kind === 'outdated' && thread.original !== null ? thread.original : null + const label = original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) : range ?? t('thread.fileLevel') return
+ data-resolved={thread.resolved ? '' : undefined} aria-label={label}>
- {original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) - : range ?? t('thread.fileLevel')} + {label} {commit !== null && <> · {t('thread.onCommit', { commit })}} {place.kind === 'outdated' && {t('thread.outdatedTag')}} diff --git a/packages/experimental/client-ui-forge/src/client/review-threads.ts b/packages/experimental/client-ui-forge/src/client/review-threads.ts index 831b2acc0a..5080059646 100644 --- a/packages/experimental/client-ui-forge/src/client/review-threads.ts +++ b/packages/experimental/client-ui-forge/src/client/review-threads.ts @@ -164,8 +164,10 @@ export function formatThreadQuote(thread: ForgeReviewThread, url: string, index: const state = [thread.resolved ? 'resolved' : 'unresolved', ...thread.subject === 'line' && thread.current === null ? ['outdated'] : []].join(', ') const fence = thread.diffHunk === null ? '' : '`'.repeat(Math.max(3, ...Array.from(thread.diffHunk.matchAll(/`+/g), match => match[0].length + 1))) const hunk = thread.diffHunk === null ? '' : `Diff hunk:\n${fence}diff\n${thread.diffHunk}\n${fence}\n` + // A blank comment line becomes a bare `>` and comments follow one another without blank lines, since the composer + // quotes every line of the reference again. const comments = thread.comments.map(comment => `${comment.author === null ? 'Unknown author' : `@${comment.author.login}`} (${comment.createdAt}):\n` - + comment.body.split('\n').map(line => `> ${line}`).join('\n')).join('\n\n') + + comment.body.split('\n').map(line => line.trim() === '' ? '>' : `> ${line}`).join('\n')).join('\n') const link = thread.comments[0]?.url ?? url return `## Review Thread ${index}\nFile: ${thread.path}\n${where}\nState: ${state}\nSource: ${link}\n${hunk}Comments:\n${comments}` } diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index 9cbb1f9656..88485d2ea6 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -1786,6 +1786,13 @@ describe('ChangesTab review threads', () => { expect(card(m, 'on-row')).toBeNull() }) + it('offers no thread list for a change request without threads', async () => { + const m = mount({ source, setup: reviewed({ ...REVIEW, threads: [] }) }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledOnce() + expect(m.view.queryByRole('button', { name: /^Review threads/ })).toBeNull() + }) + it('names no author when the host hides the change request\'s author', async () => { const m = mount({ source, state: ready({ changeRequests: [entry(7, snapshot(7, { author: null }))] }), setup: reviewed({ ...REVIEW, threads: [line('on-row', 'src/app.ts', 'R', 4, { comments: [reviewComment({ author: { login: 'octo', avatarUrl: null, bot: false } })] })] }) }) diff --git a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx index b6173e7853..b29d22434b 100644 --- a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx @@ -108,7 +108,7 @@ describe('ThreadCard', () => { let answer: (value: RemoteResult) => void = () => {} const shared = actions({ readText: vi.fn(() => new Promise>((resolve) => { answer = resolve })) }) const view = render() - const card = view.getByRole('region', { name: en['thread.fileLevel'] }) + const card = view.getByRole('region', { name: 'Originally R2–3' }) expect(card.textContent).toContain(`Originally R2–3 · on ${OLD.slice(0, 7)}`) expect([...card.querySelectorAll('[class*="excerptLine"]')].map(line => line.textContent)).toEqual(['@@ -1 +1,3 @@', ' a', '+b', '+c']) fireEvent.click(view.getByRole('button', { name: en['thread.viewOriginal'] })) diff --git a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts index 2942e99f8a..1d793a7fc3 100644 --- a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts @@ -109,13 +109,13 @@ describe('thread quotes', () => { it('formats a line thread with its hunk, state, and every comment quoted line by line', () => { const thread: ForgeReviewThread = reviewThread({ current: { commit: HEAD, startLine: 3, endLine: 5 }, diffHunk: '@@ -1 +1 @@\n-```\n+````', - comments: [reviewComment({ body: '# Heading\nsecond line', url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), + comments: [reviewComment({ body: '# Heading\n\nsecond line', url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), reviewComment({ author: null, body: 'reply' })], }) expect(formatThreadQuote(thread, 'https://github.com/octo/demo/pull/7', 2)).toBe([ '## Review Thread 2', 'File: src/a.ts', 'Side: R', 'Lines: 3-5', `Commit: ${HEAD}`, 'State: unresolved', 'Source: https://github.com/octo/demo/pull/7#discussion_r1', 'Diff hunk:', '`````diff', '@@ -1 +1 @@\n-```\n+````', '`````', - 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> # Heading', '> second line', '', 'Unknown author (2026-11-05T08:00:00Z):', '> reply', + 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> # Heading', '>', '> second line', 'Unknown author (2026-11-05T08:00:00Z):', '> reply', ].join('\n')) }) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index c329bf236f..6d3ca2d441 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 0f8041e0380036c9 - zh: 63cbdc82c9f438d4 + en: dc1fc7eb436482c8 + zh: 968aa86136fdd840 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: - en: f98e77171cba809f - zh: 1b330cb342d69efe + en: f3d0a577b0a4db0a + zh: 0c15ac7979909117 /deepseek-ai-dsh-experimental-forge-github/further-exploration: en: d377d5fd9d9f190e zh: 920bcbfdb8c993f2 @@ -30,8 +30,8 @@ en: 26b946af4b742e17 zh: 00b34f0721ac72f6 /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work: - en: 9f13ae6d928e4ef7 - zh: 09a549b1655f4de3 + en: b5ad30bb209cdd48 + zh: eaea2d4cbe350ce4 /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 84a261269a..0d5f8ea6b6 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -50,6 +50,8 @@ A pull request is `mergeReady` when it is open, not a draft, GitHub reports its Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. +`readReview` reads review threads with one GraphQL query that names `headRefOid` and the signed-in viewer, and returns `stale` when the head is not the requested commit. It reads threads in pages of up to 100 until it has read `maxReviewThreads` of them, each page after the first naming the head again, so a push during the read returns `stale`; it reads every comment of each thread, and the commits and `diffHunk` of a thread's first comment once. All pages together, measured as JSON, must fit `maxOutputBytes`: the page that would pass it is dropped with the threads after it, and the result is marked `truncated`, as when more than `maxReviewThreads` threads exist. A thread on lines gets a `current` position only when GitHub reports it not outdated, with a `line`, and its first comment's `commit` is the head: a comment written on an earlier commit after a force-push keeps a `line` that numbers that commit, and GitHub can mark a thread outdated while keeping its `line`. `original` comes from `originalLine` and `originalStartLine` at the first comment's `originalCommit`; `subjectType: FILE` threads have neither. A range whose start is on the other side of the diff keeps its last line. Threads without comments are left out. REST's `position` does not tell outdated threads apart and is not read. + ----- @@ -66,6 +68,7 @@ A fetch groups references by host and sends one initial `gh api graphql` query p | [`src/provider.ts`](src/provider.ts) | URL mapping, host grouping, and failure classification | | [`src/graphql.ts`](src/graphql.ts) | Query construction and response validation and mapping | | [`src/compare.ts`](src/compare.ts) | Patch between two commits through the compare API, shared by every diff read | +| [`src/threads.ts`](src/threads.ts) | Review thread read, pagination, and mapping onto head and original positions | | [`src/gh.ts`](src/gh.ts) | Host `gh` execution with environment scrubbing and limits | | [`src/urls.ts`](src/urls.ts) | Remote URL and pull request URL parsing, and validation of inlined names and commits | @@ -96,11 +99,11 @@ None. Provider output reaches only host-side watchers. - **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read, including references retained across configuration changes. A status fetch returns `unavailable` for those references; a head lookup rejects before starting `gh`. -- **Review thread bound** — a status read counts at most `maxReviewThreads` review threads per pull request; beyond it `unresolvedThreads` is a lower bound that `threadsTruncated` marks. +- **Review thread bound** — a status read counts, and a review read reads, at most `maxReviewThreads` review threads per pull request; beyond it `unresolvedThreads` is a lower bound that `threadsTruncated` marks, and a review read is `truncated`. - **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads. - **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated. - **No fork discovery** — head lookups exclude pull requests whose head is in another repository. -- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit. +- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit. A review read costs one GraphQL query per page of 100 threads and one more per further page of 100 comments in a thread. - **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 205a8f0439..99896c73d8 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -50,6 +50,8 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 +`readReview` 用一次 GraphQL 查询读取评审讨论,同时读出 `headRefOid` 和当前登录的用户;head 不是所请求的提交时返回 `stale`。讨论按每页最多 100 个分页读取,直到读满 `maxReviewThreads` 个;第一页之后的每一页都会再次读取 head,因此读取期间有新的推送时返回 `stale`。每个讨论的评论都会全部读出,讨论第一条评论的提交和 `diffHunk` 只读一次。所有页面按 JSON 计合计不得超过 `maxOutputBytes`:会超出上限的那一页及其后的讨论被丢弃,结果标记为 `truncated`,与讨论数超过 `maxReviewThreads` 时相同。行级讨论只有在 GitHub 报告它未过期、带有 `line`,且第一条评论的 `commit` 就是 head 时,才得到 `current` 位置:force-push 之后写在旧提交上的评论保留的 `line` 是那个旧提交的行号,GitHub 也可能在标记讨论过期的同时保留它的 `line`。`original` 取自第一条评论 `originalCommit` 上的 `originalLine` 和 `originalStartLine`;`subjectType: FILE` 的讨论两者都没有。起始行在 diff 另一侧的范围只保留最后一行。没有评论的讨论会被略去。REST 的 `position` 无法区分已过期的讨论,因此不读取。 + ----- @@ -66,6 +68,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 | [`src/provider.ts`](src/provider.ts) | URL 映射、按主机分组与失败分类 | | [`src/graphql.ts`](src/graphql.ts) | 构造查询,并校验和映射响应 | | [`src/compare.ts`](src/compare.ts) | 经 compare API 读取两个提交之间的补丁,供所有 diff 读取共用 | +| [`src/threads.ts`](src/threads.ts) | 读取评审讨论、分页,并映射到 head 位置与原始位置 | | [`src/gh.ts`](src/gh.ts) | 在宿主上执行 `gh`,清理环境变量并施加限制 | | [`src/urls.ts`](src/urls.ts) | 解析远端 URL 与 pull request URL,并校验写入请求的名称与提交 | @@ -96,11 +99,11 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 - **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取,包括配置变更后保留的引用。状态读取为这些引用返回 `unavailable`;head 查找会在启动 `gh` 前拒绝。 -- **评审讨论上限**——每次状态读取每个 pull request 最多统计 `maxReviewThreads` 个评审讨论;超出时 `unresolvedThreads` 只是下限,由 `threadsTruncated` 标出。 +- **评审讨论上限**——每次状态读取每个 pull request 最多统计、每次评审读取最多读取 `maxReviewThreads` 个评审讨论;超出时 `unresolvedThreads` 只是下限,由 `threadsTruncated` 标出,评审读取的结果标记为 `truncated`。 - **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。 - **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。 - **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 -- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。 +- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。每次评审读取每页 100 个讨论消耗一次 GraphQL 查询,单个讨论的评论每多一页(100 条)再消耗一次。 - **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。 diff --git a/packages/experimental/forge-github/src/index.ts b/packages/experimental/forge-github/src/index.ts index 7daff01956..3235e030a3 100644 --- a/packages/experimental/forge-github/src/index.ts +++ b/packages/experimental/forge-github/src/index.ts @@ -18,9 +18,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts index f90c18b0a0..7962e90280 100644 --- a/packages/experimental/forge-github/tests/threads.spec.ts +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -56,15 +56,20 @@ function nextPage(threads: Record, headRefOid = head): Record | { readonly raw: GhOutput }): answer is { readonly raw: GhOutput } { + return 'raw' in answer +} + /** Answer the first thread page with `first`, later thread pages from `later` by cursor, and comment pages from `comments` by cursor. */ function review( - first: Record | GhOutput, + first: Record | { readonly raw: GhOutput }, later: Readonly>> = {}, comments: Readonly>> = {}, ): ReturnType { return fakeGh((args) => { const cursor = args.find(arg => arg.startsWith('cursor='))?.slice('cursor='.length) ?? '' - if (args.includes(`query=${REVIEW_QUERY}`)) return 'exitCode' in first ? first : output(first) + if (args.includes(`query=${REVIEW_QUERY}`)) return isRaw(first) ? first.raw : output(first) if (args.includes(`query=${REVIEW_THREADS_QUERY}`)) return output(later[cursor] ?? { data: { node: null } }) if (args.includes(`query=${THREAD_COMMENTS_QUERY}`)) return output(comments[cursor] ?? { data: { node: null } }) throw new Error(`unexpected gh ${args.join(' ')}`) @@ -221,14 +226,14 @@ describe('GitHub provider review threads', () => { }) it('passes on the failure of any page read and of a response that is not JSON', async () => { - const missing = review(output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.')) + const missing = review({ raw: output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.') }) await expect(provider(missing.run).readReview(pull(7), head, signal)) .resolves.toEqual({ code: 'not-found', message: 'gh: Could not resolve to a PullRequest with the number of 7.' }) - const garbled = review(output('not json')) + const garbled = review({ raw: output('not json') }) await expect(provider(garbled.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' }) - const refused = review(output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] })) + const refused = review({ raw: output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] }) }) await expect(provider(refused.run).readReview(pull(7), head, signal)) .resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' }) diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index ce02168f4c..2b96a443f1 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 66ce801db0de565f - zh: 884973fad2ad2540 + en: 2327b151ac15aa2f + zh: 429988457f4a67bd /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 8727e8cd2b..72bdb80f85 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -48,6 +48,8 @@ Reaching `fetchTimeoutMs` aborts the fetch signal with `FORGE_FETCH_TIMEOUT`. Pr Providers may implement `submitReview` to publish a line comment or a completed review at an explicit head commit. The registry returns unsupported when that capability is absent and refreshes watched status after a submission. Successful writes return their web URL; failures distinguish changed heads, refusals, and unknown outcomes. Callers must reconcile unknown outcomes before another submission. Pending drafts belong to the consumer, not the polling registry. +Providers may implement `readReview` to read a change request's review threads at one head commit. `readReview(ref, headSha, signal)` returns `ForgeReviewData`, the threads with each line thread's position at the head and at the commit it was written on, only when the read names `headSha` as the head; otherwise `stale`, which polls a watched change request at once, once per head commit shared with stale diff reads. The registry returns an `unsupported` failure when the provider lacks the method. + ----- diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index 168012126c..543f6ca83f 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -48,6 +48,8 @@ kind: "package-reference" provider 可以实现 `submitReview`,在指定的 head 提交上发布行内评论或完整评审。未实现该能力时,注册表返回不支持;提交结束后会刷新受关注的状态。成功的写操作返回网页 URL;失败区分 head 变化、明确拒绝与结果不明。调用方必须先核对结果不明的操作,再次提交。待提交草稿由消费者管理,不由轮询注册表保存。 +提供方可以实现 `readReview`,在一个 head 提交处读取变更请求的评审讨论。只有读取时的 head 就是 `headSha` 时,`readReview(ref, headSha, signal)` 才返回 `ForgeReviewData`,其中每个行级讨论都带有它在 head 上的位置和在写下时所在提交上的位置;否则返回 `stale`,并立即重新轮询被关注的变更请求,每个 head 提交只轮询一次,这个次数与 diff 读取遇到 `stale` 时共用。提供方未实现该方法时,注册表返回 `unsupported` 失败。 + ----- diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de37043f87..1c342a0672 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1276,6 +1276,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.', throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'], }, + { + signature: 'async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise', + description: 'Read the review threads of a change request at one head commit. A `stale` failure polls a watched change request again as ForgeService.diff does.', + parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'headSha', description: 'head commit the threads must be read at, usually the head of the displayed diff.' }, { name: 'signal', description: 'cancellation of the read.' }], + returns: 'the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews.', + throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'], + }, { signature: 'async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise', description: 'List the commits of a change request at its current head.', @@ -1433,6 +1440,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the text.', throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit; `forgePanel/diff-failed` when the host does not provide the file otherwise.'], }, + { + signature: '@Remote async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise', + description: 'Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read at the same head commit and update time are reused from memory, for any Session, until they are `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved since, which do not change a GitHub pull request\'s update time.', + parameters: [{ name: 'session', description: 'Session the change request is bound to.' }, { name: 'url', description: 'binding URL of the change request.' }, { name: 'headSha', description: 'head commit of the displayed diff.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'the threads at `headSha`, with `current` positions numbering the files of that diff.', + throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the status was read and when the host does not provide the threads otherwise.'], + }, { signature: '@Remote async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise', description: 'Publish a line comment or completed review for a visible Session binding.', @@ -6621,6 +6635,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeActionFailure', declaration: 'export interface ForgeActionFailure {\n readonly code: \'unauthenticated\' | \'stale\' | \'failed\';\n readonly message: string;\n}', }, + { + name: 'ForgeAnchor', + declaration: 'export interface ForgeAnchor {\n readonly commit: string;\n readonly startLine: number;\n readonly endLine: number;\n}', + }, { name: 'ForgeAutomaticBase', declaration: 'export interface ForgeAutomaticBase {\n readonly name: string;\n readonly worktree: boolean;\n}', @@ -6661,6 +6679,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeCheckoutState', declaration: 'export type ForgeCheckoutState = {\n readonly state: \'pending\';\n} | {\n readonly state: \'none\';\n} | {\n readonly state: \'failed\';\n readonly message: string;\n} | {\n readonly state: \'ready\';\n readonly checkout: ForgeCheckout;\n};', }, + { + name: 'ForgeCommentAuthor', + declaration: 'export interface ForgeCommentAuthor {\n readonly login: string;\n readonly avatarUrl: string | null;\n readonly bot: boolean;\n}', + }, + { + name: 'ForgeCommentId', + declaration: 'export type ForgeCommentId = Branded<\'ForgeCommentId\'>;', + }, { name: 'ForgeCommit', declaration: 'export interface ForgeCommit {\n readonly sha: string;\n readonly parent: string | null;\n readonly title: string;\n readonly author: string | null;\n readonly authoredAt: string;\n}', @@ -6687,7 +6713,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeFailure', - declaration: 'export interface ForgeFailure {\n readonly code: \'not-found\' | \'unauthenticated\' | \'too-large\' | \'stale\' | \'unavailable\';\n readonly message: string;\n}', + declaration: 'export interface ForgeFailure {\n readonly code: \'not-found\' | \'unauthenticated\' | \'too-large\' | \'stale\' | \'unsupported\' | \'unavailable\';\n readonly message: string;\n}', }, { name: 'ForgeFileLayer', @@ -6731,7 +6757,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeProvider', - declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n compare?(ref: ChangeRequestRef, base: string, head: string, s /* …truncated — full shape in source */', + declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: A /* …truncated — full shape in source */', }, { name: 'ForgeReadiness', @@ -6741,6 +6767,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeRepository', declaration: 'export interface ForgeRepository {\n readonly forge: string;\n readonly host: string;\n readonly owner: string;\n readonly name: string;\n}', }, + { + name: 'ForgeReviewComment', + declaration: 'export interface ForgeReviewComment {\n readonly id: ForgeCommentId;\n readonly author: ForgeCommentAuthor | null;\n readonly body: string;\n readonly createdAt: string;\n readonly pending: boolean;\n readonly url: string | null;\n}', + }, + { + name: 'ForgeReviewData', + declaration: 'export interface ForgeReviewData {\n readonly headSha: string;\n readonly viewer: string | null;\n readonly threads: readonly ForgeReviewThread[];\n readonly truncated: boolean;\n}', + }, { name: 'ForgeReviewRequest', declaration: 'export type ForgeReviewRequest = {\n readonly headSha: string;\n} & ({\n readonly kind: \'comment\';\n readonly comment: ForgeLineComment;\n} | {\n readonly kind: \'review\';\n readonly event: \'comment\' | \'approve\' | \'request-changes\';\n readonly body: string;\n readonly comments: readonly ForgeLineComment[];\n});', @@ -6749,10 +6783,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeReviewResult', declaration: 'export type ForgeReviewResult = {\n readonly ok: true;\n readonly url: string;\n} | {\n readonly ok: false;\n readonly code: \'unsupported\' | \'stale\' | \'failed\' | \'unknown\';\n readonly message: string;\n};', }, + { + name: 'ForgeReviewThread', + declaration: 'export interface ForgeReviewThread {\n readonly id: ForgeThreadId;\n readonly path: string;\n readonly side: \'L\' | \'R\';\n readonly subject: \'line\' | \'file\';\n readonly current: ForgeAnchor | null;\n readonly original: ForgeAnchor | null;\n readonly diffHunk: string | null;\n readonly resolved: boolean;\n readonly comments: readonly ForgeReviewComment[];\n}', + }, { name: 'ForgeSessionWorktree', declaration: 'export interface ForgeSessionWorktree {\n readonly path: string;\n readonly branch: string;\n readonly from: string | null;\n readonly baseCommit: string;\n readonly repositoryRoot: string;\n}', }, + { + name: 'ForgeThreadId', + declaration: 'export type ForgeThreadId = Branded<\'ForgeThreadId\'>;', + }, { name: 'ForgeWorktreeBlocker', declaration: 'export type ForgeWorktreeBlocker = \'not-git\' | \'outside-writable-root\' | \'read-only\';', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 92c46bf4b5..ee6fdd75bf 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -428,6 +428,7 @@ export const LINK_MAP: Readonly> = { ForgeChangeRequestDiff: 'forge.md', ForgeCommit: 'forge.md', ForgeCommitList: 'forge.md', + ForgeReviewData: 'forge.md', ForgeCommitRange: 'forge.md', ForgeFileLayer: 'forge.md', ForgeChangeRequestStatesFrame: 'forge.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index eb54361c8d..ad71d6ad42 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2821,6 +2821,41 @@ "doc": "docs/subsystems/forge.md", "symbol": "ForgeReviewResult", "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeThreadId", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeCommentId", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeAnchor", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeCommentAuthor", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeReviewComment", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeReviewThread", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeReviewData", + "source": "packages/experimental/forge/src/types.ts" } ] } From 409b0b1ab9be8466f954bec85f1265e786175fb1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:33:30 +0800 Subject: [PATCH 082/149] fix(cua): keep physical hover specific to supporting providers --- .../2026-10-13-cua-continuous-hover.i18n.yaml | 6 ++--- .../2026-10-13-cua-continuous-hover.md | 4 ++-- .../2026-10-13-cua-continuous-hover.zh.md | 2 +- ...cua-hover-routes-and-coordinates.i18n.yaml | 4 ++-- ...-10-13-cua-hover-routes-and-coordinates.md | 2 +- ...-13-cua-hover-routes-and-coordinates.zh.md | 2 +- .../tests/provider-lifecycle.spec.ts | 23 ++++++++++++++++++- packages/experimental/cua/src/index.ts | 6 ++--- 8 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml index 04a78f70d3..1dbc38b5ae 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml @@ -9,10 +9,10 @@ en: cdc04139c8d69415 zh: a3fe3e78ada08d95 /agent-note-continuous-cua-hover-without-cross-call-ownership/decision: - en: 125c860c1c568960 - zh: 8a2ece1101358200 + en: 193acf8c23672457 + zh: e614884d52585b16 /agent-note-continuous-cua-hover-without-cross-call-ownership/alternatives-considered: - en: 18d917ee2e909b93 + en: df67e547bce2f289 zh: 5b5ad0530f138cda /agent-note-continuous-cua-hover-without-cross-call-ownership/consequences: en: 714789d0a472a308 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md index 2cce428cff..29adedf224 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md @@ -10,7 +10,7 @@ Hover interactions span movement, delayed content, observation and input inside ## Decision -An explicit Window exposes `mouseMove` for coordinates or an observed element ID. The result reports `hoverRoute` and `systemPointerMoved`. It never presses a mouse button; disabled controls can use system-pointer movement with `systemPointerHover` enabled. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) selects AX alternate UI before allowing opt-in system movement with foreground and mouse-hit checks. It never activates or raises the app implicitly. Other input or window changes can race that check. +An explicit Window exposes `mouseMove` for coordinates or an observed element ID. The result reports `hoverRoute` and `systemPointerMoved`. It never presses a mouse button. With session approval, system-pointer movement also works on disabled controls. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) requires foreground and mouse-hit checks for system movement. It never activates or raises the app implicitly. Other input or window changes can race that check. The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete individual operations. It grants no application ownership across calls. Earlier arrival, a retained app object and Session lifetime do not establish task priority. Waiting for the model does not reserve the application. Explicit task coordination determines any required execution order. @@ -20,7 +20,7 @@ The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete **Click or drag as hover** introduces button input that can activate a control, select text or dismiss a popup. Hover requires a separate unpressed movement operation. -**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The system-pointer fallback avoids reporting this unreliable event route as continuous hover. +**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The approved system-pointer route avoids reporting this unreliable event route as continuous hover. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md index cc2457a70a..22668bd215 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,启用 `systemPointerHover` 后可通过系统鼠标悬停禁用控件。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)优先选择 AX 替代界面,只有启用该配置时才回退到经过前台和鼠标命中检查的系统移动,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。 +显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,用户授权当前会话移动鼠标后,也可悬停在禁用的控件上。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)要求移动系统鼠标前检查前台应用和鼠标命中的窗口,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。 [共享 helper](2026-10-09-cua-shared-native-helper.zh.md)串行执行完整的单次操作,不分配跨调用的应用所有权。先发起请求、持有 app 对象和 Session 寿命均不代表任务优先级。等待模型期间不占用应用。需要确定执行顺序时,由明确的任务协调决定。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 9775baf3a4..f6546d2ce8 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: bdf4d3386521ce00 - zh: 96db93177ad96a76 + en: 9e06d78eba10770d + zh: 25e985aa49f75dd8 /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index c385525731..a1f0a7fac1 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -10,7 +10,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. ## Decision -Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. Enabling `systemPointerHover` permits the real system pointer fallback. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). +By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. After the user approves `cua.requestMouseControl()`, each `mouseMove` in that session uses the system pointer until DSH restarts. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for `click`, `mouseMove`, `scroll` and `drag`; it does not apply to opaque element IDs. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index 0d3db32400..f079ff736f 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `systemPointerHover` 后才允许移动系统光标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 +默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。用户同意 `cua.requestMouseControl()` 后,本会话的每次 `mouseMove` 都使用系统光标,重启 DSH 后授权失效。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index f975862baa..922078987a 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -17,7 +17,7 @@ import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import Hmr from '@deepseek-ai/dsh-hmr' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' -import { CuaConfig } from '@deepseek-ai/dsh-cua' +import { CuaConfig, installCua } from '@deepseek-ai/dsh-cua' import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import FileSystem from '@deepseek-ai/dsh-fs-local' @@ -927,8 +927,10 @@ it('drops late approval after cancellation and admits only one pending question' expect((await f.execute('requestMouseControl')).isError).toBe(true) await f.ctx.plugin(UserQuestions) const entered = Promise.withResolvers() + let count = 0 const release = Promise.withResolvers() f.ctx.on('user-questions/request', async () => { + count++ entered.resolve(undefined) await release.promise return { answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] } @@ -941,5 +943,24 @@ it('drops late approval after cancellation and admits only one pending question' release.resolve(undefined) await Promise.allSettled([first]) expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(count).toBe(2) expect(native.request).not.toHaveBeenCalled() }) + +it('keeps provider-specific pointer flags and questions off providers without mouse control', async () => { + const f = await setup() + await f.provider.dispose() + await f.ctx.plugin({ name: 'fixture-window-provider', inject: NativeProvider.inject, apply(ctx: Context) { + installCua(ctx, CuaConfig(config), { + helperPath: config.helperPath, createChannel() { throw new Error('Unexpected native channel') }, + guidance: '', permissionParameters: () => ({}), + capabilities: { platform: 'fixture', methods: ['getApp', 'mouseMove'], appLevelAccessibility: true, + keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, + }) + } }) + expect((await f.execute('requestMouseControl')).isError).toBe(true) + await selectApplication(f) + const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } + expect((await f.execute('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', target, expect.any(AbortSignal), expect.any(String)) +}) diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 40cb0bf1cd..763e7bc0ec 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -157,7 +157,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) - const mouseControl = new MouseControl(ctx) + const mouseControl = backend.capabilities.methods.includes('requestMouseControl') ? new MouseControl(ctx) : undefined const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -222,7 +222,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) ? { ...params, maxElements: config.maxElements, maxDepth: config.maxDepth } : method === 'drag' ? { ...params, durationMs: config.dragDurationMs, steps: config.dragSteps } : params - if (method === 'mouseMove') nativeParams.useSystemPointer = mouseControl.allowed(agent) + if (method === 'mouseMove' && mouseControl) nativeParams.useSystemPointer = mouseControl.allowed(agent) const requestNative = () => client.request(method, nativeParams, signal, execution.callId) if (method === 'screenshot') { const { attachments, target } = await screenshotStore(ctx, execution) @@ -310,7 +310,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) signal.throwIfAborted() const operation = (async () => { if (request.method === 'requestMouseControl') { - if (!backend.capabilities.methods.includes(request.method)) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') + if (!mouseControl) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') return mouseControl.request(agent, signal) } const previous = clients.get(agent) From dd5cda398ceb95fd50e1c3310a7fd429e9c8cf66 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:47:29 +0800 Subject: [PATCH 083/149] feat(cua-macos): send owner key repeats and explain conflicting holds --- .../experimental/cua-macos/README.i18n.yaml | 4 +- packages/experimental/cua-macos/README.md | 4 +- packages/experimental/cua-macos/README.zh.md | 4 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../native/Sources/CuaNative/AXWorker.swift | 12 +++- .../native/Sources/CuaNative/HeldKeys.swift | 46 +++++++++++--- .../Sources/CuaNative/KeyboardInput.swift | 20 +++--- .../native/tests/CapabilitiesFixture.swift | 7 ++- .../native/tests/capabilities_e2e.py | 13 +++- .../tests/swift/KeyboardChordTests.swift | 61 +++++++++++++++++-- .../experimental/cua-macos/src/guidance.ts | 2 +- .../cua-native/system-prompt.expected.md | 2 +- .../session/cua-ptc/system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- 16 files changed, 146 insertions(+), 41 deletions(-) diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 00c45988de..1be5a7abd9 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: db5e8ad4d2ea832a - zh: 647cf55f4851760e + en: 83d4eb62d0d0db2f + zh: 3afde1c3b8c25211 /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 971acf4818..26a218c40d 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -81,7 +81,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper `pressKey(key, modifiers?)` presses and releases one key. For shortcuts, pass held modifiers separately, as in `pressKey('a', ['command'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. Use the App object's `pressKey` for a short press. -For a long press, call `app.sendKeyEvent('w', 'down')` inside `run_code`. Each successful down remains active across subsequent tool calls in that execution. Choose when to send each up: after W down, D down and D up, W stays down. When the program returns, exits with an error, or the user pauses it, the helper releases the remaining keys at the processes that received their downs. A later execution cannot release an earlier execution's keys. +For a long press, call `app.sendKeyEvent('w', 'down')` inside `run_code`. The first down holds the key across subsequent tool calls. For an ordinary key already held by this execution, each additional down sends one key-down marked as a repeat to the original process. Choose the interval between calls. One up releases the key. Choose when to send each up: after W down, D down and D up, W stays down. When the program returns, exits with an error, or the user pauses it, the helper releases the remaining keys at the processes that received their downs. A later execution cannot release an earlier execution's keys. Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot. @@ -224,7 +224,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 57c2f3f371..a40be5399b 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -81,7 +81,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. `pressKey(key, modifiers?)` 按下并松开一个键。使用快捷键时单独传入修饰键,例如 `pressKey('a', ['command'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。`+` 和 `plus` 都会发送 Shift 和等号。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。短按使用 App 对象上的 `pressKey`。 -长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。按下成功后,在同一次执行中调用其他工具时该键仍保持按下。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 为本次执行仍按着的键逐一发送松开事件,每个事件都发回原接收进程。后续执行不能释放前一次执行的键。 +长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。首次按下后,该键在同一次执行的后续工具调用中保持按下。对自己已按住的普通键再发一次 `down`,原接收进程会收到一次带重复标志的按下事件。调用方决定是否重复和间隔多久,松键只需一次 `up`。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 为本次执行仍按着的键逐一发送松开事件,每个事件都发回原接收进程。后续执行不能释放前一次执行的键。 Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 @@ -224,7 +224,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 58af404d5d..57420b0348 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2a69d89b7fc95998 zh: 084575bb31fb40dd /dsh-cua-native-helper/observation-and-input: - en: 0bbc620165d868fa - zh: abe73bd9c7b8cc5d + en: c6787eda94ba4833 + zh: 855e3c6432918775 /dsh-cua-native-helper/verification: en: c87945aeec587bf5 zh: e5d69e4927e2fed3 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index cc3a0de35f..51d0a7b131 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -105,7 +105,7 @@ Capture and pointer delivery read CG bounds before and after an exact-window AX `typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses the listed modifiers followed by one key, then releases all pressed keys in reverse order, including on cancellation. Keyboard events go directly to the target PID and bypass the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. -The Host associates directional key events with one `run_code` execution. On down, the helper retains the selected application, recipient PID and process start time, physical key code, and matching up event. Explicit up and cleanup verify the original process identity. On return, error, or user pause, the Host waits for any in-flight native request and asks the helper to release only that execution’s remaining keys. Disconnect cleanup also releases keys owned by the closed connection. +The Host associates directional key events with one `run_code` execution. On down, the helper retains the selected application, recipient PID and process start time, physical key code, and matching up event. A repeated down from the owner posts one key-down with `keyboardEventAutorepeat` set and reuses the existing record. Repeats, explicit up and cleanup verify the original process identity. A conflicting down or up reports the key and whether another session or another run holds it. On return, error, or user pause, the Host waits for any in-flight native request and asks the helper to release only that execution’s remaining keys. Disconnect cleanup also releases keys owned by the closed connection. The selected helper application needs macOS Accessibility permission for AX state and input, and Screen Recording permission for screenshots. Capability preflight and permission errors do not grant permissions. Capture refuses a window that moves or resizes while the image is being read. Unassociated system panels and accessory-only applications are not selectable. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 1b0c3f406f..eef79a770f 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -105,7 +105,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和一个主键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。键盘事件直接发给目标进程,绕过全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 -宿主为每次 `run_code` 的 `sendKeyEvent` 附上执行标识。按下时,helper 保存已选应用、接收进程的 PID 与启动时间、物理键码及对应的松开事件。显式松键或执行结束时会核对原进程身份。正常返回、异常退出或用户暂停时,宿主等待在途原生请求结束,再通知 helper 释放该执行持有的其余按键。连接断开时,helper 也会清理该连接持有的按键。 +宿主为每次 `run_code` 的 `sendKeyEvent` 附上执行标识。按下时,helper 保存已选应用、接收进程的 PID 与启动时间、物理键码及对应的松开事件。持有方再次按下普通键时,helper 复用原记录,发送一次带 `keyboardEventAutorepeat` 标志的按下事件。重复、显式松键和执行结束时都会核对原进程身份。其他执行尝试按下或松开同一个键时,错误会指出键名,并区分其他会话和同一连接内的其他执行。正常返回、异常退出或用户暂停时,宿主等待在途原生请求结束,再通知 helper 释放该执行持有的其余按键。连接断开时,helper 也会清理该连接持有的按键。 所选 helper 应用需要 macOS 辅助功能权限以读取 AX 和输入,需要屏幕录制权限以截图。能力预检和权限错误不授予权限。截图会拒绝在读取图片期间移动或缩放的窗口。无法确认归属的系统面板及仅后台辅助类型的应用不可选择。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index 1ebde9ee41..b8121fee03 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -215,7 +215,12 @@ actor AXWorker { return .result(Self.actionResult) } if method == "sendKeyEvent", try params.string("direction") == "up" { - let dispatched = try inputs().0.keyUp(params.string("key"), app: params.string("appHandle"), run: params.string("runId")) + let handle = try params.string("appHandle") + let dispatched = try inputs().0.keyUp(params.string("key"), app: handle, run: params.string("runId")) { + try NativePlatform.requireUnlocked(observeSession()) + try NativePlatform.requireAccessibility() + return try ax().keyboardRecipient(validateIdentity(handle), catalog: catalog) + } return .result(.object(["dispatched": .bool(dispatched)])) } let locked = observeSession() @@ -339,8 +344,9 @@ actor AXWorker { readTimeout: .milliseconds(try params.integer("pasteReadTimeoutMs", in: 1...2147483647)), maxElements: try params.integer("maxElements", in: 1...10000), maxDepth: try params.integer("maxDepth", in: 1...100))) case "sendKeyEvent": - let recipient = try ax().keyboardRecipient(identity, catalog: catalog) - try inputs().0.keyDown(params.string("key"), app: handle, run: params.string("runId"), recipient: recipient) + try inputs().0.keyDown(params.string("key"), app: handle, run: params.string("runId")) { + try ax().keyboardRecipient(identity, catalog: catalog) + } case "typeText", "pressKey": let recipient = try ax().keyboardRecipient(identity, catalog: catalog) try NativePlatform.verify(recipient) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift index 47c34fbe2a..06f47981d2 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift @@ -26,11 +26,34 @@ final class HeldKeys: @unchecked Sendable { lock.withLock { Dictionary(uniqueKeysWithValues: pressed.filter { $0.recipient.pid == pid }.map { ($0.code, $0.flag) }) } } - func down(owner: Owner, recipient: ProcessIdentity, code: CGKeyCode, flag: CGEventFlags, + private func conflict(_ name: String, holder: Owner, caller: Owner) -> NativeFailure { + let source: String + if holder.connection != caller.connection { source = "another session" } + else if holder.run != caller.run { source = "another run_code execution" } + else { source = "a different application handle in this run_code" } + return RequestParameters.invalid("Key '\(name)' is held for this target by \(source). No event was sent. Wait for the holder to release it or for its run_code to end.") + } + + /// Repeats use the saved recipient even when the selected application's focused panel has changed. + func repeatIfHeld(owner: Owner, name: String, code: CGKeyCode, down: CGEvent, post: (CGEvent, Int32) -> Void) throws -> Bool { + try lock.withLock { + guard let key = pressed.first(where: { $0.owner == owner && $0.code == code }) else { return false } + guard down.type == .keyDown else { + throw RequestParameters.invalid("Modifier '\(name)' is already held. Send 'up' to release it.") + } + try NativePlatform.verify(key.recipient) + down.flags = flags(for: key.recipient) + down.setIntegerValueField(.keyboardEventAutorepeat, value: 1) + post(down, key.recipient.pid) + return true + } + } + + func down(owner: Owner, recipient: ProcessIdentity, name: String, code: CGKeyCode, flag: CGEventFlags, down: CGEvent, up: CGEvent, post: (CGEvent, Int32) -> Void) throws { try lock.withLock { - guard !pressed.contains(where: { $0.code == code && ($0.owner == owner || $0.recipient.pid == recipient.pid) }) else { - throw RequestParameters.invalid("Key is already held for this target") + if let key = pressed.first(where: { $0.code == code && $0.recipient.pid == recipient.pid }) { + throw conflict(name, holder: key.owner, caller: owner) } pressed.append(Press(owner: owner, recipient: recipient, code: code, flag: flag, up: up)) down.flags = flags(for: recipient) @@ -56,13 +79,20 @@ final class HeldKeys: @unchecked Sendable { return true } - func up(owner: Owner, code: CGKeyCode, post: (CGEvent, Int32) -> Void) throws -> Bool { - try lock.withLock { - guard let index = pressed.firstIndex(where: { $0.owner == owner && $0.code == code }) else { - throw RequestParameters.invalid("This run has not pressed that key for this application") - } + func up(owner: Owner, name: String, code: CGKeyCode, recipientIfUnheld: () throws -> ProcessIdentity, + post: (CGEvent, Int32) -> Void) throws -> Bool { + let dispatched = try lock.withLock { () throws -> Bool? in + guard let index = pressed.firstIndex(where: { $0.owner == owner && $0.code == code }) else { return nil } return try release(at: index, post: post) } + if let dispatched { return dispatched } + let recipient = try recipientIfUnheld() + throw lock.withLock { + if let key = pressed.first(where: { $0.code == code && $0.recipient == recipient }) { + return conflict(name, holder: key.owner, caller: owner) + } + return RequestParameters.invalid("Key '\(name)' is not held for this target. Send 'down' in this run_code before 'up'.") + } } func release(connection: UUID, run: String?, post: (CGEvent, Int32) -> Void) throws { diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift index 1a7ca086ba..b435d4e46b 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/KeyboardInput.swift @@ -22,20 +22,24 @@ final class KeyboardInput { self.checkCancellation = checkCancellation } - func keyDown(_ name: String, app: String, run: String, recipient: ProcessIdentity) throws { - let modifier = Self.namedModifier(name) + func keyDown(_ name: String, app: String, run: String, recipient: () throws -> ProcessIdentity) throws { + let owner = HeldKeys.Owner(connection: connection, run: run, app: app) let code = try Self.keyCode(name) let source = try Self.source() let down = try Self.keyEvent(source: source, code: code, down: true, flags: []) + try checkCancellation() + if try held.repeatIfHeld(owner: owner, name: name, code: code, down: down, post: post) { return } + let target = try recipient() let up = try Self.keyEvent(source: source, code: code, down: false, flags: []) try checkCancellation() - try NativePlatform.verify(recipient) - try held.down(owner: .init(connection: connection, run: run, app: app), recipient: recipient, - code: code, flag: modifier?.flag ?? [], down: down, up: up, post: post) + try NativePlatform.verify(target) + try held.down(owner: owner, recipient: target, name: name, code: code, flag: Self.namedModifier(name)?.flag ?? [], + down: down, up: up, post: post) } - func keyUp(_ name: String, app: String, run: String) throws -> Bool { - try held.up(owner: .init(connection: connection, run: run, app: app), code: Self.keyCode(name), post: post) + func keyUp(_ name: String, app: String, run: String, recipientIfUnheld: () throws -> ProcessIdentity) throws -> Bool { + try held.up(owner: .init(connection: connection, run: run, app: app), name: name, code: Self.keyCode(name), + recipientIfUnheld: recipientIfUnheld, post: post) } func releaseHeldKeys(run: String? = nil) throws { @@ -52,7 +56,7 @@ final class KeyboardInput { chord.append(Key(keyCode: code, flag: Self.namedModifier(name)?.flag ?? [])) let alreadyHeld = held.snapshot(pid: pid) guard alreadyHeld[code] == nil else { - throw RequestParameters.invalid("Release the held key before using it in pressKey") + throw RequestParameters.invalid("Key '\(name)' is already held for this target. Use pressKey after the run_code that holds it releases it.") } var selected = Set(alreadyHeld.keys) var flags = alreadyHeld.values.reduce(CGEventFlags()) { $0.union($1) } diff --git a/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift b/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift index 1151468790..d937c35ac2 100644 --- a/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift +++ b/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift @@ -85,8 +85,11 @@ final class CapabilitiesFixture: NSObject, NSApplicationDelegate, NSWindowDelega guard let self else { return } var value: [String: Any] = ["type": event.type.rawValue, "timestamp": event.timestamp, "flags": event.modifierFlags.rawValue, "shift": event.modifierFlags.contains(.shift), "command": event.modifierFlags.contains(.command)] - if [.keyDown, .keyUp].contains(event.type) { value["keyCode"] = event.keyCode; value["characters"] = event.characters ?? "" } - else { value["button"] = event.buttonNumber } + if [.keyDown, .keyUp].contains(event.type) { + value["keyCode"] = event.keyCode + value["characters"] = event.characters ?? "" + value["repeat"] = event.isARepeat + } else { value["button"] = event.buttonNumber } self.events.append(value) self.persist() } diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 86f5cdef80..66dd004700 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -150,6 +150,17 @@ def check_keyboard(call, owner, state_file, state): state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) assert [(e["type"], e["keyCode"]) for e in state["events"][count:]] == [(10, 13), (10, 2), (11, 2), (11, 13)] + for explicit_up in (True, False): + count = len(state["events"]) + key("w", "down") + key("w", "down") + if explicit_up: + key("w", "up") + call("releaseHeldKeys", {"runId": "held-input"}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 3) + repeat = [(e["type"], e["keyCode"], e["repeat"]) for e in state["events"][count:]] + assert repeat == [(10, 13, False), (10, 13, True), (11, 13, False)], repeat + def main(): parser = argparse.ArgumentParser() @@ -191,7 +202,7 @@ def main(): pid = wait_for_state(state_file, lambda value: value["windowId"] > 0)["pid"] if args.keyboard_only: check_keyboard(call, owner, state_file, json.loads(state_file.read_text())) - print("PASS: Shift+D shortcut, two-second W+D hold, independent D up and remaining W cleanup") + print("PASS: Shift+D shortcut, two-second W+D hold, independent D up, explicit W repeat and one final up") return if not args.click_only: call("activate", owner) diff --git a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift index f81b10d1da..a971328ce3 100644 --- a/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/KeyboardChordTests.swift @@ -72,15 +72,66 @@ func plusAlonePressesShiftEquals(_ alias: String) throws { #expect(pid == target.pid) events.append(event) }, textEventInterval: 0, held: held) - try input.keyDown("shift", app: "editor", run: "one", recipient: target) - try input.keyDown("w", app: "editor", run: "two", recipient: target) - #expect(throws: NativeFailure.self) { try input.keyDown("W", app: "editor", run: "one", recipient: target) } - #expect(throws: NativeFailure.self) { try input.keyUp("w", app: "other", run: "two") } + try input.keyDown("shift", app: "editor", run: "one", recipient: { target }) + #expect(throws: NativeFailure.self) { try input.keyDown("shift", app: "editor", run: "one", recipient: { target }) } + try input.keyDown("w", app: "editor", run: "two", recipient: { target }) + #expect(throws: NativeFailure.self) { try input.keyDown("W", app: "editor", run: "one", recipient: { target }) } + #expect(throws: NativeFailure.self) { try input.keyUp("w", app: "other", run: "two", recipientIfUnheld: { target }) } #expect(throws: NativeFailure.self) { try input.pressKey("w", modifiers: [], to: target.pid) } try input.pressKey("d", modifiers: ["shift"], to: target.pid) try input.releaseHeldKeys(run: "one") - #expect(try input.keyUp("w", app: "editor", run: "two")) + #expect(try input.keyUp("w", app: "editor", run: "two", recipientIfUnheld: { target })) try input.releaseHeldKeys(run: "two") #expect(events.map { $0.getIntegerValueField(.keyboardEventKeycode) } == [56, 13, 2, 2, 56, 13]) #expect(events.map { $0.flags.contains(.maskShift) } == [true, true, true, true, false, false]) } + +@Test(arguments: [true, false]) +func repeatedDownUsesOriginalRecipientAndOneRelease(_ explicitUp: Bool) throws { + let target = try NativePlatform.identity(getpid()) + var events: [CGEvent] = [] + let input = KeyboardInput(post: { event, pid in + #expect(pid == target.pid) + events.append(event) + }, textEventInterval: 0) + func changedFocus() throws -> ProcessIdentity { + throw RequestParameters.invalid("The repeat or release looked up a new recipient") + } + try input.keyDown("w", app: "editor", run: "one", recipient: { target }) + try input.keyDown("W", app: "editor", run: "one", recipient: changedFocus) + if explicitUp { #expect(try input.keyUp("w", app: "editor", run: "one", recipientIfUnheld: changedFocus)) } + try input.releaseHeldKeys(run: "one") + #expect(events.map(\.type) == [.keyDown, .keyDown, .keyUp]) + #expect(events.map { $0.getIntegerValueField(.keyboardEventAutorepeat) } == [0, 1, 0]) +} + +@Test func foreignSessionCannotRepeatOrReleaseTheOwnersKey() throws { + let target = try NativePlatform.identity(getpid()) + let held = HeldKeys() + var events: [CGEvent] = [] + let a = KeyboardInput(post: { event, _ in events.append(event) }, textEventInterval: 0, held: held) + let b = KeyboardInput(post: { _, _ in Issue.record("Non-owner dispatched an event") }, textEventInterval: 0, held: held) + try a.keyDown("w", app: "a", run: "same-run-id", recipient: { target }) + for direction in ["down", "up"] { + do { + if direction == "down" { try b.keyDown("W", app: "b", run: "same-run-id", recipient: { target }) } + else { _ = try b.keyUp("W", app: "b", run: "same-run-id", recipientIfUnheld: { target }) } + Issue.record("Non-owner was allowed to send an event") + } catch let failure as NativeFailure { + #expect(failure.code == "INVALID_ARGUMENT") + #expect(failure.message.contains("Key 'W' is held for this target by another session")) + #expect(failure.message.contains("Wait for the holder to release it")) + } + } + try b.releaseHeldKeys() + try a.keyDown("W", app: "a", run: "same-run-id", recipient: { target }) + try a.releaseHeldKeys() + #expect(events.map(\.type) == [.keyDown, .keyDown, .keyUp]) + do { + _ = try b.keyUp("W", app: "b", run: "same-run-id", recipientIfUnheld: { target }) + Issue.record("Unheld key was released") + } catch let failure as NativeFailure { + #expect(failure.code == "INVALID_ARGUMENT") + #expect(failure.message.contains("Key 'W' is not held for this target")) + } +} diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 6b2f073924..839eb56a3c 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -14,7 +14,7 @@ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { if (method === 'pressKey') return `${entry} -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index e8deb643fb..a01916dbe2 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -79,7 +79,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 5322aa44d7..ef5ce31b6d 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -81,7 +81,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index 4c5b91329b..9c072b94ea 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -81,7 +81,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' once per key to hold one or more keys across tool calls within one run_code, for example W down, D down, D up, W up. Holding a physical key on a keyboard can produce repeated key-down events after a delay. This method posts one event per call and rejects a second 'down' for a held key. Use the app's response to decide what to do while holding the key and when to send 'up'. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From 6cc45f22bd9c3c569da9210389c927ebd6901319 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sat, 7 Nov 2026 14:55:25 +0800 Subject: [PATCH 084/149] =?UTF-8?q?feat:=20=E5=9C=A8=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=AD=E5=85=B3=E9=97=AD=20subagent=20?= =?UTF-8?q?=E5=92=8C=20Team=20=E7=9A=84=20interrupt=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=20(#7986)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(subagent): disable interrupt tool by default * test(subagent): update remaining default tool inventories * fix: disable agent interruption in shipped configuration * test: configure interruption explicitly in custom preset fixture * test: update tool count in collapse display snapshots * docs: clarify agent interruption opt-in and verify preset controls * test: cover macOS CUA channel policy forwarding on every platform * test: move CUA coverage repair to a separate master change --- .../parent.expected.jsonl | 4 +- apps/cli/tests/web-agent-presets.e2e.ts | 51 +- apps/web/tests/shipped-composition.e2e.ts | 1 - .../guide.i18n.yaml | 16 + .../subagent-interrupt-default/guide.md | 27 + .../subagent-interrupt-default/guide.zh.md | 27 + packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 + packages/bundle/base/README.zh.md | 2 + packages/bundle/base/cordis.patch.yml | 2 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 + packages/bundle/web-app/README.zh.md | 2 + .../bundle/web-app/presets/cordis.patch.yml | 2 + packages/bundle/web-app/presets/ptc.patch.yml | 2 + .../bundle/web-app/presets/standard.patch.yml | 2 + packages/team/agent-team/README.i18n.yaml | 4 +- packages/team/agent-team/README.md | 2 +- packages/team/agent-team/README.zh.md | 2 +- .../dynamic-tools/tool-history.json | 3 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../system-prompt.expected.md | 8 - .../notifications.expected.jsonl | 6 +- .../sdk/dynamic-tool-updates/session.v4.jsonl | 6 +- snapshots/sdk/plan-review/session.v4.jsonl | 10 +- .../plan-review/tool-schemas.expected.json | 1936 ++++++++--------- .../tool-schemas.1.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../notifications.expected.jsonl | 4 +- .../sdk/tool-error-details/session.v4.jsonl | 4 +- .../system-prompt.expected.md | 8 - .../tool-schemas.expected.json | 16 - .../advanced-toolchain/session.v4.jsonl | 8 +- .../system-prompt.expected.md | 8 - .../tool-schemas.expected.json | 32 - .../tool-schemas.expected.json | 32 - .../tool-schemas.expected.json | 16 - .../both-mode-turn/system-prompt.expected.md | 8 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 32 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../cua-native/tool-schemas.expected.json | 16 - .../session/cua-ptc/system-prompt.expected.md | 8 - .../session.v4.jsonl | 6 +- .../dynamic-tool-updates/session.v4.jsonl | 6 +- .../tool-schemas.expected.json | 48 - .../tool-schemas.expected.json | 16 - .../in-history-series-start/session.v4.jsonl | 6 +- .../lsp-definition/tool-schemas.expected.json | 16 - .../system-prompt.expected.md | 8 - .../mcp-resources/tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../office-authoring-checker/session.v4.jsonl | 4 +- .../session.v4.jsonl | 4 +- .../tool-schemas.expected.json | 32 - .../system-prompt.expected.md | 16 - .../office-render-tools/writer.expected.jsonl | 4 +- .../session/office-skills/session.v4.jsonl | 4 +- .../office-skills/tool-schemas.expected.json | 32 - .../tool-schemas.expected.json | 16 - .../session.v4.jsonl | 6 +- .../plugin-manager-mcp/session.v4.jsonl | 4 +- .../tool-schemas.expected.json | 32 - .../plugin-manager/tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../ptc-persistent/system-prompt.expected.md | 8 - .../system-prompt.expected.md | 8 - .../ptc-python-turn/system-prompt.expected.md | 10 - .../ptc-read-image/system-prompt.expected.md | 8 - .../system-prompt.expected.md | 8 - .../ptc-turn/system-prompt.expected.md | 8 - .../tool-schemas.expected.json | 16 - .../pwsh-tool-turn/tool-schemas.expected.json | 16 - .../ralph-loop/tool-schemas.1.expected.json | 16 - .../ralph-loop/tool-schemas.2.expected.json | 16 - .../ralph-loop/tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.1.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../cordis.snapshot.yml | 2 + .../subagent-no-message-tool/cordis.yml | 2 + .../tool-schemas.1.expected.json | 16 - .../session/team-targets/cordis.snapshot.yml | 4 + snapshots/session/team-targets/cordis.yml | 4 + .../tool-schemas.expected.json | 16 - .../text-turn/tool-schemas.expected.json | 16 - .../web-fetch/tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../worktree/tool-schemas.expected.json | 16 - .../collapse-timing/next-message.expected.md | 2 +- .../web/collapse-timing/verbose.expected.md | 2 +- .../creation-recommendations/session.v4.jsonl | 4 +- .../tool-schemas.expected.json | 32 - .../tool-schemas.expected.json | 16 - .../tool-schemas.expected.json | 16 - .../system-prompt.expected.md | 8 - .../system-prompt.expected.md | 8 - .../plugin-recommendations/session.v4.jsonl | 4 +- .../tool-schemas.expected.json | 32 - .../web/ptc-round/system-prompt.expected.md | 8 - 120 files changed, 1133 insertions(+), 2328 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.zh.md diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 311240e645..a7352b5dd7 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -17,8 +17,8 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 14be185017..ac293e8017 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -289,6 +289,55 @@ describe('the shipped Web composition', () => { expect(ctx.agentPresets.defaultId).toBe('standard') }) + it.each(['standard', 'cordis', 'ptc'] as const)('keeps interruption disabled in %s with Team mode off or on', async (preset) => { + const handle = await ctx.agents.create({ + sessionId: SessionId(`preset-interrupt-${preset}`), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, preset).then(() => undefined), + }) + try { + for (const enabled of [false, true]) { + await ctx.agentTeams.setEnabled(handle.agent, enabled) + const names = toolNames(ctx, handle.agent) + expect(names).not.toContain('interrupt_agent') + expect(names).toEqual(expect.arrayContaining(['send_message', 'list_agents', 'wait_agent'])) + } + } finally { + await handle.dispose() + } + }) + + it.each(['standard', 'cordis', 'ptc'] as const)('restores interruption through a complete %s declaration override', async (preset) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-interrupt-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const declaration = composeEntries([webPatches('interrupt migration')]) + .find(row => row.id === `preset-${preset}`)!.config as { + id: string + order: number + plugins: import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] + } + const configured = await bootWeb(root, [{ + id: `preset-${preset}`, + config: { + ...declaration, + plugins: composeEntries([[{ insert: declaration.plugins }, { + id: 'tool-subagent-control', config: { interrupt: true }, + }]]), + }, + }]) + try { + const handle = await configured.agents.create({ + sessionId: SessionId(`preset-interrupt-enabled-${preset}`), + setup: agentCtx => configured.agentPresets.mount(agentCtx, preset).then(() => undefined), + }) + try { + expect(toolNames(configured)).not.toContain('interrupt_agent') + expect(toolNames(configured, handle.agent)).toEqual(expect.arrayContaining([ + 'interrupt_agent', 'send_message', 'list_agents', 'wait_agent', + ])) + } finally { await handle.dispose() } + } finally { await configured.fiber.dispose() } + }) + it('composes the full agent from `standard`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-standard'), @@ -302,7 +351,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'office_convert', 'office_recalculate', 'office_render', 'plugin_manager', 'present', 'read', 'read_image', + 'get_goal', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'office_convert', 'office_recalculate', 'office_render', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'send_message', 'skill', 'subagent', 'subagent_fork', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 36b74d0e88..35a08e5fb2 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -479,7 +479,6 @@ const EXPECTED_TOOLS = [ 'edit', 'exit_plan_mode', 'get_goal', - 'interrupt_agent', 'job_kill', 'job_list', 'job_output', diff --git a/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.i18n.yaml new file mode 100644 index 0000000000..fb72a12add --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.md +/: + en: fda192f58c50cd90 + zh: 2ebe5d7389eb9689 +/built-in-configurations-disable-agent-interruption: + en: c253388db5ba6f5a + zh: af138bbc7bfd31b4 +/built-in-configurations-disable-agent-interruption/change: + en: 76b23fa746332e06 + zh: d30315eab3aa53bd +/built-in-configurations-disable-agent-interruption/migration: + en: 0f5bd67305b455dc + zh: a98770b64686d612 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.md new file mode 100644 index 0000000000..ddcd5a2149 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.md @@ -0,0 +1,27 @@ +--- +kind: upgrade-guide +description: "Built-in configurations disable interrupt_agent for subagents and Team mode." +--- + +# Built-in configurations disable agent interruption + +English | [中文](guide.zh.md) + +## Change + +The base bundle and the Web `standard`, `cordis`, and `ptc` presets now set `config.interrupt: false` on their `tool-subagent-control` rows. Models using these configurations no longer receive `interrupt_agent`, including in Team mode. Messaging, discovery, inbox waiting, and the subagent service interruption API remain available. The plugin's own default remains `true`; independent compositions that omit the setting retain interruption. + +## Migration + +Keep `interrupt: false` to retain the built-in behavior. To restore interruption: + +1. Edit `$DSH_HOME/profiles//cordis.patch.yml`. For base-backed profiles such as `headless`, `sdk`, and `acp`, append: + + ```yaml + - id: tool-subagent-control + config: + interrupt: true + ``` + +2. For `web` or `desktop`, instead override each required declaration: `preset-standard`, `preset-cordis`, or `preset-ptc`. Copy that declaration's complete `config` from the installed `@deepseek-ai/dsh-web-app` bundle's `presets/.patch.yml` (source: [standard](../../../../packages/bundle/web-app/presets/standard.patch.yml), [cordis](../../../../packages/bundle/web-app/presets/cordis.patch.yml), [ptc](../../../../packages/bundle/web-app/presets/ptc.patch.yml)). If your profile already overrides the declaration, edit that override to preserve your customizations. A patch replaces the whole `config`: retain `id`, `order`, and the entire `plugins` list, including the `delegation` group. Within that group's `config`, change the `tool-subagent-control` row's `config.interrupt` to `true`. Use the declaration's outer ID as the patch target, following [Change a shipped preset](../../../../packages/preset/agent-preset/skills/dsh-doc/references/compositions.md#change-a-shipped-preset). A top-level patch targeting `tool-subagent-control` does not reach the preset's child row; it only changes the disabled Host row. Leave that Host row disabled. +3. Restart the profile and create a new Session using the changed preset; existing Sessions retain their preset revision. Confirm that the model's tool definitions include `interrupt_agent`, `send_message`, `list_agents`, and `wait_agent`. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.zh.md new file mode 100644 index 0000000000..7f9455936a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.zh.md @@ -0,0 +1,27 @@ +--- +kind: upgrade-guide +description: "内置配置关闭普通 subagent 和 Team 模式的 interrupt_agent。" +--- + +# 内置配置关闭 agent 中断 + +[English](guide.md) | 中文 + +## 变更 + +base 组合包及 Web 的 `standard`、`cordis`、`ptc` preset 现在为各自的 `tool-subagent-control` 行设置 `config.interrupt: false`。使用这些配置的模型不再获得 `interrupt_agent`,也包括 Team 模式。消息、发现、收件箱等待和 subagent 服务的中断 API 仍然可用。插件自身的默认值仍为 `true`;省略该设置的独立组合仍提供中断工具。 + +## 迁移 + +保留 `interrupt: false` 可维持内置行为。如需恢复中断工具: + +1. 编辑 `$DSH_HOME/profiles//cordis.patch.yml`。对于 `headless`、`sdk`、`acp` 等基于 base 的 profile,追加: + + ```yaml + - id: tool-subagent-control + config: + interrupt: true + ``` + +2. 对于 `web` 或 `desktop`,改为覆盖需要启用的声明行:`preset-standard`、`preset-cordis` 或 `preset-ptc`。从已安装的 `@deepseek-ai/dsh-web-app` 组合包的 `presets/.patch.yml` 中复制该声明的完整 `config`(源码:[standard](../../../../packages/bundle/web-app/presets/standard.patch.yml)、[cordis](../../../../packages/bundle/web-app/presets/cordis.patch.yml)、[ptc](../../../../packages/bundle/web-app/presets/ptc.patch.yml))。如果 profile 已经覆盖该声明,编辑已有覆盖以保留自定义配置。patch 会替换整个 `config`:保留 `id`、`order` 和完整的 `plugins` 列表,包括 `delegation` 组。在该组的 `config` 内,将 `tool-subagent-control` 行的 `config.interrupt` 改为 `true`。以声明的外层 ID 为 patch 目标,写法参见[修改内置 preset](../../../../packages/preset/agent-preset/skills/dsh-doc/references/compositions.md#change-a-shipped-preset)。顶层以 `tool-subagent-control` 为目标的 patch 无法修改 preset 内的子行,只会修改已禁用的 Host 行。保持该 Host 行禁用。 +3. 重启 profile,并使用修改后的 preset 创建新 Session;已有 Session 保留原 preset 修订。确认模型的工具定义包含 `interrupt_agent`、`send_message`、`list_agents` 和 `wait_agent`。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index b475ce9b82..bd859ea112 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0f971f7ac7c09139 zh: ceb866da681c709c /deepseek-ai-dsh-base/use-this-package/what-you-get: - en: ab2fb279cdd726c5 - zh: ae84c61bddc00ddf + en: dd7e69c32d06cb6a + zh: 9301140a8e023b41 /deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform: en: 2dce12f82d6ee955 zh: d4599eb899a3cd88 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 5b5f163694..b2ab5dcf1e 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -53,6 +53,8 @@ Both `subagent` and `subagent_fork` create managed children and return the child The bundle also mounts [Agent Teams](../../team/agent-team/README.md) and its task tools. Team mode defaults off per root Session; the root conversation’s **Use Agent Teams** menu switch enables collaboration for that tree without changing the profile. +The `tool-subagent-control` row sets `interrupt: false`, so ordinary subagents and Team mode omit `interrupt_agent`. Set `config.interrupt: true` on that row in a later patch to enable it. The plugin itself still defaults to `true`. + The built-in `working_directory` tool changes the directory for subsequent operations while existing processes and write permissions remain independent. Current directory information appears in user context. Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: ```yaml diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 62d6ad03b9..223b88bb0a 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -53,6 +53,8 @@ kind: "package-bundle" 此 bundle 还挂载 [Agent Teams](../../team/agent-team/README.zh.md) 及其任务工具。每个根 Session 的 Team 模式默认关闭;根会话菜单中的**使用智能体团队**开关为当前树开启协作,无需修改 profile。 +`tool-subagent-control` 行显式设置 `interrupt: false`,因此普通 subagent 和 Team 模式均不提供 `interrupt_agent`。在后续 patch 中为该行设置 `config.interrupt: true` 可启用它。插件自身的默认值仍为 `true`。 + 内置 `working_directory` 工具改变后续操作使用的目录,已有进程与写权限保持独立。当前目录信息出现在用户上下文中。默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: ```yaml diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 26082074f3..18286618f9 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -385,6 +385,8 @@ # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' + config: + interrupt: false - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index cef33b6aac..1a650beec8 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -21,8 +21,8 @@ en: e3cac911f080d8ef zh: 60749e9f5c39c1bc /deepseek-ai-dsh-web-app/use-this-package/configuration: - en: b1787804544f2587 - zh: ce09d2c248ffd999 + en: f876c60a37e3cb0e + zh: 42a535554fe4bc40 /deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments: en: e6ddea1d2fc40501 zh: ff08b6fa3fc24136 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index d80b45463d..aed6c22d91 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -61,6 +61,8 @@ The `minimal` agent preset uses a persistent shell, the `read_image` tool, and a The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them. +The `standard`, `cordis`, and `ptc` presets set `config.interrupt: false` on their `tool-subagent-control` rows. To expose `interrupt_agent`, including in Team mode, override the relevant preset declaration as described in the [migration steps](../../../docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.md); changing the disabled Host row does not change preset tools. + ### Listening, trust, and public deployments diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 8952c0d315..80b1fd2216 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -61,6 +61,8 @@ dsh --profile web --no-open --port 8080 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。 +`standard`、`cordis` 和 `ptc` 三个 preset 在各自的 `tool-subagent-control` 行中设置 `config.interrupt: false`。如需提供 `interrupt_agent`(也适用于 Team 模式),按[迁移步骤](../../../docs/upgrade-guide/v0.2.2-rc.1/subagent-interrupt-default/guide.zh.md)覆盖相应 preset 声明;修改已禁用的 Host 行不会改变 preset 的工具。 + ### 监听、信任与公开部署 diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml index d0c9f7f419..912fb7d3ac 100644 --- a/packages/bundle/web-app/presets/cordis.patch.yml +++ b/packages/bundle/web-app/presets/cordis.patch.yml @@ -90,6 +90,8 @@ config: - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' + config: + interrupt: false - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 86ccc4f0ad..7a9329e5db 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -91,6 +91,8 @@ config: - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' + config: + interrupt: false - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index d0bca7e336..a3532d0f3a 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -94,6 +94,8 @@ config: - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' + config: + interrupt: false - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/team/agent-team/README.i18n.yaml b/packages/team/agent-team/README.i18n.yaml index 2f1733cd95..3d527874a8 100644 --- a/packages/team/agent-team/README.i18n.yaml +++ b/packages/team/agent-team/README.i18n.yaml @@ -33,8 +33,8 @@ en: 27e822116b00ca32 zh: b49fa665c72692bf /deepseek-ai-dsh-agent-team/use-this-package/waiting-and-interruption: - en: 3c43a806d94f9294 - zh: 04b04e009759c9ce + en: 2d266b76e6fea2a7 + zh: 4c829e65ba77e406 /deepseek-ai-dsh-agent-team/use-this-package/what-success-and-failure-look-like: en: 36814f2b06dc2359 zh: c46af795bc89942f diff --git a/packages/team/agent-team/README.md b/packages/team/agent-team/README.md index a58392c1c3..665cb0e487 100644 --- a/packages/team/agent-team/README.md +++ b/packages/team/agent-team/README.md @@ -80,7 +80,7 @@ File hints produce warnings when two in-progress tasks plan to touch overlapping The standard `wait_agent` tool waits for the exact caller’s next-step inbox and works without Team mode; see [subagent controls](../../subagent/tool-subagent-control/README.md#wait_agent). Task changes and Team mode changes do not end waits, add inbox input, or start idle Agents. Use `send_message` when an Agent needs to act on an update. -An ancestor stops a descendant’s current turn with the standard `interrupt_agent` tool. Interruption keeps unclaimed queued messages and leaves task ownership unchanged. +When the [subagent control plugin](../../subagent/tool-subagent-control/README.md#use-this-package) enables `config.interrupt`, an ancestor can stop a descendant’s current turn with `interrupt_agent`. Built-in configurations disable this tool by default. Interruption keeps unclaimed queued messages and leaves task ownership unchanged. ### What success and failure look like diff --git a/packages/team/agent-team/README.zh.md b/packages/team/agent-team/README.zh.md index 9e57943fa1..4e8db32d87 100644 --- a/packages/team/agent-team/README.zh.md +++ b/packages/team/agent-team/README.zh.md @@ -80,7 +80,7 @@ kind: "package-reference" 标准 `wait_agent` 工具等待当前调用者的下一步收件箱,无需 Team 模式即可使用;参见 [subagent 控制工具](../../subagent/tool-subagent-control/README.zh.md#wait_agent)。任务变化和 Team 模式切换不会结束等待、添加收件箱输入或启动空闲 Agent。需要 Agent 处理更新时使用 `send_message`。 -祖先通过标准 `interrupt_agent` 工具停止后代的当前轮次。中断保留尚未认领的排队消息,也不改变任务归属。 +当 [subagent 控制插件](../../subagent/tool-subagent-control/README.zh.md#use-this-package)启用 `config.interrupt` 时,祖先可通过 `interrupt_agent` 停止后代的当前轮次。内置配置默认关闭此工具。中断保留尚未认领的排队消息,也不改变任务归属。 ### 成功与失败的表现 diff --git a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json index 91ecd5ad13..fd5e22c70b 100644 --- a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json +++ b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json @@ -52,7 +52,6 @@ "get_goal", "glob", "grep", - "interrupt_agent", "job_kill", "job_list", "job_output", @@ -116,7 +115,6 @@ "get_goal", "glob", "grep", - "interrupt_agent", "job_kill", "job_list", "job_output", @@ -195,7 +193,6 @@ "get_goal", "glob", "grep", - "interrupt_agent", "job_kill", "job_list", "job_output", diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index e1d6aa4ee7..3c3f4d2cc9 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/command-attachments/tool-schemas.expected.json b/snapshots/sdk/command-attachments/tool-schemas.expected.json index 61d3f590fb..85bc6d2cf7 100644 --- a/snapshots/sdk/command-attachments/tool-schemas.expected.json +++ b/snapshots/sdk/command-attachments/tool-schemas.expected.json @@ -266,22 +266,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/desktop-context/tool-schemas.expected.json b/snapshots/sdk/desktop-context/tool-schemas.expected.json index 5f29906dab..1209423422 100644 --- a/snapshots/sdk/desktop-context/tool-schemas.expected.json +++ b/snapshots/sdk/desktop-context/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md b/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md index 5e1feff690..98d3bca7f1 100644 --- a/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md +++ b/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md @@ -142,11 +142,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -493,9 +488,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl b/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl index 463007668f..252ed9695e 100644 --- a/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl +++ b/snapshots/sdk/dynamic-tool-updates/notifications.expected.jsonl @@ -7,7 +7,7 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":12,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":13,"time":0,"data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} @@ -15,14 +15,14 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_task_read"},"toolCallId":"call_task_read","content":[{"type":"text","text":"{{cwd}}/task.txt\nfile\n\n1: Reply with the single word DONE after reading this file.\n\n(End of file - total 1 lines)\n"}],"isError":false,"id":"{{sessionId}}"},"meta":{"path":"{{cwd}}/task.txt","offset":1,"lines":[{"number":1,"text":"Reply with the single word DONE after reading this file."}],"totalLines":1}},"sourceEventSeqs":[15],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":19,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":19,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"developer/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":19},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":26,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":26,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"developer/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{sessionId}}"}},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":3}}}} diff --git a/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl b/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl index 6818a03e2c..9731e89b0b 100644 --- a/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl +++ b/snapshots/sdk/dynamic-tool-updates/session.v4.jsonl @@ -10,7 +10,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1793796109297,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793796109297,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1793796109297,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1793796109297,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1793796109297,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -18,14 +18,14 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_task_read"},"toolCallId":"call_task_read","content":[{"type":"text","text":"{{cwd}}/task.txt\nfile\n\n1: Reply with the single word DONE after reading this file.\n\n(End of file - total 1 lines)\n"}],"isError":false,"id":"{{message:6}}"},"meta":{"path":"{{cwd}}/task.txt","offset":1,"lines":[{"number":1,"text":"Reply with the single word DONE after reading this file."}],"totalLines":1}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1793796109316,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793796109316,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1793796109316,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1793796109316,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:10}}"}},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1793796109334,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793796109334,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793796109334,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793796109334,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1793796109334,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} diff --git a/snapshots/sdk/plan-review/session.v4.jsonl b/snapshots/sdk/plan-review/session.v4.jsonl index 5911c994ea..064eee20d6 100644 --- a/snapshots/sdk/plan-review/session.v4.jsonl +++ b/snapshots/sdk/plan-review/session.v4.jsonl @@ -11,7 +11,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}]},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply SDK_PLAN_INITIAL without tools.","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":14}} @@ -29,7 +29,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:9}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}} {"type":"user/message","data":{"content":[{"type":"text","text":"The user switched this session to plan mode."}],"source":{"kind":"plan-mode","form":"notice","summary":"The user switched this session to plan mode."},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Prepare a plan through exit_plan_mode; revise after feedback and acknowledge approval."}],"source":{"kind":"user"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} {"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"exit_plan_mode"}],"role":"developer","id":"{{message:10}}"},"headerSeq":30},"surfaceOp":"append"} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":31}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"commands-tool-1","name":"exit_plan_mode","arguments":"{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}},"id":"{{message:11}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-1","name":"exit_plan_mode","args":[""]},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-1","args":["{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"commands-tool-1","name":"exit_plan_mode","arguments":"{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} @@ -45,7 +45,7 @@ {"type":"plan/mode","data":{"active":false}} {"type":"step/start","data":{"turn":2,"step":3}} {"type":"system/message","data":{"turn":2,"step":3,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:15}}"}},"sourceEventSeqs":[27],"surfaceOp":{"op":"replace","startSeq":27,"endSeq":27}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"series"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"series"}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":46}} {"type":"assistant/message","data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_PLAN_APPROVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:16}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_PLAN_APPROVED"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_PLAN_APPROVED"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":3}} @@ -62,7 +62,7 @@ {"type":"system/message","data":{"turn":3,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:19}}"}},"sourceEventSeqs":[45],"surfaceOp":{"op":"replace","startSeq":45,"endSeq":45}} {"type":"user/message","data":{"content":[{"type":"text","text":"The user switched this session to plan mode."}],"source":{"kind":"plan-mode","form":"notice","summary":"The user switched this session to plan mode."},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Present another plan for review."}],"source":{"kind":"user"},"role":"user","id":"{{message:18}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"series"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"series"}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":63}} {"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"commands-tool-4","name":"exit_plan_mode","arguments":"{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}},"id":"{{message:20}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-4","name":"exit_plan_mode","args":[""]},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-4","args":["{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"commands-tool-4","name":"exit_plan_mode","arguments":"{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":3,"step":1,"callId":"commands-tool-4","name":"exit_plan_mode","arguments":"{\"plan\": \"# Implement the change\\n\\n- Inspect existing code.\\n- Make the smallest change.\\n- Run focused tests.\"}"}} @@ -81,7 +81,7 @@ {"type":"system/message","data":{"turn":4,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:24}}"}},"sourceEventSeqs":[60],"surfaceOp":{"op":"replace","startSeq":60,"endSeq":60}} {"type":"user/message","data":{"content":[{"type":"text","text":"The user switched this session back to the default mode."}],"source":{"kind":"plan-mode","form":"notice","summary":"The user switched this session back to the default mode."},"role":"user","id":"{{message:22}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply SDK_PLAN_OK without tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:23}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"series"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"series"}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":82}} {"type":"assistant/message","data":{"turn":4,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_PLAN_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:25}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_PLAN_OK"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_PLAN_OK"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":4,"step":1}} diff --git a/snapshots/sdk/plan-review/tool-schemas.expected.json b/snapshots/sdk/plan-review/tool-schemas.expected.json index 13159a8596..07f6cdbbe1 100644 --- a/snapshots/sdk/plan-review/tool-schemas.expected.json +++ b/snapshots/sdk/plan-review/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -1208,18 +1192,942 @@ } }, { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", + "name": "job_kill", + "description": "Request cancellation of a running background job.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job: output since the previous read for stream jobs, or the result of a finished final-output job.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry names, descriptions, and status. A descendant's name is its path from the root, such as reviewer/checker. You can message any other agent in the subagent tree. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. corrupt, unsupported, or unavailable means this row's own descendants could not be read; the row itself is still a member you can message. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "plugin_manager", + "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Management operation.", + "enum": [ + "list_plugins", + "list_bundles", + "set_plugin", + "set_bundle", + "install_bundle", + "remove_bundle", + "list_version_exemptions", + "set_version_exemption" + ] + }, + "target": { + "type": "string", + "description": "Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle." + }, + "enabled": { + "type": "boolean", + "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." + }, + "runtimeVersion": { + "type": "string", + "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." + }, + "acceptRisk": { + "type": "boolean", + "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." + }, + "approvedBuilds": { + "type": "array", + "description": "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.", + "items": { + "type": "string" + } + }, + "registry": { + "type": "string", + "description": "For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable." + }, + "offset": { + "type": "number", + "description": "Zero-based list offset; defaults to 0." + }, + "limit": { + "type": "number", + "description": "List page size, from 1 to 100; defaults to 25." + } + }, + "required": [ + "action" + ] + } + }, + { + "name": "present", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "parameters": { + "type": "object", + "properties": { + "files": { + "type": "array", + "description": "Usually the 1-2 most important deliverables; at most 4 per call.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Path of an existing regular file. Relative paths use the Session working directory." + }, + "description": { + "type": "string", + "description": "Brief description for the user." + } + }, + "required": [ + "path" + ] + } + } + }, + "required": [ + "files" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "schedule_create", + "description": "Create a scheduled task. At each occurrence of rrule, prompt is delivered to the target session as a scheduled message from the user. The result includes the task id and the next occurrence as scheduledAt in UTC and scheduledAtLocal in the zone of rrule. After downtime, only the latest missed occurrence is delivered. Delivery can repeat after a crash.", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes." + }, + "prompt": { + "type": "string", + "description": "Instruction delivered at each occurrence." + }, + "rrule": { + "type": "string", + "description": "Supported RFC 5545 subset: one DTSTART line, a newline, and one RRULE line. DTSTART ends in Z for UTC or carries TZID with an IANA time zone; a fixed offset such as UTC+5 is Etc/GMT-5, with the sign reversed. DTSTART must name an existing local time. A TZID rule keeps local clock time across daylight-saving changes; generated nonexistent local times are skipped, and repeated ones occur once, at the earlier instant. DTSTART may be in the past; delivery starts with the first occurrence after creation. A one-time delivery is RRULE:FREQ=DAILY;COUNT=1. No SECONDLY; BYSECOND takes one value. COUNT is at most 1000; use UNTIL for longer rules. MINUTELY and HOURLY accept no BY parts, so select clock times with DAILY or WEEKLY plus BYHOUR and BYMINUTE. BYMONTHDAY and ordinal BYDAY require MONTHLY or YEARLY; BYYEARDAY and BYWEEKNO require YEARLY. Ordinal BYDAY cannot combine with BYWEEKNO. Each FREQ period allows at most 10080 candidates before BYSETPOS, which accepts at most 31 values. UNTIL is a UTC instant ending in Z. Example: \"DTSTART;TZID=Asia/Singapore:20260901T080000\\nRRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1\"" + }, + "target": { + "type": "string", + "description": "this_session delivers to this session; new_once delivers to the same new session every time, opened at the first delivery; new opens a different new session for every delivery. New sessions use this session's working directory and preset. With this_session or new_once, a delivery waits until the previous one finishes, and only the latest occurrence that passed meanwhile is delivered. Use this_session for reminders and for follow-ups to this conversation; use new_once or new for independent work, such as a recurring report, and whenever the user asks for a new conversation.", + "enum": [ + "this_session", + "new_once", + "new" + ] + } + }, + "required": [ + "title", + "prompt", + "rrule", + "target" + ] + } + }, + { + "name": "schedule_edit", + "description": "Update, pause, resume, or delete one scheduled task by id. Deleting does not retract queued messages.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Task id." + }, + "action": { + "type": "string", + "description": "update applies the provided fields, at least one; delete removes the task.", + "enum": [ + "update", + "delete" + ] + }, + "title": { + "type": "string", + "description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes." + }, + "prompt": { + "type": "string", + "description": "New instruction delivered at each occurrence." + }, + "rrule": { + "type": "string", + "description": "New recurrence in the schedule_create rrule format." + }, + "target": { + "type": "string", + "description": "Omit to retain the destination and its creation settings. Providing target replaces them; new_once reserves a different Session. New targets capture this Session's working directory and preset. Choices are the same as schedule_create.", + "enum": [ + "this_session", + "new_once", + "new" + ] + }, + "active": { + "type": "boolean", + "description": "false pauses the task; true resumes only if its timing has a future occurrence. Otherwise provide a new rrule." + }, + "expectedUpdatedAt": { + "type": "string", + "description": "updatedAt from the latest result that returned this task; if the task changed after that result, the result is schedule_conflict and nothing changes." + } + }, + "required": [ + "id", + "action" + ] + } + }, + { + "name": "schedule_list", + "description": "List scheduled tasks, including tasks created from other sessions. Active tasks come first, ordered by scheduledAt.", + "parameters": { + "type": "object", + "properties": { + "active": { + "type": "boolean", + "description": "true for active tasks only; false for paused and ended tasks only; omitted for all tasks." + }, + "limit": { + "type": "integer", + "description": "Page size from 1 to 500; defaults to 100." + }, + "offset": { + "type": "integer", + "description": "Number of tasks to skip; defaults to 0." + } + } + } + }, + { + "name": "schedule_view", + "description": "View one scheduled task by id.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Task id." + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation.", "parameters": { "type": "object", "properties": { "agent": { "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." + "description": "The name of another agent in your subagent tree, as shown by list_agents." + }, + "message": { + "type": "string", + "description": "The message to deliver to the agent." } }, "required": [ - "agent" + "agent", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for a skill. Call it before acting on a task that names or clearly matches a skill in the session skill catalog.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its name. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes.", + "parameters": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Initial child working directory. Relative paths use your current directory; omitted inherits it. Later directory changes in either agent are independent." + }, + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." + }, + "name": { + "type": "string", + "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its name. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes.", + "parameters": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Initial child working directory. Relative paths use your current directory; omitted inherits it. Later directory changes in either agent are independent." + }, + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." + }, + "name": { + "type": "string", + "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a task list to plan multi-step work and show progress; skip it for trivial single-step tasks. Add one todo per concrete step before you start. While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. Mark each todo `completed` as soon as it is done.", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the current goal.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "wait_agent", + "description": "Wait for input at your next step, including agent messages, completion notifications, and user steering. Returns immediately if new input is pending. `timedOut: false` means new input is pending. Pending input is delivered to the model after code execution finishes.", + "parameters": { + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } + } + }, + { + "name": "web_fetch", + "description": "Fetch the content of a specific HTTP(S) URL and return it decoded to text.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The HTTP(S) URL to fetch." + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "1–10 search queries; their results are merged.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return `; the JSON-serializable value is this tool's result." + }, + "meta": { + "type": "object", + "description": "The workflow identity as plain JSON, not code.", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice." + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "working_directory", + "description": "Read the current working directory, or change it with cd. Relative paths use the current directory. Existing shells and running processes keep their own directories.", + "parameters": { + "type": "object", + "properties": { + "cd": { + "type": "string", + "description": "Existing directory to enter. Omit to read the current directory." + } + } + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend. Provide `file_path` before `content` in the arguments." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The narrowest wider sandbox mode for a one-shot retry of the exact operation the sandbox just denied; the retry asks the user for approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + [ + { + "name": "apply_patch", + "description": "Create, update, delete, or move UTF-8 text files with one patch; prefer it over multiple edit calls when a change spans several files or separated regions.", + "parameters": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory." + }, + "sandbox_permissions": { + "type": "string", + "description": "The narrowest wider sandbox mode for a one-shot retry of the exact operation the sandbox just denied; the retry asks the user for approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request." + } + }, + "required": [ + "input" + ] + } + }, + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. An answer item with empty selected and no custom means the user skipped that question: continue without it, do not ask it again, and do not treat the skip as permission.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Provide `description` before `command` in the arguments. Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The narrowest wider sandbox mode for a one-shot retry of the exact command the sandbox just denied; the retry asks the user for approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. Use the language of the user’s current request." + } + }, + "required": [ + "description", + "command" + ] + } + }, + { + "name": "create_goal", + "description": "Create a persisted goal that keeps this session working across automatic continuation rounds. Use it when the direct human request is a long-running objective, even if the user did not say \"goal\"; not for single-turn work.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend. Provide `file_path` before `old_string` and `new_string` in the arguments." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The narrowest wider sandbox mode for a one-shot retry of the exact operation the sandbox just denied; the retry asks the user for approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current session goal, including the id and revision that update_goal requires.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result keeps the first paths and reports where the complete list was saved.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" ] } }, @@ -2163,22 +3071,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -3119,978 +4011,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, - { - "name": "job_kill", - "description": "Request cancellation of a running background job.", - "parameters": { - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "Job id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the job." - } - }, - "required": [ - "job_id" - ] - } - }, - { - "name": "job_list", - "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "job_output", - "description": "Read a background job: output since the previous read for stream jobs, or the result of a finished final-output job.", - "parameters": { - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "Job id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration." - } - }, - "required": [ - "job_id" - ] - } - }, - { - "name": "list_agents", - "description": "List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry names, descriptions, and status. A descendant's name is its path from the root, such as reviewer/checker. You can message any other agent in the subagent tree. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. corrupt, unsupported, or unavailable means this row's own descendants could not be read; the row itself is still a member you can message. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "plugin_manager", - "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", - "parameters": { - "type": "object", - "properties": { - "action": { - "type": "string", - "description": "Management operation.", - "enum": [ - "list_plugins", - "list_bundles", - "set_plugin", - "set_bundle", - "install_bundle", - "remove_bundle", - "list_version_exemptions", - "set_version_exemption" - ] - }, - "target": { - "type": "string", - "description": "Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle." - }, - "enabled": { - "type": "boolean", - "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." - }, - "runtimeVersion": { - "type": "string", - "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." - }, - "acceptRisk": { - "type": "boolean", - "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." - }, - "approvedBuilds": { - "type": "array", - "description": "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.", - "items": { - "type": "string" - } - }, - "registry": { - "type": "string", - "description": "For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable." - }, - "offset": { - "type": "number", - "description": "Zero-based list offset; defaults to 0." - }, - "limit": { - "type": "number", - "description": "List page size, from 1 to 100; defaults to 25." - } - }, - "required": [ - "action" - ] - } - }, - { - "name": "present", - "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", - "parameters": { - "type": "object", - "properties": { - "files": { - "type": "array", - "description": "Usually the 1-2 most important deliverables; at most 4 per call.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "path": { - "type": "string", - "description": "Path of an existing regular file. Relative paths use the Session working directory." - }, - "description": { - "type": "string", - "description": "Brief description for the user." - } - }, - "required": [ - "path" - ] - } - } - }, - "required": [ - "files" - ] - } - }, - { - "name": "read", - "description": "Read a UTF-8 text file and return line-numbered content.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to read, resolved by the filesystem backend." - }, - "offset": { - "type": "number", - "description": "1-based first line to return. Defaults to 1." - }, - "limit": { - "type": "number", - "description": "Maximum number of lines to return. Defaults to 2000." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "read_image", - "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to the image file, resolved by the filesystem backend." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "schedule_create", - "description": "Create a scheduled task. At each occurrence of rrule, prompt is delivered to the target session as a scheduled message from the user. The result includes the task id and the next occurrence as scheduledAt in UTC and scheduledAtLocal in the zone of rrule. After downtime, only the latest missed occurrence is delivered. Delivery can repeat after a crash.", - "parameters": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes." - }, - "prompt": { - "type": "string", - "description": "Instruction delivered at each occurrence." - }, - "rrule": { - "type": "string", - "description": "Supported RFC 5545 subset: one DTSTART line, a newline, and one RRULE line. DTSTART ends in Z for UTC or carries TZID with an IANA time zone; a fixed offset such as UTC+5 is Etc/GMT-5, with the sign reversed. DTSTART must name an existing local time. A TZID rule keeps local clock time across daylight-saving changes; generated nonexistent local times are skipped, and repeated ones occur once, at the earlier instant. DTSTART may be in the past; delivery starts with the first occurrence after creation. A one-time delivery is RRULE:FREQ=DAILY;COUNT=1. No SECONDLY; BYSECOND takes one value. COUNT is at most 1000; use UNTIL for longer rules. MINUTELY and HOURLY accept no BY parts, so select clock times with DAILY or WEEKLY plus BYHOUR and BYMINUTE. BYMONTHDAY and ordinal BYDAY require MONTHLY or YEARLY; BYYEARDAY and BYWEEKNO require YEARLY. Ordinal BYDAY cannot combine with BYWEEKNO. Each FREQ period allows at most 10080 candidates before BYSETPOS, which accepts at most 31 values. UNTIL is a UTC instant ending in Z. Example: \"DTSTART;TZID=Asia/Singapore:20260901T080000\\nRRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1\"" - }, - "target": { - "type": "string", - "description": "this_session delivers to this session; new_once delivers to the same new session every time, opened at the first delivery; new opens a different new session for every delivery. New sessions use this session's working directory and preset. With this_session or new_once, a delivery waits until the previous one finishes, and only the latest occurrence that passed meanwhile is delivered. Use this_session for reminders and for follow-ups to this conversation; use new_once or new for independent work, such as a recurring report, and whenever the user asks for a new conversation.", - "enum": [ - "this_session", - "new_once", - "new" - ] - } - }, - "required": [ - "title", - "prompt", - "rrule", - "target" - ] - } - }, - { - "name": "schedule_edit", - "description": "Update, pause, resume, or delete one scheduled task by id. Deleting does not retract queued messages.", - "parameters": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Task id." - }, - "action": { - "type": "string", - "description": "update applies the provided fields, at least one; delete removes the task.", - "enum": [ - "update", - "delete" - ] - }, - "title": { - "type": "string", - "description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes." - }, - "prompt": { - "type": "string", - "description": "New instruction delivered at each occurrence." - }, - "rrule": { - "type": "string", - "description": "New recurrence in the schedule_create rrule format." - }, - "target": { - "type": "string", - "description": "Omit to retain the destination and its creation settings. Providing target replaces them; new_once reserves a different Session. New targets capture this Session's working directory and preset. Choices are the same as schedule_create.", - "enum": [ - "this_session", - "new_once", - "new" - ] - }, - "active": { - "type": "boolean", - "description": "false pauses the task; true resumes only if its timing has a future occurrence. Otherwise provide a new rrule." - }, - "expectedUpdatedAt": { - "type": "string", - "description": "updatedAt from the latest result that returned this task; if the task changed after that result, the result is schedule_conflict and nothing changes." - } - }, - "required": [ - "id", - "action" - ] - } - }, - { - "name": "schedule_list", - "description": "List scheduled tasks, including tasks created from other sessions. Active tasks come first, ordered by scheduledAt.", - "parameters": { - "type": "object", - "properties": { - "active": { - "type": "boolean", - "description": "true for active tasks only; false for paused and ended tasks only; omitted for all tasks." - }, - "limit": { - "type": "integer", - "description": "Page size from 1 to 500; defaults to 100." - }, - "offset": { - "type": "integer", - "description": "Number of tasks to skip; defaults to 0." - } - } - } - }, - { - "name": "schedule_view", - "description": "View one scheduled task by id.", - "parameters": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Task id." - } - }, - "required": [ - "id" - ] - } - }, - { - "name": "send_message", - "description": "Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of another agent in your subagent tree, as shown by list_agents." - }, - "message": { - "type": "string", - "description": "The message to deliver to the agent." - } - }, - "required": [ - "agent", - "message" - ] - } - }, - { - "name": "skill", - "description": "Load the full instructions for a skill. Call it before acting on a task that names or clearly matches a skill in the session skill catalog.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The exact skill name from the available skills list." - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its name. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes.", - "parameters": { - "type": "object", - "properties": { - "cwd": { - "type": "string", - "description": "Initial child working directory. Relative paths use your current directory; omitted inherits it. Later directory changes in either agent are independent." - }, - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." - }, - "name": { - "type": "string", - "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." - }, - "prompt": { - "type": "string", - "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its name. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes.", - "parameters": { - "type": "object", - "properties": { - "cwd": { - "type": "string", - "description": "Initial child working directory. Relative paths use your current directory; omitted inherits it. Later directory changes in either agent are independent." - }, - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." - }, - "name": { - "type": "string", - "description": "Optional short name for the subagent, unique among the subagents you create: lower-case letters, digits, and hyphens, starting with a letter, at most 32 characters; lead is reserved for the root agent. Omitted names default to agent-1, agent-2, and so on." - }, - "prompt": { - "type": "string", - "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "todo_write", - "description": "Record and update a task list to plan multi-step work and show progress; skip it for trivial single-step tasks. Add one todo per concrete step before you start. While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. Mark each todo `completed` as soon as it is done.", - "parameters": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The COMPLETE task list, replacing any previous list.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "content": { - "type": "string", - "description": "What the task is — a short imperative line." - }, - "status": { - "type": "string", - "description": "pending (not started) | in_progress (now) | completed (done).", - "enum": [ - "pending", - "in_progress", - "completed" - ] - } - }, - "required": [ - "content", - "status" - ] - } - } - }, - "required": [ - "todos" - ] - } - }, - { - "name": "update_goal", - "description": "Update the current goal.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "name": "wait_agent", - "description": "Wait for input at your next step, including agent messages, completion notifications, and user steering. Returns immediately if new input is pending. `timedOut: false` means new input is pending. Pending input is delivered to the model after code execution finishes.", - "parameters": { - "type": "object", - "properties": { - "timeout_ms": { - "type": "integer", - "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." - } - } - } - }, - { - "name": "web_fetch", - "description": "Fetch the content of a specific HTTP(S) URL and return it decoded to text.", - "parameters": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "The HTTP(S) URL to fetch." - } - }, - "required": [ - "url" - ] - } - }, - { - "name": "web_search", - "description": "Search the web for current information. Returns an optional summary answer and a list of source URLs.", - "parameters": { - "type": "object", - "properties": { - "queries": { - "type": "array", - "description": "1–10 search queries; their results are merged.", - "items": { - "type": "string" - } - } - }, - "required": [ - "queries" - ] - } - }, - { - "name": "workflow", - "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return `; the JSON-serializable value is this tool's result." - }, - "meta": { - "type": "object", - "description": "The workflow identity as plain JSON, not code.", - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "description": "Short kebab-case workflow name." - }, - "description": { - "type": "string", - "description": "One-line description of what the workflow does." - }, - "whenToUse": { - "type": "string", - "description": "Optional guidance on when this workflow applies." - }, - "phases": { - "type": "array", - "description": "Optional phase declarations matched by phase() calls.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "title": { - "type": "string", - "description": "The phase title phase() calls match by exact string." - }, - "detail": { - "type": "string", - "description": "Optional one-line description of the phase." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "model": { - "type": "string", - "description": "Optional model override this phase is expected to use." - } - }, - "required": [ - "title" - ] - } - } - }, - "required": [ - "name", - "description" - ] - }, - "args": { - "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice." - } - }, - "required": [ - "script", - "meta" - ] - } - }, - { - "name": "working_directory", - "description": "Read the current working directory, or change it with cd. Relative paths use the current directory. Existing shells and running processes keep their own directories.", - "parameters": { - "type": "object", - "properties": { - "cd": { - "type": "string", - "description": "Existing directory to enter. Omit to read the current directory." - } - } - } - }, - { - "name": "write", - "description": "Create or fully replace a UTF-8 text file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to write, resolved by the filesystem backend. Provide `file_path` before `content` in the arguments." - }, - "content": { - "type": "string", - "description": "Full UTF-8 text content to write." - }, - "sandbox_permissions": { - "type": "string", - "description": "The narrowest wider sandbox mode for a one-shot retry of the exact operation the sandbox just denied; the retry asks the user for approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - [ - { - "name": "apply_patch", - "description": "Create, update, delete, or move UTF-8 text files with one patch; prefer it over multiple edit calls when a change spans several files or separated regions.", - "parameters": { - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "Patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory." - }, - "sandbox_permissions": { - "type": "string", - "description": "The narrowest wider sandbox mode for a one-shot retry of the exact operation the sandbox just denied; the retry asks the user for approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request." - } - }, - "required": [ - "input" - ] - } - }, - { - "name": "ask_user_question", - "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. An answer item with empty selected and no custom means the user skipped that question: continue without it, do not ask it again, and do not treat the skip as permission.", - "parameters": { - "type": "object", - "properties": { - "questions": { - "type": "array", - "description": "Questions to ask the user before continuing.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "id": { - "type": "string", - "description": "Stable id for this question; echoed in the answer." - }, - "question": { - "type": "string", - "description": "The specific question to ask the user." - }, - "header": { - "type": "string", - "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." - }, - "options": { - "type": "array", - "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "label": { - "type": "string", - "description": "Short user-facing option label." - }, - "description": { - "type": "string", - "description": "One sentence explaining the tradeoff or impact." - } - }, - "required": [ - "label" - ] - } - }, - "multi_select": { - "type": "boolean", - "description": "Whether the user may select more than one option. Defaults to false." - } - }, - "required": [ - "id", - "question" - ] - } - } - }, - "required": [ - "questions" - ] - } - }, - { - "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Provide `description` before `command` in the arguments. Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." - }, - "command": { - "type": "string", - "description": "The bash command to execute." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - }, - "run_in_background": { - "type": "boolean", - "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." - }, - "sandbox_permissions": { - "type": "string", - "description": "The narrowest wider sandbox mode for a one-shot retry of the exact command the sandbox just denied; the retry asks the user for approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. Use the language of the user’s current request." - } - }, - "required": [ - "description", - "command" - ] - } - }, - { - "name": "create_goal", - "description": "Create a persisted goal that keeps this session working across automatic continuation rounds. Use it when the direct human request is a long-running objective, even if the user did not say \"goal\"; not for single-turn work.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "edit", - "description": "Edit an existing UTF-8 text file by replacing literal text.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to edit, resolved by the filesystem backend. Provide `file_path` before `old_string` and `new_string` in the arguments." - }, - "old_string": { - "type": "string", - "description": "Literal text to replace." - }, - "new_string": { - "type": "string", - "description": "Literal replacement text. Use an empty string to delete the match." - }, - "replace_all": { - "type": "boolean", - "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." - }, - "sandbox_permissions": { - "type": "string", - "description": "The narrowest wider sandbox mode for a one-shot retry of the exact operation the sandbox just denied; the retry asks the user for approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "exit_plan_mode", - "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", - "parameters": { - "type": "object", - "properties": { - "plan": { - "type": "string", - "description": "The complete plan, as markdown, starting with a # heading that names it." - } - }, - "required": [ - "plan" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current session goal, including the id and revision that update_goal requires.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "glob", - "description": "Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result keeps the first paths and reports where the complete list was saved.", - "parameters": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." - }, - "path": { - "type": "string", - "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." - } - }, - "required": [ - "pattern" - ] - } - }, - { - "name": "grep", - "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved.", - "parameters": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Regular expression to search for (ripgrep syntax)." - }, - "path": { - "type": "string", - "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." - }, - "include": { - "type": "string", - "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." - } - }, - "required": [ - "pattern" - ] - } - }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-activation-limit/tool-schemas.1.expected.json b/snapshots/sdk/subagent-activation-limit/tool-schemas.1.expected.json index 53f6a4bf69..08fc35470a 100644 --- a/snapshots/sdk/subagent-activation-limit/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-activation-limit/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json index 53f6a4bf69..08fc35470a 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json index 53f6a4bf69..08fc35470a 100644 --- a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json index ccc6927ef0..81aaececa6 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json index 7429a130fa..1f122af658 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json index 62b13c9954..9fd1e04d56 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json index 53f6a4bf69..08fc35470a 100644 --- a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/subagent-send-message/tool-schemas.1.expected.json b/snapshots/sdk/subagent-send-message/tool-schemas.1.expected.json index 53f6a4bf69..08fc35470a 100644 --- a/snapshots/sdk/subagent-send-message/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-send-message/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/team-default-enabled/tool-schemas.1.expected.json b/snapshots/sdk/team-default-enabled/tool-schemas.1.expected.json index d1e5090032..52d309609b 100644 --- a/snapshots/sdk/team-default-enabled/tool-schemas.1.expected.json +++ b/snapshots/sdk/team-default-enabled/tool-schemas.1.expected.json @@ -222,22 +222,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/team-default-enabled/tool-schemas.expected.json b/snapshots/sdk/team-default-enabled/tool-schemas.expected.json index d1e5090032..52d309609b 100644 --- a/snapshots/sdk/team-default-enabled/tool-schemas.expected.json +++ b/snapshots/sdk/team-default-enabled/tool-schemas.expected.json @@ -222,22 +222,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/sdk/tool-error-details/notifications.expected.jsonl b/snapshots/sdk/tool-error-details/notifications.expected.jsonl index 675b8118eb..d96af775ab 100644 --- a/snapshots/sdk/tool-error-details/notifications.expected.jsonl +++ b/snapshots/sdk/tool-error-details/notifications.expected.jsonl @@ -7,7 +7,7 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Exercise a native denial and a caught PTC inner denial, then reply ERROR_DETAILS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":12,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":13,"time":0,"data":{"title":"Exercise a native denial and","messageSeqs":[8],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"denied-native","name":"bash","arguments":"{\"command\":\"echo AUTO_DENIED\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"denied-native","name":"bash","args":["{\"command\":\"echo AUTO_DENIED\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"denied-native","name":"bash","arguments":"{\"command\":\"echo AUTO_DENIED\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} @@ -17,7 +17,7 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"system/message","seq":19,"time":0,"data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":20,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["run_code"]},"reason":"change","startsSeries":true}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"developer/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"run_code"},{"type":"tool-removal","toolName":"apply_patch"},{"type":"tool-removal","toolName":"bash"},{"type":"tool-removal","toolName":"create_goal"},{"type":"tool-removal","toolName":"edit"},{"type":"tool-removal","toolName":"get_goal"},{"type":"tool-removal","toolName":"glob"},{"type":"tool-removal","toolName":"grep"},{"type":"tool-removal","toolName":"interrupt_agent"},{"type":"tool-removal","toolName":"job_kill"},{"type":"tool-removal","toolName":"job_list"},{"type":"tool-removal","toolName":"job_output"},{"type":"tool-removal","toolName":"list_agents"},{"type":"tool-removal","toolName":"read"},{"type":"tool-removal","toolName":"read_image"},{"type":"tool-removal","toolName":"send_message"},{"type":"tool-removal","toolName":"skill"},{"type":"tool-removal","toolName":"subagent"},{"type":"tool-removal","toolName":"subagent_fork"},{"type":"tool-removal","toolName":"todo_write"},{"type":"tool-removal","toolName":"update_goal"},{"type":"tool-removal","toolName":"wait_agent"},{"type":"tool-removal","toolName":"web_fetch"},{"type":"tool-removal","toolName":"web_search"},{"type":"tool-removal","toolName":"workflow"},{"type":"tool-removal","toolName":"working_directory"},{"type":"tool-removal","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":20},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"developer/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"run_code"},{"type":"tool-removal","toolName":"apply_patch"},{"type":"tool-removal","toolName":"bash"},{"type":"tool-removal","toolName":"create_goal"},{"type":"tool-removal","toolName":"edit"},{"type":"tool-removal","toolName":"get_goal"},{"type":"tool-removal","toolName":"glob"},{"type":"tool-removal","toolName":"grep"},{"type":"tool-removal","toolName":"job_kill"},{"type":"tool-removal","toolName":"job_list"},{"type":"tool-removal","toolName":"job_output"},{"type":"tool-removal","toolName":"list_agents"},{"type":"tool-removal","toolName":"read"},{"type":"tool-removal","toolName":"read_image"},{"type":"tool-removal","toolName":"send_message"},{"type":"tool-removal","toolName":"skill"},{"type":"tool-removal","toolName":"subagent"},{"type":"tool-removal","toolName":"subagent_fork"},{"type":"tool-removal","toolName":"todo_write"},{"type":"tool-removal","toolName":"update_goal"},{"type":"tool-removal","toolName":"wait_agent"},{"type":"tool-removal","toolName":"web_fetch"},{"type":"tool-removal","toolName":"web_search"},{"type":"tool-removal","toolName":"workflow"},{"type":"tool-removal","toolName":"working_directory"},{"type":"tool-removal","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":20},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"denied-code","name":"run_code","arguments":"{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"denied-code","name":"run_code","args":["{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"denied-code","name":"run_code","arguments":"{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"denied-code","name":"run_code","arguments":"{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/ptc-dispatch-start","seq":24,"time":0,"data":{"rootCallId":"denied-code","parentCallId":"denied-code","subCallId":"denied-code:ptc:1","name":"bash","arguments":{"command":"echo AUTO_DENIED"}}}}} diff --git a/snapshots/sdk/tool-error-details/session.v4.jsonl b/snapshots/sdk/tool-error-details/session.v4.jsonl index 5207c04c20..0095f09f67 100644 --- a/snapshots/sdk/tool-error-details/session.v4.jsonl +++ b/snapshots/sdk/tool-error-details/session.v4.jsonl @@ -10,7 +10,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Exercise a native denial and a caught PTC inner denial, then reply ERROR_DETAILS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Exercise a native denial and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"denied-native","name":"bash","arguments":"{\"command\":\"echo AUTO_DENIED\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"stream":[{"type":"chunk","time":1793796132469,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793796132470,"index":0,"dt":[],"id":"denied-native","name":"bash","args":["{\"command\":\"echo AUTO_DENIED\"}"]},{"type":"chunk","time":1793796132470,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"denied-native","name":"bash","arguments":"{\"command\":\"echo AUTO_DENIED\"}"}}},{"type":"chunk","time":1793796132470,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -20,7 +20,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"system/message","data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:7}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["run_code"]},"reason":"change","startsSeries":true}} -{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"run_code"},{"type":"tool-removal","toolName":"apply_patch"},{"type":"tool-removal","toolName":"bash"},{"type":"tool-removal","toolName":"create_goal"},{"type":"tool-removal","toolName":"edit"},{"type":"tool-removal","toolName":"get_goal"},{"type":"tool-removal","toolName":"glob"},{"type":"tool-removal","toolName":"grep"},{"type":"tool-removal","toolName":"interrupt_agent"},{"type":"tool-removal","toolName":"job_kill"},{"type":"tool-removal","toolName":"job_list"},{"type":"tool-removal","toolName":"job_output"},{"type":"tool-removal","toolName":"list_agents"},{"type":"tool-removal","toolName":"read"},{"type":"tool-removal","toolName":"read_image"},{"type":"tool-removal","toolName":"send_message"},{"type":"tool-removal","toolName":"skill"},{"type":"tool-removal","toolName":"subagent"},{"type":"tool-removal","toolName":"subagent_fork"},{"type":"tool-removal","toolName":"todo_write"},{"type":"tool-removal","toolName":"update_goal"},{"type":"tool-removal","toolName":"wait_agent"},{"type":"tool-removal","toolName":"web_fetch"},{"type":"tool-removal","toolName":"web_search"},{"type":"tool-removal","toolName":"workflow"},{"type":"tool-removal","toolName":"working_directory"},{"type":"tool-removal","toolName":"write"}],"role":"developer","id":"{{message:8}}"},"headerSeq":20},"surfaceOp":"append"} +{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"run_code"},{"type":"tool-removal","toolName":"apply_patch"},{"type":"tool-removal","toolName":"bash"},{"type":"tool-removal","toolName":"create_goal"},{"type":"tool-removal","toolName":"edit"},{"type":"tool-removal","toolName":"get_goal"},{"type":"tool-removal","toolName":"glob"},{"type":"tool-removal","toolName":"grep"},{"type":"tool-removal","toolName":"job_kill"},{"type":"tool-removal","toolName":"job_list"},{"type":"tool-removal","toolName":"job_output"},{"type":"tool-removal","toolName":"list_agents"},{"type":"tool-removal","toolName":"read"},{"type":"tool-removal","toolName":"read_image"},{"type":"tool-removal","toolName":"send_message"},{"type":"tool-removal","toolName":"skill"},{"type":"tool-removal","toolName":"subagent"},{"type":"tool-removal","toolName":"subagent_fork"},{"type":"tool-removal","toolName":"todo_write"},{"type":"tool-removal","toolName":"update_goal"},{"type":"tool-removal","toolName":"wait_agent"},{"type":"tool-removal","toolName":"web_fetch"},{"type":"tool-removal","toolName":"web_search"},{"type":"tool-removal","toolName":"workflow"},{"type":"tool-removal","toolName":"working_directory"},{"type":"tool-removal","toolName":"write"}],"role":"developer","id":"{{message:8}}"},"headerSeq":20},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"denied-code","name":"run_code","arguments":"{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"stream":[{"type":"chunk","time":1793796132486,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793796132486,"index":0,"dt":[],"id":"denied-code","name":"run_code","args":["{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"]},{"type":"chunk","time":1793796132486,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"denied-code","name":"run_code","arguments":"{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"}}},{"type":"chunk","time":1793796132486,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"denied-code","name":"run_code","arguments":"{\"code\":\"try { await tools.bash({command: \\\"echo AUTO_DENIED\\\"}) } catch (error) { return error.message }\",\"description\":\"Catch one denied inner call\"}"}} {"type":"tool/ptc-dispatch-start","data":{"rootCallId":"denied-code","parentCallId":"denied-code","subCallId":"denied-code:ptc:1","name":"bash","arguments":{"command":"echo AUTO_DENIED"}}} diff --git a/snapshots/sdk/tool-error-details/system-prompt.expected.md b/snapshots/sdk/tool-error-details/system-prompt.expected.md index 0e5cf03a8b..ccfe4750ea 100644 --- a/snapshots/sdk/tool-error-details/system-prompt.expected.md +++ b/snapshots/sdk/tool-error-details/system-prompt.expected.md @@ -157,11 +157,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -426,9 +421,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/sdk/tool-error-details/tool-schemas.expected.json b/snapshots/sdk/tool-error-details/tool-schemas.expected.json index d47fe772df..bf3d9439fe 100644 --- a/snapshots/sdk/tool-error-details/tool-schemas.expected.json +++ b/snapshots/sdk/tool-error-details/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/advanced-toolchain/session.v4.jsonl b/snapshots/session/advanced-toolchain/session.v4.jsonl index 2c3ad47d45..c1a09eb618 100644 --- a/snapshots/session/advanced-toolchain/session.v4.jsonl +++ b/snapshots/session/advanced-toolchain/session.v4.jsonl @@ -9,7 +9,7 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: list runtime inspect providers; discover providers through run_code when available; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; list providers again; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","cordis_inspect_list","cordis_inspect_query","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","cordis_inspect_list","cordis_inspect_query","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791315715021,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791315715022,"index":0,"dt":[],"id":"advanced-inspect-before","name":"cordis_inspect_list","args":["{}"]},{"type":"chunk","time":1791315715022,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-inspect-before","name":"cordis_inspect_list","arguments":"{}"}}},{"type":"chunk","time":1791315715022,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791315715022,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"system/message","data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:6}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["run_code"]},"reason":"change","startsSeries":true}} -{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"run_code"},{"type":"tool-removal","toolName":"apply_patch"},{"type":"tool-removal","toolName":"bash"},{"type":"tool-removal","toolName":"cordis_inspect_list"},{"type":"tool-removal","toolName":"cordis_inspect_query"},{"type":"tool-removal","toolName":"create_goal"},{"type":"tool-removal","toolName":"edit"},{"type":"tool-removal","toolName":"get_goal"},{"type":"tool-removal","toolName":"glob"},{"type":"tool-removal","toolName":"grep"},{"type":"tool-removal","toolName":"interrupt_agent"},{"type":"tool-removal","toolName":"job_kill"},{"type":"tool-removal","toolName":"job_list"},{"type":"tool-removal","toolName":"job_output"},{"type":"tool-removal","toolName":"list_agents"},{"type":"tool-removal","toolName":"read"},{"type":"tool-removal","toolName":"read_image"},{"type":"tool-removal","toolName":"send_message"},{"type":"tool-removal","toolName":"skill"},{"type":"tool-removal","toolName":"subagent"},{"type":"tool-removal","toolName":"subagent_fork"},{"type":"tool-removal","toolName":"todo_write"},{"type":"tool-removal","toolName":"update_goal"},{"type":"tool-removal","toolName":"wait_agent"},{"type":"tool-removal","toolName":"web_fetch"},{"type":"tool-removal","toolName":"web_search"},{"type":"tool-removal","toolName":"workflow"},{"type":"tool-removal","toolName":"working_directory"},{"type":"tool-removal","toolName":"write"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} +{"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"run_code"},{"type":"tool-removal","toolName":"apply_patch"},{"type":"tool-removal","toolName":"bash"},{"type":"tool-removal","toolName":"cordis_inspect_list"},{"type":"tool-removal","toolName":"cordis_inspect_query"},{"type":"tool-removal","toolName":"create_goal"},{"type":"tool-removal","toolName":"edit"},{"type":"tool-removal","toolName":"get_goal"},{"type":"tool-removal","toolName":"glob"},{"type":"tool-removal","toolName":"grep"},{"type":"tool-removal","toolName":"job_kill"},{"type":"tool-removal","toolName":"job_list"},{"type":"tool-removal","toolName":"job_output"},{"type":"tool-removal","toolName":"list_agents"},{"type":"tool-removal","toolName":"read"},{"type":"tool-removal","toolName":"read_image"},{"type":"tool-removal","toolName":"send_message"},{"type":"tool-removal","toolName":"skill"},{"type":"tool-removal","toolName":"subagent"},{"type":"tool-removal","toolName":"subagent_fork"},{"type":"tool-removal","toolName":"todo_write"},{"type":"tool-removal","toolName":"update_goal"},{"type":"tool-removal","toolName":"wait_agent"},{"type":"tool-removal","toolName":"web_fetch"},{"type":"tool-removal","toolName":"web_search"},{"type":"tool-removal","toolName":"workflow"},{"type":"tool-removal","toolName":"working_directory"},{"type":"tool-removal","toolName":"write"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791315715055,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791315715055,"index":0,"dt":[],"id":"advanced-code","name":"run_code","args":["{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"]},{"type":"chunk","time":1791315715055,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"}}},{"type":"chunk","time":1791315715055,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791315715055,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const inspected = await tools.cordis_inspect_list({}); return inspected.providers.map(provider => provider.id);\",\"description\":\"Discover runtime APIs through PTC\"}"}} {"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"cordis_inspect_list","arguments":{}}} @@ -28,8 +28,8 @@ {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"system/message","data":{"turn":1,"step":3,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:10}}"}},"sourceEventSeqs":[18],"surfaceOp":{"op":"replace","startSeq":18,"endSeq":18}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","cordis_inspect_list","cordis_inspect_query","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} -{"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"cordis_inspect_list"},{"type":"tool-addition","toolName":"cordis_inspect_query"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"},{"type":"tool-removal","toolName":"run_code"}],"role":"developer","id":"{{message:11}}"},"headerSeq":29},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","bash","cordis_inspect_list","cordis_inspect_query","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} +{"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"cordis_inspect_list"},{"type":"tool-addition","toolName":"cordis_inspect_query"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"},{"type":"tool-removal","toolName":"run_code"}],"role":"developer","id":"{{message:11}}"},"headerSeq":29},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1791315715200,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791315715200,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":["{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"]},{"type":"chunk","time":1791315715200,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}},{"type":"chunk","time":1791315715200,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1791315715200,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}} {"type":"subagent/catalog","data":{"version":3,"childId":"{{session:2}}","childCreatedAt":1791315715221,"mode":"continuable","label":"Check direct child","name":"agent-1"}} diff --git a/snapshots/session/advanced-toolchain/system-prompt.expected.md b/snapshots/session/advanced-toolchain/system-prompt.expected.md index 4327bd60c2..2761bf68ad 100644 --- a/snapshots/session/advanced-toolchain/system-prompt.expected.md +++ b/snapshots/session/advanced-toolchain/system-prompt.expected.md @@ -170,11 +170,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -441,9 +436,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/advanced-toolchain/tool-schemas.expected.json b/snapshots/session/advanced-toolchain/tool-schemas.expected.json index 8b209628c6..60c0d74948 100644 --- a/snapshots/session/advanced-toolchain/tool-schemas.expected.json +++ b/snapshots/session/advanced-toolchain/tool-schemas.expected.json @@ -228,22 +228,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -965,22 +949,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/agent-instructions/tool-schemas.expected.json b/snapshots/session/agent-instructions/tool-schemas.expected.json index 330f640f56..05b7194e33 100644 --- a/snapshots/session/agent-instructions/tool-schemas.expected.json +++ b/snapshots/session/agent-instructions/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -844,22 +828,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/apply-patch-update/tool-schemas.expected.json b/snapshots/session/apply-patch-update/tool-schemas.expected.json index 842f8ad9d4..827b3a3e9b 100644 --- a/snapshots/session/apply-patch-update/tool-schemas.expected.json +++ b/snapshots/session/apply-patch-update/tool-schemas.expected.json @@ -159,22 +159,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "list_agents", "description": "List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry names, descriptions, and status. A descendant's name is its path from the root, such as reviewer/checker. You can message any other agent in the subagent tree. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. corrupt, unsupported, or unavailable means this row's own descendants could not be read; the row itself is still a member you can message. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation.", diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index 0442b72193..3cc2d5e4c5 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -120,11 +120,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -389,9 +384,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/browser-use-chrome-devtools-mcp/tool-schemas.expected.json b/snapshots/session/browser-use-chrome-devtools-mcp/tool-schemas.expected.json index 755e8e2317..277db426bf 100644 --- a/snapshots/session/browser-use-chrome-devtools-mcp/tool-schemas.expected.json +++ b/snapshots/session/browser-use-chrome-devtools-mcp/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/browser-use-playwright-mcp/tool-schemas.expected.json b/snapshots/session/browser-use-playwright-mcp/tool-schemas.expected.json index 0b0a8eb9a3..efa2e54853 100644 --- a/snapshots/session/browser-use-playwright-mcp/tool-schemas.expected.json +++ b/snapshots/session/browser-use-playwright-mcp/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/browser-use-stagehand-native/tool-schemas.expected.json b/snapshots/session/browser-use-stagehand-native/tool-schemas.expected.json index 6917c9788c..b83b65b7bc 100644 --- a/snapshots/session/browser-use-stagehand-native/tool-schemas.expected.json +++ b/snapshots/session/browser-use-stagehand-native/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/compaction-recovery/tool-schemas.expected.json b/snapshots/session/compaction-recovery/tool-schemas.expected.json index 86eea456d6..ae0b7ce3a7 100644 --- a/snapshots/session/compaction-recovery/tool-schemas.expected.json +++ b/snapshots/session/compaction-recovery/tool-schemas.expected.json @@ -159,22 +159,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -788,22 +772,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/computer-use-cua-driver-mcp/tool-schemas.expected.json b/snapshots/session/computer-use-cua-driver-mcp/tool-schemas.expected.json index aa794f54ac..3a3f73ce68 100644 --- a/snapshots/session/computer-use-cua-driver-mcp/tool-schemas.expected.json +++ b/snapshots/session/computer-use-cua-driver-mcp/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/computer-use-cua-driver-native/tool-schemas.expected.json b/snapshots/session/computer-use-cua-driver-native/tool-schemas.expected.json index 204b124365..acf3b16718 100644 --- a/snapshots/session/computer-use-cua-driver-native/tool-schemas.expected.json +++ b/snapshots/session/computer-use-cua-driver-native/tool-schemas.expected.json @@ -233,22 +233,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index cd6e6548e5..b1df2e293d 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -228,22 +228,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 33f3636750..565485ea46 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -1545,22 +1545,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 587436227a..b0af17e860 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -173,11 +173,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -443,9 +438,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/dynamic-tool-prompt-updates/session.v4.jsonl b/snapshots/session/dynamic-tool-prompt-updates/session.v4.jsonl index 60e4ea8b5f..b295cf23d8 100644 --- a/snapshots/session/dynamic-tool-prompt-updates/session.v4.jsonl +++ b/snapshots/session/dynamic-tool-prompt-updates/session.v4.jsonl @@ -9,7 +9,7 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","systemPromptUpdate":"in-history"}} {"type":"session/title","data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790149644504,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790149644504,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1790149644504,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1790149644504,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790149644504,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -18,14 +18,14 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"system/message","data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:6}}"}},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1790149644534,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790149644534,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1790149644534,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1790149644535,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:10}}"}},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1790149644557,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1790149644557,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1790149644557,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1790149644557,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1790149644557,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} diff --git a/snapshots/session/dynamic-tool-updates/session.v4.jsonl b/snapshots/session/dynamic-tool-updates/session.v4.jsonl index 718e23f2fe..c2e7bc3bad 100644 --- a/snapshots/session/dynamic-tool-updates/session.v4.jsonl +++ b/snapshots/session/dynamic-tool-updates/session.v4.jsonl @@ -9,7 +9,7 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790049391235,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391235,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1790049391235,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790049391235,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -17,14 +17,14 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_task_read"},"toolCallId":"call_task_read","content":[{"type":"text","text":"{{cwd}}/task.txt\nfile\n\n1: Reply with the single word DONE after reading this file.\n\n(End of file - total 1 lines)\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"path":"{{cwd}}/task.txt","offset":1,"lines":[{"number":1,"text":"Reply with the single word DONE after reading this file."}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:6}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"stream":[{"type":"chunk","time":1790049391255,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790049391255,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1790049391255,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1790049391255,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:9}}"}},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1790049391270,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1790049391270,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1790049391270,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1790049391270,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} diff --git a/snapshots/session/dynamic-tool-updates/tool-schemas.expected.json b/snapshots/session/dynamic-tool-updates/tool-schemas.expected.json index a3e5000b90..082427d65d 100644 --- a/snapshots/session/dynamic-tool-updates/tool-schemas.expected.json +++ b/snapshots/session/dynamic-tool-updates/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -844,22 +828,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -1508,22 +1476,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json index 1763014d03..f3889efab3 100644 --- a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json +++ b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json @@ -159,22 +159,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "list_agents", "description": "List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry names, descriptions, and status. A descendant's name is its path from the root, such as reviewer/checker. You can message any other agent in the subagent tree. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. corrupt, unsupported, or unavailable means this row's own descendants could not be read; the row itself is still a member you can message. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation.", diff --git a/snapshots/session/in-history-series-start/session.v4.jsonl b/snapshots/session/in-history-series-start/session.v4.jsonl index ee4aa2eba1..3d91c63da2 100644 --- a/snapshots/session/in-history-series-start/session.v4.jsonl +++ b/snapshots/session/in-history-series-start/session.v4.jsonl @@ -9,7 +9,7 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","systemPromptUpdate":"in-history"}} {"type":"session/title","data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792735518085,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792735518085,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1792735518085,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1792735518085,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792735518085,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -18,7 +18,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"system/message","data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:6}}"}},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1792735518105,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792735518105,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1792735518105,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1792735518105,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} @@ -26,7 +26,7 @@ {"type":"step/end","data":{"turn":1,"step":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Snapshot checkpoint replacing the first admitted user message."}],"source":{"kind":"test-compaction"},"role":"user","id":"{{message:10}}"},"sourceEventSeqs":[8],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":8}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} {"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:11}}"}},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1792735518120,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792735518120,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792735518120,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792735518120,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1792735518120,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} diff --git a/snapshots/session/lsp-definition/tool-schemas.expected.json b/snapshots/session/lsp-definition/tool-schemas.expected.json index e78092b598..6f0c4997d9 100644 --- a/snapshots/session/lsp-definition/tool-schemas.expected.json +++ b/snapshots/session/lsp-definition/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/mcp-resources-ptc/system-prompt.expected.md b/snapshots/session/mcp-resources-ptc/system-prompt.expected.md index 5df4972023..4e7f0f4ab6 100644 --- a/snapshots/session/mcp-resources-ptc/system-prompt.expected.md +++ b/snapshots/session/mcp-resources-ptc/system-prompt.expected.md @@ -128,11 +128,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -418,9 +413,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/mcp-resources/tool-schemas.expected.json b/snapshots/session/mcp-resources/tool-schemas.expected.json index 669bfabee6..49aee6d79b 100644 --- a/snapshots/session/mcp-resources/tool-schemas.expected.json +++ b/snapshots/session/mcp-resources/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/mcp-template-error/tool-schemas.expected.json b/snapshots/session/mcp-template-error/tool-schemas.expected.json index bfad6be741..9adf9d084c 100644 --- a/snapshots/session/mcp-template-error/tool-schemas.expected.json +++ b/snapshots/session/mcp-template-error/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/multimodal-spill-ends/tool-schemas.expected.json b/snapshots/session/multimodal-spill-ends/tool-schemas.expected.json index 0fee5ddc7b..4947b4edeb 100644 --- a/snapshots/session/multimodal-spill-ends/tool-schemas.expected.json +++ b/snapshots/session/multimodal-spill-ends/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/office-authoring-checker/session.v4.jsonl b/snapshots/session/office-authoring-checker/session.v4.jsonl index 325a1cb92c..2af2dfe8c8 100644 --- a/snapshots/session/office-authoring-checker/session.v4.jsonl +++ b/snapshots/session/office-authoring-checker/session.v4.jsonl @@ -10,7 +10,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Load the Office Word, PowerPoint, and Excel authoring skills. Read their structural-check instructions and reply DONE. Do not create files."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Load the Office Word, PowerPoint,","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_office_skill","name":"skill","arguments":"{\"name\":\"office-docx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"stream":[{"type":"chunk","time":1793628043727,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793628043727,"index":0,"dt":[],"id":"call_office_skill","name":"skill","args":["{\"name\":\"office-docx\"}"]},{"type":"chunk","time":1793628043727,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_office_skill","name":"skill","arguments":"{\"name\":\"office-docx\"}"}}},{"type":"chunk","time":1793628043727,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -18,7 +18,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_office_skill"},"toolCallId":"call_office_skill","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-docx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Word documents\n\nUse `python-docx` for DOCX creation and ordinary edits unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below.\n\nKeep scripts, intermediate files, and final documents in the workspace; runtime and skill resources are read-only. Use the requested language and preserve the document's design unless a redesign is requested.\n\nName Python scripts for their task, such as `edit_docx.py`; avoid module names such as `inspect.py`, `docx.py`, or `zipfile.py` that shadow imported libraries.\n\n## Create and edit\n\nIf `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step.\n\npython-docx cannot read legacy `.doc` files. Use `office_convert` with `format: \"docx\"`, copy the result into the workspace, then edit the copy.\n\nFor text edits, locate the target by its text or position and inspect only its runs and formatting. A title can be an ordinary paragraph without a Title style or document-property title. Update the relevant runs; assigning paragraph `.text` destroys run formatting. Save a new file unless an in-place edit is requested. For ordinary text edits, do not unzip the document, enumerate ZIP members, dump raw XML, or inspect core properties, headers, or footers unrelated to the target. Investigate these only for a concrete failure, an ambiguous target, an unsupported feature, or an explicit user requirement. Rebuilding a document can lose unsupported features.\n\nUse paragraph styles for headings and body text. Size tables for the section that contains them, and account for merged cells and nested tables. Chinese, Japanese, and Korean text may need an explicit `w:eastAsia` font assignment in addition to `run.font.name`; font names alone do not establish glyph availability or rendered appearance.\n\nFor a new document, use the selected Python executable:\n\n```python\nfrom docx import Document\n\ndocument = Document()\ndocument.add_heading(\"Project report\", level=0)\ndocument.add_paragraph(\"Summary\", style=\"Heading 1\")\ndocument.add_paragraph(\"The requested findings go here.\")\ndocument.save(\"report.docx\")\n```\n\npython-docx does not paginate or render documents. Do not represent ordinary replacement, colored text, or comments as tracked changes. When real revisions or unsupported OOXML features matter, preserve their package parts and verify the requested operation rather than silently discarding them. When writing package parts directly, create a new ZIP and copy each unchanged member once; appending a replacement member duplicates its name and corrupts the package.\n\n## Check and deliver\n\nReopen the saved document with python-docx and verify the requested content, related formatting, and unchanged content that must be preserved. These checks complete routine validation; deliver the file once they pass. ZIP integrity, package inventories, and metadata matter only for a concrete failure.\n\nCall `office_render` on the changed page when the user asks for images or visual inspection, or when styling, font color, spacing, sizing, or text alignment is the requested edit; a request to skip rendering takes precedence. New documents and content edits, with incidental basic formatting, are checked with python-docx alone. For PDF output, call `office_convert` with `format: \"pdf\"`, then copy the returned temporary file to the destination.\n\nA fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as \"Content and structure checks passed; visual inspection was not completed.\" or \"内容与结构检查通过;视觉检查未完成。\".\n\nCall `present({\"files\":[{\"path\":\"report.docx\"}]})` with the actual final DOCX path. It exposes the current file without copying or preserving its bytes; keep it in place and omit temporary QA reports unless requested. If `present` is unavailable, deliver the final workspace path with the session's supported method.\n\n## Python environment\n\nFollow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise use the project's or user's existing Python, such as `python3` or `python` on PATH. Before the first script, check that this Python can import the libraries the workflow needs. If Python or a library is missing, tell the user which packages to install; do not install packages into a global environment without the user's agreement.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"office_convert"},{"type":"tool-addition","toolName":"office_recalculate"},{"type":"tool-addition","toolName":"office_render"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_office_2","name":"skill","arguments":"{\"name\":\"office-pptx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1793628043746,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793628043746,"index":0,"dt":[],"id":"call_office_2","name":"skill","args":["{\"name\":\"office-pptx\"}"]},{"type":"chunk","time":1793628043746,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_office_2","name":"skill","arguments":"{\"name\":\"office-pptx\"}"}}},{"type":"chunk","time":1793628043746,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_office_2","name":"skill","arguments":"{\"name\":\"office-pptx\"}"}} diff --git a/snapshots/session/office-progressive-native/session.v4.jsonl b/snapshots/session/office-progressive-native/session.v4.jsonl index 7d84dd306b..fe32bf2d08 100644 --- a/snapshots/session/office-progressive-native/session.v4.jsonl +++ b/snapshots/session/office-progressive-native/session.v4.jsonl @@ -10,7 +10,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Load the office router, then the authoring skill for this workflow. Render page 1 of report.docx, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Load the office router, then","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_office_skill","name":"skill","arguments":"{\"name\":\"office\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"stream":[{"type":"chunk","time":1793628044587,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793628044588,"index":0,"dt":[],"id":"call_office_skill","name":"skill","args":["{\"name\":\"office\"}"]},{"type":"chunk","time":1793628044588,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_office_skill","name":"skill","arguments":"{\"name\":\"office\"}"}}},{"type":"chunk","time":1793628044588,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -18,7 +18,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_office_skill"},"toolCallId":"call_office_skill","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Office task routing\n\nLoad `office-docx`, `office-pptx`, or `office-xlsx` to create, read, or edit; operations alone need no format skill.\n\nIf `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step.\n\n- `office_render`: Office or PDF images, visual checks, and appearance or slide checks required by the format skill; skip it when asked.\n- `office_recalculate`: formulas, with `format: \"xlsx\"` or `\"ods\"`.\n- `office_convert` with `format`; Office inputs only, not PDFs. For PDF export alone, check the copy is nonempty and starts with `%PDF-`, then deliver it without rendering.\n\nCopy temporary outputs to the destination, keeping existing files unless asked to replace them. Reuse loaded skills.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"office_convert"},{"type":"tool-addition","toolName":"office_recalculate"},{"type":"tool-addition","toolName":"office_render"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_office_2","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1793628044612,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793628044612,"index":0,"dt":[],"id":"call_office_2","name":"skill","args":["{\"name\":\"office-xlsx\"}"]},{"type":"chunk","time":1793628044612,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_office_2","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}}},{"type":"chunk","time":1793628044612,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_office_2","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}} diff --git a/snapshots/session/office-progressive-native/tool-schemas.expected.json b/snapshots/session/office-progressive-native/tool-schemas.expected.json index cb30cd581f..b472568322 100644 --- a/snapshots/session/office-progressive-native/tool-schemas.expected.json +++ b/snapshots/session/office-progressive-native/tool-schemas.expected.json @@ -142,22 +142,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -754,22 +738,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/office-progressive-ptc/system-prompt.expected.md b/snapshots/session/office-progressive-ptc/system-prompt.expected.md index 26e78e3934..f93624c469 100644 --- a/snapshots/session/office-progressive-ptc/system-prompt.expected.md +++ b/snapshots/session/office-progressive-ptc/system-prompt.expected.md @@ -99,11 +99,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -334,9 +329,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { @@ -631,11 +623,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -895,9 +882,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/office-render-tools/writer.expected.jsonl b/snapshots/session/office-render-tools/writer.expected.jsonl index 31300316ee..b12e3b4f8f 100644 --- a/snapshots/session/office-render-tools/writer.expected.jsonl +++ b/snapshots/session/office-render-tools/writer.expected.jsonl @@ -11,7 +11,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-docx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Word documents\n\nUse `python-docx` for DOCX creation and ordinary edits unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below.\n\nKeep scripts, intermediate files, and final documents in the workspace; runtime and skill resources are read-only. Use the requested language and preserve the document's design unless a redesign is requested.\n\nName Python scripts for their task, such as `edit_docx.py`; avoid module names such as `inspect.py`, `docx.py`, or `zipfile.py` that shadow imported libraries.\n\n## Create and edit\n\nIf `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step.\n\npython-docx cannot read legacy `.doc` files. Use `office_convert` with `format: \"docx\"`, copy the result into the workspace, then edit the copy.\n\nFor text edits, locate the target by its text or position and inspect only its runs and formatting. A title can be an ordinary paragraph without a Title style or document-property title. Update the relevant runs; assigning paragraph `.text` destroys run formatting. Save a new file unless an in-place edit is requested. For ordinary text edits, do not unzip the document, enumerate ZIP members, dump raw XML, or inspect core properties, headers, or footers unrelated to the target. Investigate these only for a concrete failure, an ambiguous target, an unsupported feature, or an explicit user requirement. Rebuilding a document can lose unsupported features.\n\nUse paragraph styles for headings and body text. Size tables for the section that contains them, and account for merged cells and nested tables. Chinese, Japanese, and Korean text may need an explicit `w:eastAsia` font assignment in addition to `run.font.name`; font names alone do not establish glyph availability or rendered appearance.\n\nFor a new document, use the selected Python executable:\n\n```python\nfrom docx import Document\n\ndocument = Document()\ndocument.add_heading(\"Project report\", level=0)\ndocument.add_paragraph(\"Summary\", style=\"Heading 1\")\ndocument.add_paragraph(\"The requested findings go here.\")\ndocument.save(\"report.docx\")\n```\n\npython-docx does not paginate or render documents. Do not represent ordinary replacement, colored text, or comments as tracked changes. When real revisions or unsupported OOXML features matter, preserve their package parts and verify the requested operation rather than silently discarding them. When writing package parts directly, create a new ZIP and copy each unchanged member once; appending a replacement member duplicates its name and corrupts the package.\n\n## Check and deliver\n\nReopen the saved document with python-docx and verify the requested content, related formatting, and unchanged content that must be preserved. These checks complete routine validation; deliver the file once they pass. ZIP integrity, package inventories, and metadata matter only for a concrete failure.\n\nCall `office_render` on the changed page when the user asks for images or visual inspection, or when styling, font color, spacing, sizing, or text alignment is the requested edit; a request to skip rendering takes precedence. New documents and content edits, with incidental basic formatting, are checked with python-docx alone. For PDF output, call `office_convert` with `format: \"pdf\"`, then copy the returned temporary file to the destination.\n\nA fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as \"Content and structure checks passed; visual inspection was not completed.\" or \"内容与结构检查通过;视觉检查未完成。\".\n\nCall `present({\"files\":[{\"path\":\"report.docx\"}]})` with the actual final DOCX path. It exposes the current file without copying or preserving its bytes; keep it in place and omit temporary QA reports unless requested. If `present` is unavailable, deliver the final workspace path with the session's supported method.\n\n## Python environment\n\nFollow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise use the project's or user's existing Python, such as `python3` or `python` on PATH. Before the first script, check that this Python can import the libraries the workflow needs. If Python or a library is missing, tell the user which packages to install; do not install packages into a global environment without the user's agreement.\n\n"}],"source":{"kind":"skill-invocation","name":"office-docx","form":"instructions"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"/office-docx Load the Office rendering","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_office_skill","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1793793369677,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793793369678,"index":0,"dt":[],"id":"call_office_skill","name":"skill","args":["{\"name\":\"office-xlsx\"}"]},{"type":"chunk","time":1793793369678,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_office_skill","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}}},{"type":"chunk","time":1793793369678,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -19,7 +19,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_office_skill"},"toolCallId":"call_office_skill","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-xlsx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Excel workbooks\n\nUse `openpyxl` and `pandas` unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below.\n\nKeep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit.\n\nName Python scripts for their task, such as `edit_xlsx.py`; avoid module names such as `inspect.py`, `openpyxl.py`, or `pandas.py` that shadow imported libraries.\n\n## Read and modify\n\nIf `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step.\n\nopenpyxl cannot read legacy `.xls` files. Use `office_convert` with `format: \"xlsx\"`, copy the result into the workspace, then edit the copy.\n\nUse `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving.\n\nUse pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones.\n\nFor a new workbook, the selected Python environment can write editable values, styles, and formulas directly:\n\n```python\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font\n\nworkbook = Workbook()\nsheet = workbook.active\nsheet.title = \"Revenue\"\nfor row in [(\"Quarter\", \"Revenue\"), (\"Q1\", 12), (\"Q2\", 18), (\"Total\", \"=SUM(B2:B3)\")]:\n sheet.append(row)\nfor cell in sheet[1]:\n cell.font = Font(bold=True)\nsheet.column_dimensions[\"A\"].width = 18\nsheet.column_dimensions[\"B\"].width = 18\nworkbook.save(\"report.xlsx\")\n```\n\nPreserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook(\"input.xlsx\", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive.\n\nWriting a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate formulas; `data_only=True` returns cached values that may be absent or stale. When calculated results must be refreshed, call `office_recalculate` with `format: \"xlsx\"`, and copy the returned temporary workbook to a new file in the workspace. Open the copy with openpyxl using both `data_only=False` and `data_only=True` to check that formulas survive and cached values match the expected results. If recalculation fails, preserve the original workbook and report that calculated values were not refreshed. Do not replace requested formulas with constants or report stale or absent cached values as newly calculated results.\n\nDo not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros. Preserve the original and verify each workbook feature the task depends on.\n\n## Check and deliver\n\nReopen the saved workbook with openpyxl and verify the requested sheet names, values, types, formulas, styles, and totals against the source data, including unchanged content that must be preserved. These checks do not calculate formulas or judge workbook appearance.\n\nCall `office_render` when the user asks for images or visual inspection, or when the task targets appearance, such as cell fill colors, text alignment, spacing, or sizing; a request to skip rendering takes precedence. Do not render new workbooks or data or formula edits merely to verify accompanying bold headers or number formats; check these with openpyxl alone.\n\nRender worksheets by `sheet` and the affected `range`; worksheet numbers are not PDF page numbers. Earlier images of an unchanged source remain current. A fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as \"Content and structure checks passed; visual inspection was not completed.\" or \"内容与结构检查通过;视觉检查未完成。\".\n\nRecalculate when refreshed calculated values are needed. For PDF output, call `office_convert` with `format: \"pdf\"`, then copy the returned temporary file to the destination. Deliver after the requested data checks pass and the required operations finish.\n\nCall `present({\"files\":[{\"path\":\"report.xlsx\"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method.\n\n## Python environment\n\nFollow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise use the project's or user's existing Python, such as `python3` or `python` on PATH. Before the first script, check that this Python can import the libraries the workflow needs. If Python or a library is missing, tell the user which packages to install; do not install packages into a global environment without the user's agreement.\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"office_convert"},{"type":"tool-addition","toolName":"office_recalculate"},{"type":"tool-addition","toolName":"office_render"}],"role":"developer","id":"{{message:8}}"},"headerSeq":20},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_office_2","name":"office_tools","arguments":"{\"action\":\"load\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"stream":[{"type":"chunk","time":1793793369702,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793793369702,"index":0,"dt":[],"id":"call_office_2","name":"office_tools","args":["{\"action\":\"load\"}"]},{"type":"chunk","time":1793793369702,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_office_2","name":"office_tools","arguments":"{\"action\":\"load\"}"}}},{"type":"chunk","time":1793793369702,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_office_2","name":"office_tools","arguments":"{\"action\":\"load\"}"}} diff --git a/snapshots/session/office-skills/session.v4.jsonl b/snapshots/session/office-skills/session.v4.jsonl index 2d9f5a3fa5..c02ddac911 100644 --- a/snapshots/session/office-skills/session.v4.jsonl +++ b/snapshots/session/office-skills/session.v4.jsonl @@ -10,7 +10,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Load the office-pptx and office-xlsx skills with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","load_workspace_dependencies","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","load_workspace_dependencies","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Load the office-pptx and office-xlsx","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"office-pptx\"}"},{"type":"tool-call","id":"call_skill_xlsx","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1793628049543,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1793628049543,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1793628049543,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793628049543,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"office-pptx\"}"]},{"type":"chunk","time":1793628049543,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1793628049543,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"office-pptx\"}"}}},{"type":"chunk","time":1793628049543,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793628049543,"index":2,"dt":[],"id":"call_skill_xlsx","name":"skill","args":["{\"name\":\"office-xlsx\"}"]},{"type":"chunk","time":1793628049543,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_skill_xlsx","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}}},{"type":"chunk","time":1793628049543,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1793628049543,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -20,7 +20,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_xlsx"},"toolCallId":"call_skill_xlsx","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-xlsx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Excel workbooks\n\nUse `openpyxl` and `pandas` unless an explicit user or applicable AGENTS.md requirement names another library. Run Python as described in the Python environment section below.\n\nKeep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit.\n\nName Python scripts for their task, such as `edit_xlsx.py`; avoid module names such as `inspect.py`, `openpyxl.py`, or `pandas.py` that shadow imported libraries.\n\n## Read and modify\n\nIf `office_render`, `office_convert`, or `office_recalculate` is not in your tools, load `office` with `skill`; they appear next step.\n\nopenpyxl cannot read legacy `.xls` files. Use `office_convert` with `format: \"xlsx\"`, copy the result into the workspace, then edit the copy.\n\nUse `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving.\n\nUse pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones.\n\nFor a new workbook, the selected Python environment can write editable values, styles, and formulas directly:\n\n```python\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font\n\nworkbook = Workbook()\nsheet = workbook.active\nsheet.title = \"Revenue\"\nfor row in [(\"Quarter\", \"Revenue\"), (\"Q1\", 12), (\"Q2\", 18), (\"Total\", \"=SUM(B2:B3)\")]:\n sheet.append(row)\nfor cell in sheet[1]:\n cell.font = Font(bold=True)\nsheet.column_dimensions[\"A\"].width = 18\nsheet.column_dimensions[\"B\"].width = 18\nworkbook.save(\"report.xlsx\")\n```\n\nPreserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook(\"input.xlsx\", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive.\n\nWriting a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate formulas; `data_only=True` returns cached values that may be absent or stale. When calculated results must be refreshed, call `office_recalculate` with `format: \"xlsx\"`, and copy the returned temporary workbook to a new file in the workspace. Open the copy with openpyxl using both `data_only=False` and `data_only=True` to check that formulas survive and cached values match the expected results. If recalculation fails, preserve the original workbook and report that calculated values were not refreshed. Do not replace requested formulas with constants or report stale or absent cached values as newly calculated results.\n\nDo not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros. Preserve the original and verify each workbook feature the task depends on.\n\n## Check and deliver\n\nReopen the saved workbook with openpyxl and verify the requested sheet names, values, types, formulas, styles, and totals against the source data, including unchanged content that must be preserved. These checks do not calculate formulas or judge workbook appearance.\n\nCall `office_render` when the user asks for images or visual inspection, or when the task targets appearance, such as cell fill colors, text alignment, spacing, or sizing; a request to skip rendering takes precedence. Do not render new workbooks or data or formula edits merely to verify accompanying bold headers or number formats; check these with openpyxl alone.\n\nRender worksheets by `sheet` and the affected `range`; worksheet numbers are not PDF page numbers. Earlier images of an unchanged source remain current. A fully transparent or uniformly blank image of populated content means the renderer failed, which ends visual checks for the task and leaves the source as it is. When content and structure checks pass, state the visual status only as \"Content and structure checks passed; visual inspection was not completed.\" or \"内容与结构检查通过;视觉检查未完成。\".\n\nRecalculate when refreshed calculated values are needed. For PDF output, call `office_convert` with `format: \"pdf\"`, then copy the returned temporary file to the destination. Deliver after the requested data checks pass and the required operations finish.\n\nCall `present({\"files\":[{\"path\":\"report.xlsx\"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method.\n\n## Python environment\n\nFollow an explicit user or applicable AGENTS.md requirement for a Python environment. Otherwise call `load_workspace_dependencies` and run scripts with its Python executable and bundled libraries. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","load_workspace_dependencies","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","load_workspace_dependencies","office_convert","office_recalculate","office_render","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"office_convert"},{"type":"tool-addition","toolName":"office_recalculate"},{"type":"tool-addition","toolName":"office_render"}],"role":"developer","id":"{{message:8}}"},"headerSeq":21},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1793628049569,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1793628049569,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1793628049569,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1793628049570,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793628049570,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1793628049570,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793628049570,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1793628049570,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} diff --git a/snapshots/session/office-skills/tool-schemas.expected.json b/snapshots/session/office-skills/tool-schemas.expected.json index 7b123f5a26..a142aaf1b4 100644 --- a/snapshots/session/office-skills/tool-schemas.expected.json +++ b/snapshots/session/office-skills/tool-schemas.expected.json @@ -142,22 +142,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -762,22 +746,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/persistent-pwsh-tool-turn/tool-schemas.expected.json b/snapshots/session/persistent-pwsh-tool-turn/tool-schemas.expected.json index 49a86b7987..b48f632b42 100644 --- a/snapshots/session/persistent-pwsh-tool-turn/tool-schemas.expected.json +++ b/snapshots/session/persistent-pwsh-tool-turn/tool-schemas.expected.json @@ -114,22 +114,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "list_agents", "description": "List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry names, descriptions, and status. A descendant's name is its path from the root, such as reviewer/checker. You can message any other agent in the subagent tree. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. corrupt, unsupported, or unavailable means this row's own descendants could not be read; the row itself is still a member you can message. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation.", diff --git a/snapshots/session/pi-ai-conversation-updates/session.v4.jsonl b/snapshots/session/pi-ai-conversation-updates/session.v4.jsonl index 42bc133f87..ae4e3e85ba 100644 --- a/snapshots/session/pi-ai-conversation-updates/session.v4.jsonl +++ b/snapshots/session/pi-ai-conversation-updates/session.v4.jsonl @@ -9,7 +9,7 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Read task.txt, call the newly available snapshot_ping tool once, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"moonshotai","model":"kimi-k3"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"moonshotai","model":"kimi-k3"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"moonshotai","model":"kimi-k3","contextWindow":1048576,"systemPromptUpdate":"in-history"}} {"type":"session/title","data":{"title":"Read task.txt, call the newly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}],"source":{"kind":"model","provider":"moonshotai","model":"kimi-k3"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790149644504,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790149644504,"index":0,"dt":[],"id":"call_task_read","name":"read","args":["{\"file_path\":\"task.txt\"}"]},{"type":"chunk","time":1790149644504,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_task_read","name":"read","arguments":"{\"file_path\":\"task.txt\"}"}}},{"type":"chunk","time":1790149644504,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790149644504,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -18,14 +18,14 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"system/message","data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:6}}"}},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"moonshotai","model":"kimi-k3"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"moonshotai","model":"kimi-k3"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","snapshot_ping","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}],"source":{"kind":"model","provider":"moonshotai","model":"kimi-k3"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1790149644534,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790149644534,"index":0,"dt":[],"id":"call_dynamic_ping","name":"snapshot_ping","args":["{}"]},{"type":"chunk","time":1790149644534,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}}},{"type":"chunk","time":1790149644535,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dynamic_ping","name":"snapshot_ping","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dynamic_ping"},"toolCallId":"call_dynamic_ping","content":[{"type":"text","text":"pong"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"request/header","data":{"header":{"config":{"provider":"moonshotai","model":"kimi-k3"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"moonshotai","model":"kimi-k3"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-removal","toolName":"snapshot_ping"}],"role":"developer","id":"{{message:10}}"}},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"moonshotai","model":"kimi-k3"},"id":"{{message:11}}"},"usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10},"stream":[{"type":"chunk","time":1790149644557,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1790149644557,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1790149644557,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1790149644557,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2,"cacheReadTokens":10}}},{"type":"chunk","time":1790149644557,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} diff --git a/snapshots/session/plugin-manager-mcp/session.v4.jsonl b/snapshots/session/plugin-manager-mcp/session.v4.jsonl index eef17eba67..934d6d0411 100644 --- a/snapshots/session/plugin-manager-mcp/session.v4.jsonl +++ b/snapshots/session/plugin-manager-mcp/session.v4.jsonl @@ -9,7 +9,7 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Use plugin_manager with action set_plugin, target include:demo, enabled true to activate the configured MCP entry. Then call its newly discovered mcp__demo__ping tool and report the result. Do not use other tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","plugin_manager","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","plugin_manager","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} {"type":"session/title","data":{"title":"Use plugin_manager with action set_plugi","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to activate a plugin via plugin_manager, then call an MCP tool. Let me do the plugin_manager call first.\n\nNote: they say \"Do not use other tools\" other than plugin_manager and the mcp tool. But I need to first enable it. Let's call plugin_manager set_plugin.\n\nLet me also list plugins first? The instruction says use plugin_manager with action set_plugin, target include:demo, enabled true. Let's just do that."},{"type":"tool-call","id":"call_00_xLhr9wia3A5m7wuSRWwD0436","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"target\": \"include:demo\", \"enabled\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"75d99e21-1d47-4a33-bb88-340a1e84b749"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":7220,"outputTokens":174,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":7394},"stream":[{"type":"chunk","time":1789984418848,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789984418848,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," activate"," a"," plugin"," via"," plugin","_manager",","," then"," call"," an"," M","CP"," tool","."," Let"," me"," do"," the"," plugin","_manager"," call"," first",".\n\n","Note",":"," they"," say"," \"","Do"," not"," use"," other"," tools","\""," other"," than"," plugin","_manager"," and"," the"," m","cp"," tool","."," But"," I"," need"," to"," first"," enable"," it","."," Let","'s"," call"," plugin","_manager"," set","_","plugin",".\n\n","Let"," me"," also"," list"," plugins"," first","?"," The"," instruction"," says"," use"," plugin","_manager"," with"," action"," set","_","plugin",","," target"," include",":","demo",","," enabled"," true","."," Let","'s"," just"," do"," that","."]},{"type":"chunk","time":1789984418849,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to activate a plugin via plugin_manager, then call an MCP tool. Let me do the plugin_manager call first.\n\nNote: they say \"Do not use other tools\" other than plugin_manager and the mcp tool. But I need to first enable it. Let's call plugin_manager set_plugin.\n\nLet me also list plugins first? The instruction says use plugin_manager with action set_plugin, target include:demo, enabled true. Let's just do that."}}},{"type":"chunk","time":1789984418849,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789984418849,"index":1,"dt":[],"id":"call_00_xLhr9wia3A5m7wuSRWwD0436","name":"plugin_manager","args":[""]},{"type":"tool-call-chunks","time0":1789984418849,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_xLhr9wia3A5m7wuSRWwD0436","args":["{","\"","action","\"",": ","\"","set","_","plugin","\"",", ","\"","target","\"",": ","\"","include",":","demo","\"",", ","\"","en","abled","\"",": ","true","}"]},{"type":"chunk","time":1789984418849,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_xLhr9wia3A5m7wuSRWwD0436","name":"plugin_manager","arguments":"{\"action\": \"set_plugin\", \"target\": \"include:demo\", \"enabled\": true}"}}},{"type":"chunk","time":1789984418849,"chunk":{"type":"usage","usage":{"inputTokens":7220,"outputTokens":174,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":7394}}},{"type":"chunk","time":1789984418849,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"75d99e21-1d47-4a33-bb88-340a1e84b749"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} @@ -18,7 +18,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"system/message","data":{"turn":1,"step":2,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:6}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_mcp_resource_templates","list_mcp_resources","mcp__demo__ping","plugin_manager","read","read_image","read_mcp_resource","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","list_mcp_resource_templates","list_mcp_resources","mcp__demo__ping","plugin_manager","read","read_image","read_mcp_resource","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change","startsSeries":true}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"list_mcp_resource_templates"},{"type":"tool-addition","toolName":"list_mcp_resources"},{"type":"tool-addition","toolName":"mcp__demo__ping"},{"type":"tool-addition","toolName":"read_mcp_resource"}],"role":"developer","id":"{{message:7}}"},"headerSeq":19},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The plugin was enabled. Now call mcp__demo__ping.\n\nWait — the tool was already listed in my tools before enabling. Let's call it anyway."},{"type":"tool-call","id":"call_00_miuKfLCTz5Cb38gdp8kK5186","name":"mcp__demo__ping","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"b1ec1efe-4be6-4836-a10c-e5d6bfdce520"},{"type":"tool-call"}]}},"id":"{{message:8}}"},"usage":{"inputTokens":7035,"outputTokens":59,"cacheReadTokens":768,"cacheWriteTokens":0,"totalTokens":7862},"stream":[{"type":"chunk","time":1789984418998,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789984418998,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," plugin"," was"," enabled","."," Now"," call"," m","cp","__","demo","__","ping",".\n\n","Wait"," —"," the"," tool"," was"," already"," listed"," in"," my"," tools"," before"," enabling","."," Let","'s"," call"," it"," anyway","."]},{"type":"chunk","time":1789984418998,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The plugin was enabled. Now call mcp__demo__ping.\n\nWait — the tool was already listed in my tools before enabling. Let's call it anyway."}}},{"type":"chunk","time":1789984418998,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789984418998,"index":1,"dt":[],"id":"call_00_miuKfLCTz5Cb38gdp8kK5186","name":"mcp__demo__ping","args":[""]},{"type":"tool-call-chunks","time0":1789984418998,"index":1,"dt":[],"id":"call_00_miuKfLCTz5Cb38gdp8kK5186","args":["{}"]},{"type":"chunk","time":1789984418998,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_miuKfLCTz5Cb38gdp8kK5186","name":"mcp__demo__ping","arguments":"{}"}}},{"type":"chunk","time":1789984418998,"chunk":{"type":"usage","usage":{"inputTokens":7035,"outputTokens":59,"cacheReadTokens":768,"cacheWriteTokens":0,"totalTokens":7862}}},{"type":"chunk","time":1789984418998,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"b1ec1efe-4be6-4836-a10c-e5d6bfdce520"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_miuKfLCTz5Cb38gdp8kK5186","name":"mcp__demo__ping","arguments":"{}"}} diff --git a/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json b/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json index 9e466482ae..67353604d2 100644 --- a/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json +++ b/snapshots/session/plugin-manager-mcp/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -905,22 +889,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/plugin-manager/tool-schemas.expected.json b/snapshots/session/plugin-manager/tool-schemas.expected.json index 9a3cb7b7ec..cb0de5ebb9 100644 --- a/snapshots/session/plugin-manager/tool-schemas.expected.json +++ b/snapshots/session/plugin-manager/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/product-subagent-both/tool-schemas.expected.json b/snapshots/session/product-subagent-both/tool-schemas.expected.json index e0b04634a2..999751c5b9 100644 --- a/snapshots/session/product-subagent-both/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-both/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/product-subagent-codex/tool-schemas.expected.json b/snapshots/session/product-subagent-codex/tool-schemas.expected.json index e4f873ab07..23570e15e5 100644 --- a/snapshots/session/product-subagent-codex/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-codex/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/product-subagent-oar/tool-schemas.expected.json b/snapshots/session/product-subagent-oar/tool-schemas.expected.json index dcb39765d0..c42658816f 100644 --- a/snapshots/session/product-subagent-oar/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-oar/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json index a803e45fba..3c64ee2747 100644 --- a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/ptc-persistent/system-prompt.expected.md b/snapshots/session/ptc-persistent/system-prompt.expected.md index d4bd10b89b..89e23ec4fa 100644 --- a/snapshots/session/ptc-persistent/system-prompt.expected.md +++ b/snapshots/session/ptc-persistent/system-prompt.expected.md @@ -120,11 +120,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -393,9 +388,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/ptc-policy-change/system-prompt.expected.md b/snapshots/session/ptc-policy-change/system-prompt.expected.md index 6679176807..ca761dccb8 100644 --- a/snapshots/session/ptc-policy-change/system-prompt.expected.md +++ b/snapshots/session/ptc-policy-change/system-prompt.expected.md @@ -120,11 +120,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -391,9 +386,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/ptc-python-turn/system-prompt.expected.md b/snapshots/session/ptc-python-turn/system-prompt.expected.md index 1e477ace45..0873748931 100644 --- a/snapshots/session/ptc-python-turn/system-prompt.expected.md +++ b/snapshots/session/ptc-python-turn/system-prompt.expected.md @@ -236,14 +236,6 @@ class GrepOutputMatches(TypedDict): class GrepOutput(TypedDict): matches: list[GrepOutputMatches] -class InterruptAgentArgs(TypedDict): - # The name of an agent created under you: your direct child or a deeper descendant. - agent: str - # Additional keys beyond those declared are allowed. - -class InterruptAgentOutput(TypedDict): - accepted: bool - class JobKillArgs(TypedDict): # Job id returned by the tool that started the background work. job_id: str @@ -551,8 +543,6 @@ class Tools(Protocol): """Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result keeps the first paths and reports where the complete list was saved.""" async def grep(self, args: GrepArgs) -> GrepOutput: """Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved.""" - async def interrupt_agent(self, args: InterruptAgentArgs) -> InterruptAgentOutput: - """Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.""" async def job_kill(self, args: JobKillArgs) -> JobKillOutput: """Request cancellation of a running background job.""" async def job_list(self, args: dict[str, Any]) -> list[JobListOutput]: diff --git a/snapshots/session/ptc-read-image/system-prompt.expected.md b/snapshots/session/ptc-read-image/system-prompt.expected.md index 322fc6ae12..08651e72a0 100644 --- a/snapshots/session/ptc-read-image/system-prompt.expected.md +++ b/snapshots/session/ptc-read-image/system-prompt.expected.md @@ -120,11 +120,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -389,9 +384,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index dd88fa8d9e..fe5f42348b 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -180,11 +180,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -454,9 +449,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/ptc-turn/system-prompt.expected.md b/snapshots/session/ptc-turn/system-prompt.expected.md index 026b5523c5..55ff52414b 100644 --- a/snapshots/session/ptc-turn/system-prompt.expected.md +++ b/snapshots/session/ptc-turn/system-prompt.expected.md @@ -120,11 +120,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -393,9 +388,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json index 69bf8f0cc3..1343ed9d8e 100644 --- a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json +++ b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/pwsh-tool-turn/tool-schemas.expected.json b/snapshots/session/pwsh-tool-turn/tool-schemas.expected.json index bb43126dd4..cd4dea7393 100644 --- a/snapshots/session/pwsh-tool-turn/tool-schemas.expected.json +++ b/snapshots/session/pwsh-tool-turn/tool-schemas.expected.json @@ -114,22 +114,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/ralph-loop/tool-schemas.1.expected.json b/snapshots/session/ralph-loop/tool-schemas.1.expected.json index 2bee257e89..a1cbe4db85 100644 --- a/snapshots/session/ralph-loop/tool-schemas.1.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/ralph-loop/tool-schemas.2.expected.json b/snapshots/session/ralph-loop/tool-schemas.2.expected.json index 2bee257e89..a1cbe4db85 100644 --- a/snapshots/session/ralph-loop/tool-schemas.2.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.2.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/ralph-loop/tool-schemas.expected.json b/snapshots/session/ralph-loop/tool-schemas.expected.json index b5ebba3799..d9c16c2fee 100644 --- a/snapshots/session/ralph-loop/tool-schemas.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/session-query-spill/tool-schemas.expected.json b/snapshots/session/session-query-spill/tool-schemas.expected.json index f43e5f586d..1f68092d11 100644 --- a/snapshots/session/session-query-spill/tool-schemas.expected.json +++ b/snapshots/session/session-query-spill/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json index 3ab6a2fc39..e106eb2948 100644 --- a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/subagent-agent-preset/tool-schemas.1.expected.json b/snapshots/session/subagent-agent-preset/tool-schemas.1.expected.json index d74795aead..f44253beb1 100644 --- a/snapshots/session/subagent-agent-preset/tool-schemas.1.expected.json +++ b/snapshots/session/subagent-agent-preset/tool-schemas.1.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/subagent-agent-preset/tool-schemas.expected.json b/snapshots/session/subagent-agent-preset/tool-schemas.expected.json index 089af47c6f..bc17f7317b 100644 --- a/snapshots/session/subagent-agent-preset/tool-schemas.expected.json +++ b/snapshots/session/subagent-agent-preset/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json index a64d9377c1..a68e1bba9c 100644 --- a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json +++ b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml b/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml index 3cbd35c106..380fbee018 100644 --- a/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml +++ b/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml @@ -73,6 +73,8 @@ fetch: true searchTimeoutMs: 60000 - name: '@deepseek-ai/dsh-tool-subagent-control' + config: + interrupt: false - id: preset-child name: '@deepseek-ai/dsh-agent-preset' config: diff --git a/snapshots/session/subagent-no-message-tool/cordis.yml b/snapshots/session/subagent-no-message-tool/cordis.yml index 0f15062ac9..fa1f0dbd6c 100644 --- a/snapshots/session/subagent-no-message-tool/cordis.yml +++ b/snapshots/session/subagent-no-message-tool/cordis.yml @@ -32,6 +32,8 @@ fetch: true searchTimeoutMs: 60000 - name: '@deepseek-ai/dsh-tool-subagent-control' + config: + interrupt: false - id: preset-child name: '@deepseek-ai/dsh-agent-preset' config: diff --git a/snapshots/session/subagent-tool-filter/tool-schemas.1.expected.json b/snapshots/session/subagent-tool-filter/tool-schemas.1.expected.json index c52a5d9a66..4f86239c75 100644 --- a/snapshots/session/subagent-tool-filter/tool-schemas.1.expected.json +++ b/snapshots/session/subagent-tool-filter/tool-schemas.1.expected.json @@ -73,22 +73,6 @@ "properties": {} } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/team-targets/cordis.snapshot.yml b/snapshots/session/team-targets/cordis.snapshot.yml index 061e1b28a9..4c1be6faf4 100644 --- a/snapshots/session/team-targets/cordis.snapshot.yml +++ b/snapshots/session/team-targets/cordis.snapshot.yml @@ -47,3 +47,7 @@ - insert: - id: enable-team-fixture name: ../../../packages/team/agent-team/tests/enable-team-fixture.ts + +- id: tool-subagent-control + config: + interrupt: true diff --git a/snapshots/session/team-targets/cordis.yml b/snapshots/session/team-targets/cordis.yml index b09a716ba0..fe7ac6df21 100644 --- a/snapshots/session/team-targets/cordis.yml +++ b/snapshots/session/team-targets/cordis.yml @@ -1,3 +1,7 @@ - insert: - id: enable-team-fixture name: ../../../packages/team/agent-team/tests/enable-team-fixture.ts + +- id: tool-subagent-control + config: + interrupt: true diff --git a/snapshots/session/terminal-panel-tools/tool-schemas.expected.json b/snapshots/session/terminal-panel-tools/tool-schemas.expected.json index 96b394650d..0a1bbcadda 100644 --- a/snapshots/session/terminal-panel-tools/tool-schemas.expected.json +++ b/snapshots/session/terminal-panel-tools/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/text-turn/tool-schemas.expected.json b/snapshots/session/text-turn/tool-schemas.expected.json index 53f6a4bf69..08fc35470a 100644 --- a/snapshots/session/text-turn/tool-schemas.expected.json +++ b/snapshots/session/text-turn/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/web-fetch/tool-schemas.expected.json b/snapshots/session/web-fetch/tool-schemas.expected.json index 7429a130fa..1f122af658 100644 --- a/snapshots/session/web-fetch/tool-schemas.expected.json +++ b/snapshots/session/web-fetch/tool-schemas.expected.json @@ -187,22 +187,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/workspace-dependencies/tool-schemas.expected.json b/snapshots/session/workspace-dependencies/tool-schemas.expected.json index ff780e4c28..85be772822 100644 --- a/snapshots/session/workspace-dependencies/tool-schemas.expected.json +++ b/snapshots/session/workspace-dependencies/tool-schemas.expected.json @@ -142,22 +142,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/session/worktree/tool-schemas.expected.json b/snapshots/session/worktree/tool-schemas.expected.json index e14f6246bf..273604b19b 100644 --- a/snapshots/session/worktree/tool-schemas.expected.json +++ b/snapshots/session/worktree/tool-schemas.expected.json @@ -204,22 +204,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/web/collapse-timing/next-message.expected.md b/snapshots/web/collapse-timing/next-message.expected.md index 8e6393f8ec..68d30345d0 100644 --- a/snapshots/web/collapse-timing/next-message.expected.md +++ b/snapshots/web/collapse-timing/next-message.expected.md @@ -14,7 +14,7 @@ - button "Context injection runtime-context" - button "Context injection skill-catalog" - button "Context injection time-context" -- button "Tools updated 34 added" +- button "Tools updated 33 added" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." - paragraph: DONE - button "Copy" diff --git a/snapshots/web/collapse-timing/verbose.expected.md b/snapshots/web/collapse-timing/verbose.expected.md index 6652729078..60117faf20 100644 --- a/snapshots/web/collapse-timing/verbose.expected.md +++ b/snapshots/web/collapse-timing/verbose.expected.md @@ -22,7 +22,7 @@ - button "Context injection runtime-context" - button "Context injection skill-catalog" - button "Context injection time-context" -- button "Tools updated 34 added" +- button "Tools updated 33 added" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." - paragraph: DONE - button "Copy" diff --git a/snapshots/web/creation-recommendations/session.v4.jsonl b/snapshots/web/creation-recommendations/session.v4.jsonl index 3cf76fcece..e616ebffff 100644 --- a/snapshots/web/creation-recommendations/session.v4.jsonl +++ b/snapshots/web/creation-recommendations/session.v4.jsonl @@ -11,7 +11,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n\nCurrent DeepSeek Harness implementation checkout: \"{{sourceRoot}}\".\n\nCurrent DeepSeek Harness Web GUI URL: \"{{webUrl}}\".\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: \"{{sourceRoot}}\"."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: \"{{webUrl}}\"."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `dsh-doc`: Use when a task needs facts about DeepSeek Harness (DSH) itself, such as how a feature, setting, plugin, agent preset, tool, or Cordis API of the running installation works; when the user asks to design, review, build, install, enable, or debug a DSH plugin, bundle, MCP connection, agent preset, plugin icon or detail page, or a DSH tool definition or skill; and when upgrading a plugin or profile to a newer DSH release. Loading it grants no permission to change files or the profile.\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n- `official-plugins`: Check DSH’s optional plugins when the user asks how to do or use something in this app, whether it can, or says a capability is missing or should change: tools, input, thinking or title display, session search, terminals, worktrees, agent teams, desktop control, automation, access, approvals, badges or signatures, plugins or plugin sources. Not a request to develop DSH, even in its own repository: load before reading files or source. Not for ordinary tasks, quoted wishes or declined offers.\n- `personalized-pages`: Plan an optional page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace). Load this before reading any files or source; it says how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined offers.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"dsh-doc","description":"Use when a task needs facts about DeepSeek Harness (DSH) itself, such as how a feature, setting, plugin, agent preset, tool, or Cordis API of the running installation works; when the user asks to design, review, build, install, enable, or debug a DSH plugin, bundle, MCP connection, agent preset, plugin icon or detail page, or a DSH tool definition or skill; and when upgrading a plugin or profile to a newer DSH release. Loading it grants no permission to change files or the profile."},{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."},{"name":"official-plugins","description":"Check DSH’s optional plugins when the user asks how to do or use something in this app, whether it can, or says a capability is missing or should change: tools, input, thinking or title display, session search, terminals, worktrees, agent teams, desktop control, automation, access, approvals, badges or signatures, plugins or plugin sources. Not a request to develop DSH, even in its own repository: load before reading files or source. Not for ordinary tasks, quoted wishes or declined offers."},{"name":"personalized-pages","description":"Plan an optional page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace). Load this before reading any files or source; it says how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined offers."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: {{timeContextTimestamp}}\nBrowser time zone for this request: {{clientTimeZone}}. Interpret otherwise-unqualified dates and times in this zone.\nElapsed since the preceding model-visible message: {{elapsed}}"}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: {{timeContextTimestamp}}\nBrowser time zone for this request: {{clientTimeZone}}. Interpret otherwise-unqualified dates and times in this zone.\nElapsed since the preceding model-visible message: {{elapsed}}"}]},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"session/title","data":{"title":"I wish I had a","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1792674450282,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792674450282,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}}},{"type":"chunk","time":1792674450283,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -19,7 +19,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-personalized-recommendations\",\"title\":{\"en\":\"Personalized recommendations\",\"de\":\"Personalisierte Empfehlungen\",\"es\":\"Recomendaciones personalizadas\",\"fr\":\"Recommandations personnalisées\",\"ja\":\"パーソナライズされたおすすめ\",\"pt\":\"Recomendações personalizadas\",\"ru\":\"Персональные рекомендации\",\"zh\":\"个性化推荐\"},\"description\":{\"en\":\"Shows recommendation cards below the answer for what you want from DSH: when you want a page or small tool you can keep, it proposes a new personalized page, built in the background after you click Create; when you ask how to do something in this app or say a capability is missing, it recommends the built-in plugin that provides it, turned on when you click Enable.\",\"de\":\"Zeigt für Ihre individuellen Anforderungen an DSH Empfehlungskarten unter der Antwort an: Wenn Sie eine Seite oder ein kleines Tool zur dauerhaften Nutzung möchten, wird eine neue personalisierte Seite vorgeschlagen, die nach einem Klick auf Erstellen im Hintergrund erstellt wird; wenn Sie fragen, wie etwas in dieser App geht, oder sagen, dass eine Funktion fehlt, wird das integrierte Plugin empfohlen, das sie bereitstellt, und nach einem Klick auf Aktivieren eingeschaltet.\",\"es\":\"Muestra tarjetas de recomendación debajo de la respuesta para tus necesidades personalizadas en DSH: cuando quieres una página o una pequeña herramienta para conservar, propone una nueva página personalizada, que se genera en segundo plano al hacer clic en Crear; cuando preguntas cómo hacer algo en esta aplicación o dices que falta una función, recomienda el complemento integrado que la ofrece, que se activa al hacer clic en Habilitar.\",\"fr\":\"Affiche des cartes de recommandation sous la réponse pour vos besoins personnalisés dans DSH : lorsque vous voulez une page ou un petit outil à conserver, propose une nouvelle page personnalisée, générée en arrière-plan après un clic sur Créer ; lorsque vous demandez comment faire quelque chose dans cette application ou signalez qu’une fonctionnalité manque, recommande le plugin intégré qui la fournit, activé lorsque vous cliquez sur Activer.\",\"ja\":\"DSH に対するあなた個人のニーズに合わせて、回答の下におすすめカードを表示します。長く使えるページや小さなツールが欲しいときは新しいパーソナルページを提案し、「作成」をクリックするとバックグラウンドで生成します。このアプリで何かをする方法を尋ねたときや機能が足りないと言ったときは、それを提供する内蔵プラグインを推薦し、「有効にする」をクリックすると有効になります。\",\"pt\":\"Mostra cartões de recomendação abaixo da resposta para suas necessidades personalizadas no DSH: quando você quer uma página ou uma pequena ferramenta para manter, propõe uma nova página personalizada, gerada em segundo plano depois que você clica em Criar; quando você pergunta como fazer algo neste aplicativo ou diz que falta um recurso, recomenda o plug-in integrado que o oferece, ativado quando você clica em Ativar.\",\"ru\":\"Показывает карточки рекомендаций под ответом с учетом ваших индивидуальных потребностей в DSH: когда вам нужна страница или небольшой инструмент для постоянного использования, предлагает новую персональную страницу, которая создается в фоне после нажатия «Создать»; когда вы спрашиваете, как что-то сделать в этом приложении, или говорите, что не хватает какой-то возможности, рекомендует встроенный плагин, который ее предоставляет, и включает его после нажатия «Включить».\",\"zh\":\"针对你对 DSH 的个性化需求,在回答下方展示推荐卡片:想要能长期使用的页面或小工具时,提议新增个性化页面,点击创建后在后台生成;问到本应用怎么做某件事或缺少某项能力时,推荐对应的内置插件,点击启用后开启。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"de\":\"Entwicklertools\",\"es\":\"Herramientas de desarrollador\",\"fr\":\"Outils de développement\",\"ja\":\"開発者ツール\",\"pt\":\"Ferramentas de desenvolvedor\",\"ru\":\"Инструменты разработчика\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"For troubleshooting: view what DSH recorded for a session in Session log, inspect per-turn and per-step statistics in the Statistics tab, and debug the DSH backend in Chrome DevTools.\",\"de\":\"Zur Fehlersuche: Sehen Sie in „Sitzungsprotokoll“, was DSH für eine Sitzung aufgezeichnet hat, prüfen Sie im Tab „Statistik“ Kontext, Dauer, Cache und Werkzeugkosten pro Runde und Schritt und debuggen Sie das DSH-Backend in Chrome DevTools.\",\"es\":\"Para solucionar problemas: vea en «Registro de sesión» lo que DSH registró de una sesión, consulte el contexto, los tiempos, la caché y el coste de herramientas por turno y paso en la pestaña «Estadísticas», y depure el backend de DSH en Chrome DevTools.\",\"fr\":\"Pour le dépannage : consultez dans « Journal de session » ce que DSH a enregistré pour une session, examinez le contexte, les durées, le cache et le coût des outils par tour et étape dans l’onglet « Statistiques », et déboguez le backend de DSH dans Chrome DevTools.\",\"ja\":\"トラブルシューティング用です。「セッションログ」で DSH がセッションについて記録した内容を確認し、「統計」タブでターン/ステップごとのコンテキスト、時間、キャッシュ、ツールコストを確認し、Chrome DevTools で DSH バックエンドをデバッグできます。\",\"pt\":\"Para solução de problemas: veja em «Registro da sessão» o que o DSH registrou de uma sessão, consulte contexto, tempos, cache e custo de ferramentas por turno e etapa na aba «Estatísticas» e depure o backend do DSH no Chrome DevTools.\",\"ru\":\"Для диагностики: просматривайте в «Журнале сеанса», что DSH записал для сеанса, смотрите контекст, время, кэш и стоимость инструментов по ходам и шагам на вкладке «Статистика» и отлаживайте бэкенд DSH в Chrome DevTools.\",\"zh\":\"用于排查问题:在「会话数据诊断」中查看 DSH 为会话记录的内容,在「统计」页签查看每轮/每步的上下文、耗时、缓存和工具成本,并在 Chrome DevTools 中调试 DSH 后端。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"de\":\"Spracheingabe\",\"es\":\"Entrada de voz\",\"fr\":\"Saisie vocale\",\"ja\":\"音声入力\",\"pt\":\"Entrada de voz\",\"ru\":\"Голосовой ввод\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"de\":\"Transkribieren Sie Aufnahmen lokal mit SenseVoice; Für die erste Verwendung müssen Abhängigkeiten installiert werden.\",\"es\":\"Transcribe grabaciones localmente con SenseVoice; El primer uso requiere instalar dependencias.\",\"fr\":\"Transcrivez les enregistrements localement avec SenseVoice ; la première utilisation nécessite l'installation de dépendances.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"pt\":\"Transcreva gravações localmente com SenseVoice; o primeiro uso requer a instalação de dependências.\",\"ru\":\"Транскрибируйте записи локально с помощью SenseVoice; при первом использовании требуется установка зависимостей.\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"de\":\"Automatische Autorisierungsüberprüfung\",\"es\":\"Revisión de autorización automática\",\"fr\":\"Examen des autorisations automatiques\",\"ja\":\"自動認可レビュー\",\"pt\":\"Revisão de autorização automática\",\"ru\":\"Проверка автоматической авторизации\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Adds an Auto review access mode: before each tool call, a model checks it against what you asked for, runs allowed calls at once, and holds denied calls for your approval.\",\"de\":\"Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung.\",\"es\":\"Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación.\",\"fr\":\"Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation.\",\"ja\":\"「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。\",\"pt\":\"Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação.\",\"ru\":\"Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения.\",\"zh\":\"提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"de\":\"Maschinelle Übersetzung der Argumentation\",\"es\":\"Traducción automática de razonamiento\",\"fr\":\"Traduction automatique du raisonnement\",\"ja\":\"思考内容の機械翻訳\",\"pt\":\"Tradução automática de raciocínio\",\"ru\":\"Машинный перевод рассуждений\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Translates expanded Think rows into your chosen language as the agent writes them, and View original switches back.\",\"de\":\"Übersetzt aufgeklappte „Denken Sie nach“-Zeilen, während der Agent sie schreibt, in die von Ihnen gewählte Sprache, und „Original ansehen“ wechselt zurück.\",\"es\":\"Traduce las filas “pensar” expandidas al idioma que elija mientras el agente las escribe, y “Ver original” vuelve al texto original.\",\"fr\":\"Traduit les lignes « Pensez » développées dans la langue de votre choix pendant que l'agent les écrit, et « Voir l'original » permet de revenir au texte d'origine.\",\"ja\":\"エージェントが書き出すのと同時に、展開された「思考」行を選択した言語に翻訳します。「原文を表示」で原文に戻せます。\",\"pt\":\"Traduz as linhas “Pense” expandidas para o idioma que você escolher enquanto o agente as escreve, e “Ver original” volta ao texto original.\",\"ru\":\"Переводит развёрнутые строки «Подумайте» на выбранный вами язык по мере того, как агент их пишет, а «Посмотреть оригинал» возвращает исходный текст.\",\"zh\":\"智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"de\":\"DSH-Abzeichen-Fertigkeit\",\"es\":\"Habilidad de insignia DSH\",\"fr\":\"Compétence du badge DSH\",\"ja\":\"DSH バッジスキル\",\"pt\":\"DSH habilidade de distintivo\",\"ru\":\"DSH значок навыка\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"de\":\"Lassen Sie Agenten Dokumente, Pull-Requests und andere Ausgaben mit einem „powered by dsh“-Badge versehen.\",\"es\":\"Permita que los agentes agreguen una insignia \\\"con tecnología de dsh\\\" a documentos, solicitudes de extracción y otros resultados.\",\"fr\":\"Laissez les agents ajouter un badge « propulsé par dsh » aux documents, demandes d'extraction et autres sorties.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"pt\":\"Permita que os agentes adicionem um selo “powered by dsh” a documentos, pull requests e outros resultados.\",\"ru\":\"Позвольте агентам добавлять значок «Powered by dsh» к документам, запросам на включение и другим результатам.\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"de\":\"Ralph-Loops\",\"es\":\"Bucles de Ralph\",\"fr\":\"Boucles Ralph\",\"ja\":\"Ralph ループ\",\"pt\":\"Loops de Ralph\",\"ru\":\"Ральф петли\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Ask for a Ralph loop with a goal, and the agent starts a fresh agent on it round after round until the goal is met, a blocker is reported, or the round limit is reached.\",\"de\":\"Bitten Sie um eine Ralph-Schleife mit einem Ziel, und der Agent startet Runde um Runde einen neuen Agenten darauf, bis das Ziel erreicht ist, ein Hindernis gemeldet wird oder das Rundenlimit erreicht ist.\",\"es\":\"Pida un bucle Ralph con un objetivo, y el agente inicia un agente nuevo sobre él ronda tras ronda hasta que se cumpla el objetivo, se informe de un bloqueo o se alcance el límite de rondas.\",\"fr\":\"Demandez une boucle Ralph avec un objectif, et l'agent lance un nouvel agent dessus, tour après tour, jusqu'à ce que l'objectif soit atteint, qu'un blocage soit signalé ou que la limite de tours soit atteinte.\",\"ja\":\"目標を指定して Ralph ループを依頼すると、エージェントは、目標が達成されるか、阻害要因が報告されるか、ラウンド数の上限に達するまで、ラウンドごとに新しいエージェントを起動してその目標に取り組ませます。\",\"pt\":\"Peça um loop Ralph com um objetivo, e o agente inicia um novo agente nele rodada após rodada até que o objetivo seja atingido, um bloqueio seja informado ou o limite de rodadas seja alcançado.\",\"ru\":\"Попросите запустить цикл Ralph с целью, и агент будет раунд за раундом запускать для неё нового агента, пока цель не будет достигнута, не будет сообщено о препятствии или не будет достигнут лимит раундов.\",\"zh\":\"要求用 Ralph 循环完成一个目标后,智能体会一轮接一轮地启动新的智能体处理它,直到目标达成、报告受阻或达到轮次上限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"de\":\"Konversationsfolgende Titel\",\"es\":\"Títulos que siguen la conversación\",\"fr\":\"Titres de suivi de conversation\",\"ja\":\"会話に追従するタイトル\",\"pt\":\"Títulos que acompanham conversas\",\"ru\":\"Заголовки, следующие за разговором\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Writes a session's title from all of your messages and updates it when the main task changes, instead of only from the first message.\",\"de\":\"Erstellt den Titel einer Sitzung aus all Ihren Nachrichten und aktualisiert ihn, wenn sich die Hauptaufgabe ändert, statt ihn nur aus der ersten Nachricht zu bilden.\",\"es\":\"Escribe el título de una sesión a partir de todos sus mensajes y lo actualiza cuando cambia la tarea principal, en lugar de basarse solo en el primer mensaje.\",\"fr\":\"Rédige le titre d'une session à partir de tous vos messages et le met à jour lorsque la tâche principale change, au lieu de se baser uniquement sur le premier message.\",\"ja\":\"最初のメッセージだけでなく、あなたのすべてのメッセージからセッションのタイトルを作成し、主なタスクが変わったときに更新します。\",\"pt\":\"Escreve o título de uma sessão a partir de todas as suas mensagens e o atualiza quando a tarefa principal muda, em vez de usar apenas a primeira mensagem.\",\"ru\":\"Составляет заголовок сеанса по всем вашим сообщениям и обновляет его при смене основной задачи, а не только по первому сообщению.\",\"zh\":\"根据你的所有消息生成会话标题,并在主要任务变化时更新,而不是只根据第一条消息生成。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"de\":\"Persistente Terminals\",\"es\":\"Terminales persistentes\",\"fr\":\"Terminaux persistants\",\"ja\":\"永続ターミナル\",\"pt\":\"Terminais persistentes\",\"ru\":\"Постоянные терминалы\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Gives the agent terminals that open as sidebar tabs you can watch and take over by typing in them. Processes keep running between tool calls, so it can start a dev server or REPL and come back later to read output; it can also read terminals you open yourself.\",\"de\":\"Stellt dem Agenten Terminals bereit, die als Seitenleisten-Tabs geöffnet werden; Sie können zusehen und durch Tippen die Kontrolle übernehmen. Prozesse laufen zwischen Tool-Aufrufen weiter, sodass er einen Entwicklungsserver oder eine REPL starten und später die Ausgabe lesen kann; er kann auch Terminals lesen, die Sie selbst öffnen.\",\"es\":\"Proporciona al agente terminales que se abren como pestañas de la barra lateral, que puede observar y controlar escribiendo en ellas. Los procesos siguen ejecutándose entre llamadas a herramientas, de modo que puede iniciar un servidor de desarrollo o un REPL y volver más tarde para leer la salida; también puede leer los terminales que usted abra.\",\"fr\":\"Fournit à l'agent des terminaux qui s'ouvrent comme onglets de la barre latérale ; vous pouvez les observer et en prendre le contrôle en y tapant. Les processus continuent de s'exécuter d'un appel d'outil à l'autre, afin qu'il puisse lancer un serveur de développement ou un REPL et revenir plus tard lire la sortie ; il peut aussi lire les terminaux que vous ouvrez vous-même.\",\"ja\":\"エージェントに、サイドバーのタブとして開くターミナルを提供します。様子を見守ることができ、入力すれば操作を引き継げます。プロセスはツール呼び出し間も実行され続けるため、開発サーバーや REPL を起動して後から出力を読めます。あなたが自分で開いたターミナルも読み取れます。\",\"pt\":\"Fornece ao agente terminais que abrem como abas da barra lateral, que você pode acompanhar e assumir digitando neles. Os processos continuam em execução entre chamadas de ferramentas, para que ele possa iniciar um servidor de desenvolvimento ou um REPL e voltar depois para ler a saída; ele também pode ler os terminais que você mesmo abrir.\",\"ru\":\"Предоставляет агенту терминалы, которые открываются вкладками боковой панели; вы можете наблюдать за ними и взять управление, начав вводить текст. Процессы продолжают работать между вызовами инструментов, поэтому агент может запустить сервер разработки или REPL и позже прочитать вывод; он также может читать терминалы, которые вы открыли сами.\",\"zh\":\"为智能体提供以侧栏标签页打开的终端,你可以旁观,也可以直接在其中键入来接管。进程在多次工具调用之间持续运行,因此它可以启动开发服务器或 REPL,稍后再读取输出;它也能读取你自己打开的终端。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-liveshare\",\"title\":{\"en\":\"LiveShare Session Sharing\",\"de\":\"LiveShare-Sitzungsfreigabe\",\"es\":\"Compartir sesión de LiveShare\",\"fr\":\"Partage de session LiveShare\",\"ja\":\"LiveShare セッション共有\",\"pt\":\"Compartilhamento de sessão do LiveShare\",\"ru\":\"Совместный доступ к сеансу LiveShare\",\"zh\":\"LiveShare 会话分享\"},\"description\":{\"en\":\"Share the current Session with a colleague, who can drive it in a browser, edit files inside the granted workspace when the share allows writes, or branch their own Session from the completed history.\",\"de\":\"Teilt die aktuelle Sitzung mit einer Kollegin oder einem Kollegen, die oder der sie im Browser bedienen, bei erlaubten Schreibzugriffen Dateien im freigegebenen Arbeitsbereich bearbeiten oder aus der abgeschlossenen Historie eine eigene Sitzung abzweigen kann.\",\"es\":\"Comparte la sesión actual con un compañero, que puede dirigirla en un navegador, editar archivos dentro del espacio de trabajo concedido cuando el recurso compartido permite escritura, o crear su propia sesión a partir del historial completado.\",\"fr\":\"Partagez la session en cours avec un collègue, qui peut la piloter dans un navigateur, modifier des fichiers dans l'espace de travail accordé lorsque le partage autorise l'écriture, ou créer sa propre session à partir de l'historique terminé.\",\"ja\":\"現在のセッションを同僚と共有し、ブラウザから操作してもらう、共有が書き込みを許可する場合は付与されたワークスペース内のファイルを編集してもらう、または完了した履歴から自分のセッションを分岐してもらえます。\",\"pt\":\"Compartilhe a sessão atual com um colega, que pode conduzi-la no navegador, editar arquivos dentro do espaço de trabalho concedido quando o compartilhamento permite escrita, ou ramificar a própria sessão a partir do histórico concluído.\",\"ru\":\"Поделитесь текущим сеансом с коллегой: он сможет управлять им в браузере, редактировать файлы в предоставленном рабочем пространстве, если общий доступ разрешает запись, или ответвить собственный сеанс из завершённой истории.\",\"zh\":\"把当前会话分享给同事,对方可在浏览器中驱动该会话、在分享允许写入时修改授权工作区内的文件,或从已完成历史分支出自己的会话。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"de\":\"Sitzungssuche\",\"es\":\"búsqueda de sesión\",\"fr\":\"Recherche de sessions\",\"ja\":\"セッション検索\",\"pt\":\"Pesquisa de sessão\",\"ru\":\"Поиск сеанса\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Ask about earlier work, such as “How did we fix the pool leak last week?”, and the agent searches your earlier sessions in the same workspace to answer.\",\"de\":\"Fragen Sie nach früherer Arbeit, etwa „Wie haben wir letzte Woche das Pool-Leck behoben?“, und der Agent durchsucht Ihre früheren Sitzungen aus demselben Arbeitsverzeichnis, um zu antworten.\",\"es\":\"Pregunte por trabajo anterior, como “¿Cómo arreglamos la fuga del pool la semana pasada?”, y el agente busca en sus sesiones anteriores del mismo directorio de trabajo para responder.\",\"fr\":\"Posez une question sur un travail antérieur, par exemple « Comment avons-nous corrigé la fuite du pool la semaine dernière ? », et l'agent recherche dans vos sessions antérieures du même répertoire de travail pour répondre.\",\"ja\":\"「先週プールのリークをどう直した?」のように以前の作業について尋ねると、エージェントは同じ作業ディレクトリの過去のセッションを検索して回答します。\",\"pt\":\"Pergunte sobre trabalhos anteriores, como “Como corrigimos o vazamento do pool na semana passada?”, e o agente pesquisa suas sessões anteriores do mesmo diretório de trabalho para responder.\",\"ru\":\"Спросите о прошлой работе, например «Как мы исправили утечку в пуле на прошлой неделе?», и агент выполнит поиск по вашим прошлым сеансам из того же рабочего каталога, чтобы ответить.\",\"zh\":\"询问以往的工作,例如“上周我们是怎么修好连接池泄漏的?”,智能体会搜索同一工作区的历史会话来回答。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"de\":\"Git-Arbeitsbäume\",\"es\":\"árboles de trabajo de git\",\"fr\":\"Arbres de travail Git\",\"ja\":\"Git ワークツリー\",\"pt\":\"Árvores de trabalho Git\",\"ru\":\"Рабочие деревья Git\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Ask for work on a new branch, and the agent creates a separate Git worktree and continues there, leaving your current checkout as it is.\",\"de\":\"Bitten Sie um Arbeit auf einem neuen Branch, und der Agent erstellt einen separaten Git-Worktree und arbeitet dort weiter, während Ihr aktueller Checkout unverändert bleibt.\",\"es\":\"Pida trabajar en una rama nueva, y el agente crea un worktree de Git independiente y continúa allí, dejando su checkout actual tal como está.\",\"fr\":\"Demandez à travailler sur une nouvelle branche, et l'agent crée un worktree Git distinct et y poursuit le travail, en laissant votre copie de travail actuelle telle quelle.\",\"ja\":\"新しいブランチでの作業を依頼すると、エージェントは別の Git ワークツリーを作成してそこで作業を続け、現在のチェックアウトはそのまま残します。\",\"pt\":\"Peça para trabalhar em um novo branch, e o agente cria um worktree do Git separado e continua lá, deixando seu checkout atual como está.\",\"ru\":\"Попросите поработать в новой ветке, и агент создаст отдельное рабочее дерево Git и продолжит работу там, оставив вашу текущую рабочую копию как есть.\",\"zh\":\"要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"en\":\"Computer use\",\"de\":\"Computernutzung\",\"es\":\"Uso de la computadora\",\"fr\":\"Utilisation de l'ordinateur\",\"ja\":\"Computer Use\",\"pt\":\"Uso do computador\",\"ru\":\"Использование компьютера\",\"zh\":\"Computer Use\"},\"description\":{\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"de\":\"Unterstützt derzeit macOS; Die Windows-Unterstützung befindet sich in der Entwicklung. Lädt die Computernutzung für bestehende und neue PTC-Sitzungen. Wechseln Sie in den PTC-Modus und erteilen Sie die Berechtigungen für Barrierefreiheit und Bildschirmaufzeichnung, um ihn zu verwenden.\",\"es\":\"Actualmente es compatible con macOS; El soporte para Windows está en desarrollo. Carga el uso de la computadora para sesiones PTC nuevas y existentes. Cambie al modo PTC y otorgue permisos de Accesibilidad y Grabación de pantalla para usarlo.\",\"fr\":\"Prend actuellement en charge macOS ; La prise en charge de Windows est en cours de développement. Charge l'utilisation de l'ordinateur pour les sessions PTC existantes et nouvelles. Passez en mode PTC et accordez les autorisations d’accessibilité et d’enregistrement d’écran pour l’utiliser.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"pt\":\"Atualmente suporta macOS; O suporte Windows está em desenvolvimento. Carrega o uso do computador para sessões PTC novas e existentes. Mude para o modo PTC e conceda permissões de acessibilidade e gravação de tela para usá-lo.\",\"ru\":\"В настоящее время поддерживается macOS; Поддержка Windows находится в разработке. Загружает использование компьютера для существующих и новых сеансов PTC. Переключитесь в режим PTC и предоставьте разрешения на доступность и запись экрана для его использования.\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"de\":\"Benutzerdefinierte Plugin-Indizes\",\"es\":\"Índices de complementos personalizados\",\"fr\":\"Index de plugins personnalisés\",\"ja\":\"カスタムプラグインインデックス\",\"pt\":\"Índices de plug-ins personalizados\",\"ru\":\"Пользовательские индексы плагинов\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins, where you browse and install plugins from plugin indexes you add.\",\"de\":\"Fügt unter „Plugins“ den Tab „Entdecken“ hinzu, in dem Sie Plugins aus von Ihnen hinzugefügten Plugin-Indizes durchsuchen und installieren.\",\"es\":\"Añade la pestaña “Descubrir” a “Complementos”, donde puede explorar e instalar complementos de los índices de complementos que agregue.\",\"fr\":\"Ajoute à « Plugins » un onglet « Découvrez » où vous parcourez et installez des plugins provenant des index de plugins que vous ajoutez.\",\"ja\":\"「プラグイン」に「見つける」タブを追加し、追加したプラグインインデックスからプラグインを閲覧・インストールできるようにします。\",\"pt\":\"Adiciona a guia “Descubra” a “Plug-ins”, onde você navega e instala plug-ins dos índices de plug-ins que adiciona.\",\"ru\":\"Добавляет в «Плагины» вкладку «Откройте для себя», где можно просматривать и устанавливать плагины из добавленных вами индексов плагинов.\",\"zh\":\"在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-forge-bundle\",\"title\":{\"en\":\"Change requests & worktrees\",\"de\":\"Änderungsanfragen und Arbeitsbäume\",\"es\":\"Solicitudes de cambio y árboles de trabajo\",\"fr\":\"Demandes de modification et arbres de travail\",\"ja\":\"変更リクエストとワークツリー\",\"pt\":\"Solicitações de alteração e árvores de trabalho\",\"ru\":\"Запросы на изменение и рабочие деревья\",\"zh\":\"变更请求与工作树\"},\"description\":{\"en\":\"Keep linked GitHub pull requests beside your conversation: follow checks and reviews, inspect diffs, and send feedback to the agent. Review local changes and use a separate worktree to isolate each task. GitHub features require the gh CLI installed and signed in on the host.\",\"de\":\"Verknüpfte GitHub-Pull-Requests neben der Unterhaltung verfolgen: Prüfungen und Reviews ansehen, Code vergleichen und Feedback an den Agenten senden. Lokale Änderungen prüfen und Aufgaben mit separaten Arbeitsbäumen isolieren. GitHub-Funktionen erfordern eine installierte gh-CLI und eine Anmeldung auf dem Host.\",\"es\":\"Sigue los pull requests de GitHub vinculados a la conversación: consulta comprobaciones y revisiones, compara código y envía comentarios al agente. Revisa cambios locales y aísla cada tarea en un árbol de trabajo separado. Las funciones de GitHub requieren instalar la CLI gh e iniciar sesión en el equipo anfitrión.\",\"fr\":\"Suivez les pull requests GitHub liées à votre conversation : consultez les vérifications et les revues, comparez le code et transmettez vos remarques à l’agent. Examinez les modifications locales et isolez chaque tâche dans un arbre de travail séparé. Les fonctions GitHub nécessitent la CLI gh installée et une connexion sur l’hôte.\",\"ja\":\"会話のサイドバーで関連付けられた GitHub PR をまとめて確認できます。チェックやレビューの状況、コードの差分を確認し、修正案をエージェントに渡せます。ローカルの変更確認や、独立したワークツリーによるタスクの分離にも対応します。GitHub 機能にはホストでの gh CLI のインストールとログインが必要です。\",\"pt\":\"Acompanhe os pull requests do GitHub vinculados à conversa: consulte verificações e revisões, compare código e envie comentários ao agente. Revise alterações locais e isole cada tarefa em uma árvore de trabalho separada. Os recursos do GitHub exigem a CLI gh instalada e uma sessão iniciada no host.\",\"ru\":\"Следите за связанными с беседой pull request GitHub: проверками, рецензиями и различиями в коде, а затем передавайте замечания агенту. Просматривайте локальные изменения и изолируйте задачи в отдельных рабочих деревьях. Для функций GitHub требуется установить CLI gh и войти в аккаунт на хосте.\",\"zh\":\"在会话侧栏集中跟踪关联的 GitHub PR:查看检查状态、评审意见与代码差异,并把修改建议交给智能体继续处理。也可查看本地改动,用独立工作树隔离任务。GitHub 功能需要本机安装 gh 并登录。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"de\":\"Claude Code-Subagent\",\"en\":\"Claude Code subagent\",\"es\":\"Subagente Claude Code\",\"fr\":\"Sous-agent Claude Code\",\"ja\":\"Claude Code サブエージェント\",\"pt\":\"Claude Code subagente\",\"ru\":\"Субагент Claude Code\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Claude Code, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"es\":\"Delegue una tarea a Claude Code utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Claude Code à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Claude Code usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Claude Code, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"de\":\"Codex-Subagent\",\"en\":\"Codex subagent\",\"es\":\"Subagente Codex\",\"fr\":\"Sous-agent Codex\",\"ja\":\"Codex サブエージェント\",\"pt\":\"Codex subagente\",\"ru\":\"Субагент Codex\",\"zh\":\"Codex 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Codex, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"es\":\"Delegue una tarea a Codex utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Codex à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Codex usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Codex, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"propose_action"}],"role":"developer","id":"{{message:8}}"},"headerSeq":20},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_creation_card","name":"propose_action","arguments":"{\"action\":\"create_personalized_page\",\"title\":\"Counter\",\"brief\":\"Need: Keep a small counter visible across conversations.\\nLocation: A card titled Today’s achievements in the right sidebar.\\nControls: Increment and reset buttons beside the current value.\\nStorage: Save changes in browser storage across navigation and refresh.\\nAppearance: Follow the existing sidebar theme in light and dark mode.\\nCompletion: Both controls work and reopening restores the saved value.\",\"details\":{\"slot\":\"sidebar-right\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"stream":[{"type":"chunk","time":1792674450652,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792674450652,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_creation_card","name":"propose_action","arguments":"{\"action\":\"create_personalized_page\",\"title\":\"Counter\",\"brief\":\"Need: Keep a small counter visible across conversations.\\nLocation: A card titled Today’s achievements in the right sidebar.\\nControls: Increment and reset buttons beside the current value.\\nStorage: Save changes in browser storage across navigation and refresh.\\nAppearance: Follow the existing sidebar theme in light and dark mode.\\nCompletion: Both controls work and reopening restores the saved value.\",\"details\":{\"slot\":\"sidebar-right\"}}"}}},{"type":"chunk","time":1792674450652,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_creation_card","name":"propose_action","arguments":"{\"action\":\"create_personalized_page\",\"title\":\"Counter\",\"brief\":\"Need: Keep a small counter visible across conversations.\\nLocation: A card titled Today’s achievements in the right sidebar.\\nControls: Increment and reset buttons beside the current value.\\nStorage: Save changes in browser storage across navigation and refresh.\\nAppearance: Follow the existing sidebar theme in light and dark mode.\\nCompletion: Both controls work and reopening restores the saved value.\",\"details\":{\"slot\":\"sidebar-right\"}}"}} diff --git a/snapshots/web/creation-recommendations/tool-schemas.expected.json b/snapshots/web/creation-recommendations/tool-schemas.expected.json index c2a0dcee64..3c82da7581 100644 --- a/snapshots/web/creation-recommendations/tool-schemas.expected.json +++ b/snapshots/web/creation-recommendations/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -1220,22 +1204,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/web/creator-persona/tool-schemas.expected.json b/snapshots/web/creator-persona/tool-schemas.expected.json index e08b7ab898..09c4043629 100644 --- a/snapshots/web/creator-persona/tool-schemas.expected.json +++ b/snapshots/web/creator-persona/tool-schemas.expected.json @@ -263,22 +263,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index 1f92009d96..eee574186f 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/snapshots/web/fresh-round-trip/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/web/official-subagent-bundle/system-prompt.expected.md b/snapshots/web/official-subagent-bundle/system-prompt.expected.md index 853d504a03..c50bd1640e 100644 --- a/snapshots/web/official-subagent-bundle/system-prompt.expected.md +++ b/snapshots/web/official-subagent-bundle/system-prompt.expected.md @@ -129,11 +129,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -433,9 +428,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/web/optional-string-editor/system-prompt.expected.md b/snapshots/web/optional-string-editor/system-prompt.expected.md index 1ae9c46211..fe757ece2c 100644 --- a/snapshots/web/optional-string-editor/system-prompt.expected.md +++ b/snapshots/web/optional-string-editor/system-prompt.expected.md @@ -129,11 +129,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -439,9 +434,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { diff --git a/snapshots/web/plugin-recommendations/session.v4.jsonl b/snapshots/web/plugin-recommendations/session.v4.jsonl index 2eebfabd89..0fb5b5e921 100644 --- a/snapshots/web/plugin-recommendations/session.v4.jsonl +++ b/snapshots/web/plugin-recommendations/session.v4.jsonl @@ -11,7 +11,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following information describes your current session:\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names.\n\nCurrent DeepSeek Harness implementation checkout: \"{{sourceRoot}}\".\n\nCurrent DeepSeek Harness Web GUI URL: \"{{webUrl}}\".\n"}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: \"{{sourceRoot}}\"."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: \"{{webUrl}}\"."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `dsh-doc`: Use when a task needs facts about DeepSeek Harness (DSH) itself, such as how a feature, setting, plugin, agent preset, tool, or Cordis API of the running installation works; when the user asks to design, review, build, install, enable, or debug a DSH plugin, bundle, MCP connection, agent preset, plugin icon or detail page, or a DSH tool definition or skill; and when upgrading a plugin or profile to a newer DSH release. Loading it grants no permission to change files or the profile.\n- `office`: Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n- `official-plugins`: Check DSH’s optional plugins when the user asks how to do or use something in this app, whether it can, or says a capability is missing or should change: tools, input, thinking or title display, session search, terminals, worktrees, agent teams, desktop control, automation, access, approvals, badges or signatures, plugins or plugin sources. Not a request to develop DSH, even in its own repository: load before reading files or source. Not for ordinary tasks, quoted wishes or declined offers.\n- `personalized-pages`: Plan an optional page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace). Load this before reading any files or source; it says how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined offers.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"dsh-doc","description":"Use when a task needs facts about DeepSeek Harness (DSH) itself, such as how a feature, setting, plugin, agent preset, tool, or Cordis API of the running installation works; when the user asks to design, review, build, install, enable, or debug a DSH plugin, bundle, MCP connection, agent preset, plugin icon or detail page, or a DSH tool definition or skill; and when upgrading a plugin or profile to a newer DSH release. Loading it grants no permission to change files or the profile."},{"name":"office","description":"Create or edit Office files; inspect Office files and PDFs. Load before rendering, conversion, or recalculation to enable office_render, office_convert, and office_recalculate next step."},{"name":"office-docx","description":"Create, read, edit, and validate Word documents (.docx)."},{"name":"office-pptx","description":"Create, read, edit, and validate PowerPoint presentations (.pptx)."},{"name":"office-xlsx","description":"Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx)."},{"name":"official-plugins","description":"Check DSH’s optional plugins when the user asks how to do or use something in this app, whether it can, or says a capability is missing or should change: tools, input, thinking or title display, session search, terminals, worktrees, agent teams, desktop control, automation, access, approvals, badges or signatures, plugins or plugin sources. Not a request to develop DSH, even in its own repository: load before reading files or source. Not for ordinary tasks, quoted wishes or declined offers."},{"name":"personalized-pages","description":"Plan an optional page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace). Load this before reading any files or source; it says how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined offers."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: {{timeContextTimestamp}}\nBrowser time zone for this request: {{clientTimeZone}}. Interpret otherwise-unqualified dates and times in this zone.\nElapsed since the preceding model-visible message: {{elapsed}}"}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: {{timeContextTimestamp}}\nBrowser time zone for this request: {{clientTimeZone}}. Interpret otherwise-unqualified dates and times in this zone.\nElapsed since the preceding model-visible message: {{elapsed}}"}]},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"session/title","data":{"title":"I want to dictate long","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_plugin_skill","name":"skill","arguments":"{\"name\":\"official-plugins\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1792678188343,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792678188343,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_plugin_skill","name":"skill","arguments":"{\"name\":\"official-plugins\"}"}}},{"type":"chunk","time":1792678188344,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -19,7 +19,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_plugin_skill"},"toolCallId":"call_plugin_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-official-plugins\".\nLoad referenced resources only as needed.\n\n\n\n# Official plugins\n\nUse this workflow in the main conversation when the user wants DSH itself to work differently or gain an ability. The catalog at the end lists DSH's built-in optional plugins and whether each is enabled. A `propose_action` call shows an Enable card below your answer; nothing changes until the user clicks Enable. Match from the catalog descriptions alone: do not read files, configuration or source, call plugin management tools, or change settings yourself.\n\n## When to offer\n\nOffer one disabled plugin when the user's own words ask for an outcome its description provides. Match the outcome, not keywords. When a plugin covers only part of the wish, say the limit in one sentence: thinking translation translates the displayed thinking and does not change the language the model reasons in.\n\nOffer no card in these cases:\n\n- The plugin is already enabled: say that it is on and how to use it.\n- The wish appears inside quoted text, pasted content, a translation request, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks you not to recommend anything, or has declined: stay silent on plugins for the rest of this conversation.\n- No entry fits, or the need is too vague to match: answer normally or ask one question; do not stretch a description.\n- Something DSH already shows covers it, such as the sidebar search for finding a session by keyword yourself. Letting the agent search and read past sessions is a different need.\n\n## One card per turn\n\nA turn shows at most one card across every card skill. When the user asks for several things, follow an explicit priority; otherwise prefer an existing plugin over building a new page. Mention the other needs in one sentence, without promising a later card.\n\n## Offer without enabling\n\nTo offer, call `propose_action` once, in the same turn as loading this skill, with `action: \"enable_official_plugin\"`, `title` set to the plugin's catalog title in the user's language, `brief` set to one sentence in the user's language that ties the plugin to their need, and `details` holding `name`, the exact catalog name. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer: answer any direct question; the call's result says how to end the answer. The card shows only the plugin's catalog title and description, so the answer says in one sentence, in the user's language, how the plugin meets their need; `brief` is that same sentence. Do not narrate the call, repeat the catalog description, add setup steps the catalog does not state, claim the plugin is enabled, or ask the user to report back. The card opens the plugin's details; enabling applies to every conversation and can be undone there. The user's choice reaches you later as a status notice; enabling a plugin authorizes nothing beyond it.\n\n## Official plugins\n\n[{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"de\":\"Spracheingabe\",\"es\":\"Entrada de voz\",\"fr\":\"Saisie vocale\",\"ja\":\"音声入力\",\"pt\":\"Entrada de voz\",\"ru\":\"Голосовой ввод\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"de\":\"Transkribieren Sie Aufnahmen lokal mit SenseVoice; Für die erste Verwendung müssen Abhängigkeiten installiert werden.\",\"es\":\"Transcribe grabaciones localmente con SenseVoice; El primer uso requiere instalar dependencias.\",\"fr\":\"Transcrivez les enregistrements localement avec SenseVoice ; la première utilisation nécessite l'installation de dépendances.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"pt\":\"Transcreva gravações localmente com SenseVoice; o primeiro uso requer a instalação de dependências.\",\"ru\":\"Транскрибируйте записи локально с помощью SenseVoice; при первом использовании требуется установка зависимостей.\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["apply_patch","ask_user_question","bash","create_goal","edit","get_goal","glob","grep","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_edit","schedule_list","schedule_view","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} {"type":"developer/message","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"propose_action"}],"role":"developer","id":"{{message:8}}"},"headerSeq":20},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_plugin_card","name":"propose_action","arguments":"{\"action\":\"enable_official_plugin\",\"title\":\"Voice input\",\"brief\":\"Speak long requests instead of typing them; transcription runs on this computer.\",\"details\":{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"stream":[{"type":"chunk","time":1792678188573,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792678188573,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_plugin_card","name":"propose_action","arguments":"{\"action\":\"enable_official_plugin\",\"title\":\"Voice input\",\"brief\":\"Speak long requests instead of typing them; transcription runs on this computer.\",\"details\":{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\"}}"}}},{"type":"chunk","time":1792678188573,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_plugin_card","name":"propose_action","arguments":"{\"action\":\"enable_official_plugin\",\"title\":\"Voice input\",\"brief\":\"Speak long requests instead of typing them; transcription runs on this computer.\",\"details\":{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\"}}"}} diff --git a/snapshots/web/plugin-recommendations/tool-schemas.expected.json b/snapshots/web/plugin-recommendations/tool-schemas.expected.json index c2a0dcee64..3c82da7581 100644 --- a/snapshots/web/plugin-recommendations/tool-schemas.expected.json +++ b/snapshots/web/plugin-recommendations/tool-schemas.expected.json @@ -250,22 +250,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", @@ -1220,22 +1204,6 @@ ] } }, - { - "name": "interrupt_agent", - "description": "Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "description": "The name of an agent created under you: your direct child or a deeper descendant." - } - }, - "required": [ - "agent" - ] - } - }, { "name": "job_kill", "description": "Request cancellation of a running background job.", diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 2453375147..9e8a34b7e5 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -129,11 +129,6 @@ interface ToolArgsMap { /** One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported. */ include?: string; } & Record; - /** Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. */ - interrupt_agent: { - /** The name of an agent created under you: your direct child or a deeper descendant. */ - agent: string; - } & Record; /** Request cancellation of a running background job. */ job_kill: { /** Job id returned by the tool that started the background work. */ @@ -422,9 +417,6 @@ interface ToolOutputMap { line: string; }[]; }; - interrupt_agent: { - accepted: boolean; - }; job_kill: { outcome: "cancellation-requested" | "already-finished"; job: { From e426855e0359491b42915d07e0f98105e5cf387a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 15:01:16 +0800 Subject: [PATCH 085/149] test(cua-macos): distinguish process delivery from AppKit repeat handling --- packages/experimental/cua-macos/README.i18n.yaml | 4 ++-- packages/experimental/cua-macos/README.md | 4 ++-- packages/experimental/cua-macos/README.zh.md | 4 ++-- packages/experimental/cua-macos/native/README.i18n.yaml | 4 ++-- packages/experimental/cua-macos/native/README.md | 2 +- packages/experimental/cua-macos/native/README.zh.md | 2 +- .../cua-macos/native/Sources/CuaNative/HeldKeys.swift | 2 +- .../cua-macos/native/tests/capabilities_e2e.py | 9 ++++++--- packages/experimental/cua-macos/src/guidance.ts | 2 +- snapshots/session/cua-native/system-prompt.expected.md | 2 +- snapshots/session/cua-ptc/system-prompt.expected.md | 2 +- .../ptc-script-retained/system-prompt.expected.md | 2 +- 12 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 1be5a7abd9..7220e21ad2 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: 83d4eb62d0d0db2f - zh: 3afde1c3b8c25211 + en: 30fefafc1915db8a + zh: e819f1e722c0d614 /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: b734a0907747580e zh: 8110c5951b70e100 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 26a218c40d..34975631af 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -81,7 +81,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper `pressKey(key, modifiers?)` presses and releases one key. For shortcuts, pass held modifiers separately, as in `pressKey('a', ['command'])`. Pressing a modifier itself, as in `pressKey('shift')`, sets its flag on press and clears it on release. A single `+` or `plus` sends Shift+=. The macOS helper accepts `command`/`cmd`, `shift`, `option`/`alt`, `control`/`ctrl`, and `fn`, and maps them to their physical keys. Key names are platform-defined; keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain their physical key codes. `typeText` sends literal text, including its line breaks and tabs. Use the App object's `pressKey` for a short press. -For a long press, call `app.sendKeyEvent('w', 'down')` inside `run_code`. The first down holds the key across subsequent tool calls. For an ordinary key already held by this execution, each additional down sends one key-down marked as a repeat to the original process. Choose the interval between calls. One up releases the key. Choose when to send each up: after W down, D down and D up, W stays down. When the program returns, exits with an error, or the user pauses it, the helper releases the remaining keys at the processes that received their downs. A later execution cannot release an earlier execution's keys. +For a long press, call `app.sendKeyEvent('w', 'down')` inside `run_code`. The first down holds the key across subsequent tool calls. For an ordinary key already held by this execution, each additional down sends one key-down marked as a repeat to the original process. Choose the interval between calls. A control can filter synthetic repeats, so check its response. One up releases the key. Choose when to send each up: after W down, D down and D up, W stays down. When the program returns, exits with an error, or the user pauses it, the helper releases the remaining keys at the processes that received their downs. A later execution cannot release an earlier execution's keys. Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot. @@ -224,7 +224,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index a40be5399b..e6a0ff313f 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -81,7 +81,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. `pressKey(key, modifiers?)` 按下并松开一个键。使用快捷键时单独传入修饰键,例如 `pressKey('a', ['command'])`。单独调用 `pressKey('shift')` 时,按下事件带有 Shift 标志,释放事件清除该标志。`+` 和 `plus` 都会发送 Shift 和等号。macOS helper 接受 `command`/`cmd`、`shift`、`option`/`alt`、`control`/`ctrl` 和 `fn`,并映射到对应物理键。按键名由平台定义;数字键盘名称(`KP_0` 到 `KP_9`、`KP_Enter` 和数字键盘运算键)保留独立的物理键码。`typeText` 发送字面文本,包括其中的换行和制表符。短按使用 App 对象上的 `pressKey`。 -长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。首次按下后,该键在同一次执行的后续工具调用中保持按下。对自己已按住的普通键再发一次 `down`,原接收进程会收到一次带重复标志的按下事件。调用方决定是否重复和间隔多久,松键只需一次 `up`。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 为本次执行仍按着的键逐一发送松开事件,每个事件都发回原接收进程。后续执行不能释放前一次执行的键。 +长按时,在 `run_code` 中调用 `app.sendKeyEvent('w', 'down')`。首次按下后,该键在同一次执行的后续工具调用中保持按下。对自己已按住的普通键再发一次 `down`,原接收进程会收到一次带重复标志的按下事件。调用方决定是否重复和间隔多久。控件可能过滤模拟的重复事件,可通过控件的实际变化确认效果。松键只需一次 `up`。每个键可分别松开。例如先按 W,再按 D,松开 D 后 W 仍按着。程序正常返回、出错退出或用户暂停时,helper 为本次执行仍按着的键逐一发送松开事件,每个事件都发回原接收进程。后续执行不能释放前一次执行的键。 Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。 @@ -224,7 +224,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. ``` diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 57420b0348..d4cfe23155 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2a69d89b7fc95998 zh: 084575bb31fb40dd /dsh-cua-native-helper/observation-and-input: - en: c6787eda94ba4833 - zh: 855e3c6432918775 + en: 0808c25f70ce6eff + zh: 820864a2b18b8a24 /dsh-cua-native-helper/verification: en: c87945aeec587bf5 zh: e5d69e4927e2fed3 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index 51d0a7b131..4f2283e654 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -105,7 +105,7 @@ Capture and pointer delivery read CG bounds before and after an exact-window AX `typeText` and `pressKey` target the selected process's current focused control, not an exact window. `pressKey` presses the listed modifiers followed by one key, then releases all pressed keys in reverse order, including on cancellation. Keyboard events go directly to the target PID and bypass the global HID queue. Key names are platform-defined; keypad names such as `KP_0` and `KP_Enter` preserve their physical key codes. Background pointer actions may change that keyboard focus. Text injection sends Unicode characters individually and does not use the clipboard. LF, CR and CRLF send one Return; tabs send Tab. Applications apply their normal editing behavior, including indentation and focus navigation. Each `typeText` down/up event is followed by the configured text interval; `pressKey` keeps its own press and release timing, and dispatch completion does not confirm that the receiving application consumed every event. Cancellation can interrupt text or a gesture after partial input; it cannot undo received events. Very long text can exceed the host's operation deadline. Validate observed application state before deciding how to continue. -The Host associates directional key events with one `run_code` execution. On down, the helper retains the selected application, recipient PID and process start time, physical key code, and matching up event. A repeated down from the owner posts one key-down with `keyboardEventAutorepeat` set and reuses the existing record. Repeats, explicit up and cleanup verify the original process identity. A conflicting down or up reports the key and whether another session or another run holds it. On return, error, or user pause, the Host waits for any in-flight native request and asks the helper to release only that execution’s remaining keys. Disconnect cleanup also releases keys owned by the closed connection. +The Host associates directional key events with one `run_code` execution. On down, the helper retains the selected application, recipient PID and process start time, physical key code, and matching up event. A repeated down from the owner posts one key-down with `keyboardEventAutorepeat` set and reuses the existing record. The event is posted to the recipient PID. An AppKit view can receive the initial down and final up while filtering the marked repeat. Repeats, explicit up and cleanup verify the original process identity. A conflicting down or up reports the key and whether another session or another run holds it. On return, error, or user pause, the Host waits for any in-flight native request and asks the helper to release only that execution’s remaining keys. Disconnect cleanup also releases keys owned by the closed connection. The selected helper application needs macOS Accessibility permission for AX state and input, and Screen Recording permission for screenshots. Capability preflight and permission errors do not grant permissions. Capture refuses a window that moves or resizes while the image is being read. Unassociated system panels and accessory-only applications are not selectable. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index eef79a770f..e010295168 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -105,7 +105,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `typeText` 和 `pressKey` 面向所选进程当前获得焦点的控件,不指定窗口。`pressKey` 向目标 PID 依次发送修饰键和一个主键的按下事件,再逆序释放。操作中途取消也会逆序释放已按下的键。键盘事件直接发给目标进程,绕过全局 HID 队列。按键名由平台定义;KP_0 和 KP_Enter 等数字键盘名称保留物理键码。后台鼠标操作可能改变键盘焦点。文本逐个 Unicode 字符输入,不使用剪贴板。LF、CR 和 CRLF 发送一次 Return,制表符发送 Tab。应用保留缩进和焦点导航等正常编辑行为。`typeText` 的每次按下和抬起后等待配置的文本事件间隔;`pressKey` 使用自己的按下与释放节奏,分派完成不证明应用已消费全部事件。取消可能中断部分文本或手势,无法撤销已接收事件。很长的文本可能超过 Host 操作期限。继续输入前应核实应用状态。 -宿主为每次 `run_code` 的 `sendKeyEvent` 附上执行标识。按下时,helper 保存已选应用、接收进程的 PID 与启动时间、物理键码及对应的松开事件。持有方再次按下普通键时,helper 复用原记录,发送一次带 `keyboardEventAutorepeat` 标志的按下事件。重复、显式松键和执行结束时都会核对原进程身份。其他执行尝试按下或松开同一个键时,错误会指出键名,并区分其他会话和同一连接内的其他执行。正常返回、异常退出或用户暂停时,宿主等待在途原生请求结束,再通知 helper 释放该执行持有的其余按键。连接断开时,helper 也会清理该连接持有的按键。 +宿主为每次 `run_code` 的 `sendKeyEvent` 附上执行标识。按下时,helper 保存已选应用、接收进程的 PID 与启动时间、物理键码及对应的松开事件。持有方再次按下普通键时,helper 复用原记录,向接收进程发送一次带 `keyboardEventAutorepeat` 标志的按下事件。AppKit 视图可能只收到首次按下和最终松开,过滤中间的重复事件。重复、显式松键和执行结束时都会核对原进程身份。其他执行尝试按下或松开同一个键时,错误会指出键名,并区分其他会话和同一连接内的其他执行。正常返回、异常退出或用户暂停时,宿主等待在途原生请求结束,再通知 helper 释放该执行持有的其余按键。连接断开时,helper 也会清理该连接持有的按键。 所选 helper 应用需要 macOS 辅助功能权限以读取 AX 和输入,需要屏幕录制权限以截图。能力预检和权限错误不授予权限。截图会拒绝在读取图片期间移动或缩放的窗口。无法确认归属的系统面板及仅后台辅助类型的应用不可选择。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift index 06f47981d2..3d29795127 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/HeldKeys.swift @@ -38,7 +38,7 @@ final class HeldKeys: @unchecked Sendable { func repeatIfHeld(owner: Owner, name: String, code: CGKeyCode, down: CGEvent, post: (CGEvent, Int32) -> Void) throws -> Bool { try lock.withLock { guard let key = pressed.first(where: { $0.owner == owner && $0.code == code }) else { return false } - guard down.type == .keyDown else { + guard key.flag.isEmpty, down.type == .keyDown else { throw RequestParameters.invalid("Modifier '\(name)' is already held. Send 'up' to release it.") } try NativePlatform.verify(key.recipient) diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 66dd004700..fb7a022a83 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -157,9 +157,12 @@ def check_keyboard(call, owner, state_file, state): if explicit_up: key("w", "up") call("releaseHeldKeys", {"runId": "held-input"}) - state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 3) - repeat = [(e["type"], e["keyCode"], e["repeat"]) for e in state["events"][count:]] - assert repeat == [(10, 13, False), (10, 13, True), (11, 13, False)], repeat + state = wait_for_state(state_file, lambda value: any( + e["type"] == 11 and e.get("keyCode") == 13 for e in value["events"][count:])) + view_events = [(e["type"], e["keyCode"], e["repeat"]) for e in state["events"][count:]] + # AppKit can filter a synthetic repeat before NSView.keyDown receives it. + down, repeat, up = (10, 13, False), (10, 13, True), (11, 13, False) + assert view_events in ([down, up], [down, repeat, up]), view_events def main(): diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 839eb56a3c..47d2eb8f5c 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -14,7 +14,7 @@ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { if (method === 'pressKey') return `${entry} -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index a01916dbe2..8a8938c6ab 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -79,7 +79,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index ef5ce31b6d..4276485cd5 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -81,7 +81,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index 9c072b94ea..b83ccdc8f3 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -81,7 +81,7 @@ Available at app or window level depending on the platform. - typeText(text: string) -> ActionResult — type literal text at the app's current focus; line endings send Return and tabs send Tab. - paste(text: string, options?: { format?: 'text' | 'html' }) -> ActionResult — deliver text or HTML through the app's Paste command, restoring prior clipboard contents unless another writer changes them; a Paste failure is surfaced without substituting typeText, and PASTE_NOT_CONSUMED means inspect before any retry. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. -- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call sends one event. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. +- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. From 29ceb385e69fbd1720f1280a773b7e13574a39b2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 15:02:33 +0800 Subject: [PATCH 086/149] docs(cua-macos): document caller-requested key repeats in the upgrade guide --- .../v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml | 4 ++-- docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md | 2 +- .../upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml index 09553adc47..a017e7f82b 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.i18n.yaml @@ -12,5 +12,5 @@ en: e5d1027faa55adba zh: 4b88ad6a70977fb3 /native-cua-application-and-window-api/migration: - en: a0640d4cd1f2e84e - zh: be8d5b5f9d14caf8 + en: a63e88dce467d6ee + zh: fc25bb283ca0eaff diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md index 8f5b092012..2675699a71 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.md @@ -18,6 +18,6 @@ Providers supply startup capabilities alongside their platform guidance. The nat 1. After `cua.getApp(selector)`, select the target from `app.getWindows()` with `app.getWindow(windowId)`. Use `window.getScreenshot()`, `window.click()`, `window.scroll()`, and `window.drag()`. Replace `hover` with `window.mouseMove()`. On macOS, `window.click(id)` sends mouse events at the element center. Switch to `invoke(id)` for the default accessibility action, or `performAction(id, "AXShowMenu")` to request an advertised menu action. 2. Replace `getAXState()` with `getAccessibilityTree()`. Replace `getAXStateAndScreenshot()` with separate tree and screenshot reads. Selection reads the initial tree silently for a window, and for an app when `appLevelAccessibility` is true. Read the tree explicitly to display it. 3. Pass the opaque string ID from the latest tree wherever you passed a numeric element index. In raw requests use `elementId` with `snapshotId`. Replace `performSecondaryAction` with `listActions(id)` and `performAction(id, exactName, argument?)`, or use `invoke(id)` for the default action. `setValue` accepts the provider’s supported JSON values. On macOS, `performAction(id, 'AXRaise')` performs the advertised raise action. Call `app.activate()` first when the application must become active. -4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`. On macOS, call it inside one `run_code` to hold a key across tool calls. Returning, throwing or pausing releases any remaining keys. If your script handles unknown macOS key names as `NATIVE_ERROR`, handle `INVALID_ARGUMENT` instead. Correct the key name before retrying. +4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`. On macOS, call it inside one `run_code` to hold a key across tool calls. To request one repeat, send another down for a non-modifier key held by that execution. Returning, throwing or pausing releases any remaining keys. If your script handles unknown macOS key names as `NATIVE_ERROR`, handle `INVALID_ARGUMENT` instead. Correct the key name before retrying. 5. Read `dispatched` from action results, then observe the application to verify the outcome. `invoke` returns `dispatched: false` when the element has no default action. Remove reads of the old `verified` field and of `pid` on selected objects or window records. Use `listApps()` or running `findApps()` matches when you need a PID. 6. For a custom provider, replace `CuaBackend.description` with `capabilities` and pass `guidance` and `permissionParameters` to `installCua`. Rebuild any custom `helperPath` against the [operation schema](../../../../packages/experimental/cua/src/protocol.ts) and [tagged permission response](../../../../packages/experimental/cua/src/access-types.ts). Restart the provider, inspect its advertised methods and keyboard scopes, and open the Computer Use permission page to verify its platform controls. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md index 7580de0836..b4551f624b 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-native-platform/guide.zh.md @@ -18,6 +18,6 @@ CUA 脚本在指定窗口上截图和操作鼠标。App 和 Window 对象共享 1. 调用 `cua.getApp(selector)` 后,从 `app.getWindows()` 返回的列表选定目标,再调用 `app.getWindow(windowId)`。在窗口上调用 `getScreenshot()`、`click()`、`scroll()` 和 `drag()`,将原有的 `hover` 调用改为 `window.mouseMove()`。在 macOS 上,`window.click(id)` 在元素中心发送鼠标事件。需要执行默认辅助功能动作时,改用 `invoke(id)`。需要打开控件菜单且控件声明了 `AXShowMenu` 时,调用 `performAction(id, "AXShowMenu")`。 2. 将 `getAXState()` 改为 `getAccessibilityTree()`,将 `getAXStateAndScreenshot()` 拆成读取树和截图两次调用。选择窗口时会静默读取初始树。选择应用时,只有 `appLevelAccessibility` 为真才读取。要显示树,请显式读取。 3. 将数字元素索引改为从最新树中取得的字符串 ID。直接发送请求时使用 `elementId` 和 `snapshotId`。原先调用 `performSecondaryAction` 的地方,先用 `listActions(id)` 取得完整动作名,再调用 `performAction(id, exactName, argument?)`。要执行默认动作,可调用 `invoke(id)`。`setValue` 接受提供方支持的 JSON 值。 在 macOS 上,`performAction(id, 'AXRaise')` 只执行元素声明的抬起窗口动作。需要把应用切到前台时先调用 `app.activate()`。 -4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。在 macOS 上,同一次 `run_code` 中可调用此方法跨工具调用保持按键。执行返回、异常退出或用户暂停时会释放仍按着的键。如果脚本用 `NATIVE_ERROR` 识别 macOS 上的未知键名,请改为处理 `INVALID_ARGUMENT`,并在重试前修正键名。 +4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。在 macOS 上,同一次 `run_code` 中可调用此方法跨工具调用保持按键。如果需要一次重复按下,可在这次执行中对已按住的普通键再发一次 `down`。执行返回、异常退出或用户暂停时会释放仍按着的键。如果脚本用 `NATIVE_ERROR` 识别 macOS 上的未知键名,请改为处理 `INVALID_ARGUMENT`,并在重试前修正键名。 5. 从动作结果读取 `dispatched`,再观察应用确认效果。元素没有默认动作时,`invoke` 返回 `dispatched: false`。移除读取旧字段 `verified` 的代码,以及对已选对象和窗口记录中 `pid` 的读取。需要 PID 时,使用 `listApps()` 或 `findApps()` 返回的运行中应用记录。 6. 自定义提供方需将 `CuaBackend.description` 改为 `capabilities`,并向 `installCua` 传入 `guidance` 和 `permissionParameters`。如配置了 `helperPath`,请按[操作协议](../../../../packages/experimental/cua/src/protocol.ts)和[带平台标记的权限响应](../../../../packages/experimental/cua/src/access-types.ts)重新构建 helper。重启提供方,检查它声明的方法和键盘输入范围,再打开 Computer Use 权限页确认平台控件。 From 77abacb4d2508f62eb0ee0f05a2d18e5c500d5ea Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 15:12:17 +0800 Subject: [PATCH 087/149] docs(cua): guide large accessibility tree searches in PTC --- packages/experimental/cua-macos/README.md | 2 +- packages/experimental/cua-macos/README.zh.md | 2 +- packages/experimental/cua-macos/src/guidance.ts | 1 + packages/experimental/cua/src/guidance.ts | 2 +- snapshots/session/cua-native/system-prompt.expected.md | 2 +- snapshots/session/cua-ptc/system-prompt.expected.md | 2 +- snapshots/session/ptc-script-retained/system-prompt.expected.md | 2 +- 7 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 46b035112e..f3d84f587e 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -200,7 +200,7 @@ Setup and discovery: Available at app or window level depending on the platform. Element ids are opaque strings from the latest successful tree; the object facade keeps one current snapshot per app across its repeated app and window objects, a new read replaces it, and a failed read invalidates the previous one. Repeated strings can appear for different elements, so do not reuse an old id blindly, and element operations do not fall back to pointer input or typing. -- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the full tree text; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. +- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the tree within the provider limits; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current. - listActions(elementId: string) -> string[] — list the actions an element advertises. - invoke(elementId: string) -> ActionResult — invoke the element's default action; dispatched is false when it has none. - performAction(elementId: string, action: string, argument?: JsonValue) -> ActionResult — perform an observed action with an optional direct JSON argument. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 341796b8c4..48c85aeeac 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -200,7 +200,7 @@ Setup and discovery: Available at app or window level depending on the platform. Element ids are opaque strings from the latest successful tree; the object facade keeps one current snapshot per app across its repeated app and window objects, a new read replaces it, and a failed read invalidates the previous one. Repeated strings can appear for different elements, so do not reuse an old id blindly, and element operations do not fall back to pointer input or typing. -- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the full tree text; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. +- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the tree within the provider limits; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current. - listActions(elementId: string) -> string[] — list the actions an element advertises. - invoke(elementId: string) -> ActionResult — invoke the element's default action; dispatched is false when it has none. - performAction(elementId: string, action: string, argument?: JsonValue) -> ActionResult — perform an observed action with an optional direct JSON argument. diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index f8779651a9..8c481017ef 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -5,6 +5,7 @@ import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are available at app or window level. Simulated keyboard input is available at app level. Simulated mouse input is available at window level. For pointer input on a sheet, select its window ID with app.getWindow(id). For menu items outside a window, use app.invoke(id). findApps searches /Applications, /System/Applications, and ~/Applications. Use the Computer Use permission page to grant the helper application Accessibility and Screen Recording permissions in System Settings. Native window and AX bounds use macOS points, while delivered screenshots carry their own pixel dimensions. show does not switch Spaces, and Stage Manager can present reduced window geometry; explain any required foreground step before calling app.activate() or performing an advertised AXRaise action.' const methodDetails = new Map([ + ['getAccessibilityTree', 'If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current.'], ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 745ccfe9be..d8d7d46660 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -22,7 +22,7 @@ Setup and discovery: Available at app or window level depending on the platform. Element ids are opaque strings from the latest successful tree; the object facade keeps one current snapshot per app across its repeated app and window objects, a new read replaces it, and a failed read invalidates the previous one. Repeated strings can appear for different elements, so do not reuse an old id blindly, and element operations do not fall back to pointer input or typing. -- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the full tree text; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. +- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the tree within the provider limits; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. - listActions(elementId: string) -> string[] — list the actions an element advertises. - invoke(elementId: string) -> ActionResult — invoke the element's default action; dispatched is false when it has none. - performAction(elementId: string, action: string, argument?: JsonValue) -> ActionResult — perform an observed action with an optional direct JSON argument. diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 0b9c1b29f6..9866c3cb6d 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -57,7 +57,7 @@ Setup and discovery: Available at app or window level depending on the platform. Element ids are opaque strings from the latest successful tree; the object facade keeps one current snapshot per app across its repeated app and window objects, a new read replaces it, and a failed read invalidates the previous one. Repeated strings can appear for different elements, so do not reuse an old id blindly, and element operations do not fall back to pointer input or typing. -- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the full tree text; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. +- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the tree within the provider limits; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current. - listActions(elementId: string) -> string[] — list the actions an element advertises. - invoke(elementId: string) -> ActionResult — invoke the element's default action; dispatched is false when it has none. - performAction(elementId: string, action: string, argument?: JsonValue) -> ActionResult — perform an observed action with an optional direct JSON argument. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index b0af17e860..155edfc13e 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -59,7 +59,7 @@ Setup and discovery: Available at app or window level depending on the platform. Element ids are opaque strings from the latest successful tree; the object facade keeps one current snapshot per app across its repeated app and window objects, a new read replaces it, and a failed read invalidates the previous one. Repeated strings can appear for different elements, so do not reuse an old id blindly, and element operations do not fall back to pointer input or typing. -- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the full tree text; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. +- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the tree within the provider limits; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current. - listActions(elementId: string) -> string[] — list the actions an element advertises. - invoke(elementId: string) -> ActionResult — invoke the element's default action; dispatched is false when it has none. - performAction(elementId: string, action: string, argument?: JsonValue) -> ActionResult — perform an observed action with an optional direct JSON argument. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index fe5f42348b..0041a1a6f7 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -59,7 +59,7 @@ Setup and discovery: Available at app or window level depending on the platform. Element ids are opaque strings from the latest successful tree; the object facade keeps one current snapshot per app across its repeated app and window objects, a new read replaces it, and a failed read invalidates the previous one. Repeated strings can appear for different elements, so do not reuse an old id blindly, and element operations do not fall back to pointer input or typing. -- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the full tree text; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. +- getAccessibilityTree(options?: { emit?: boolean, includeMenus?: boolean, details?: boolean }) -> string — read the tree within the provider limits; includeMenus expands menu contents and details adds identifiers, native bounds, and complete action names. If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current. - listActions(elementId: string) -> string[] — list the actions an element advertises. - invoke(elementId: string) -> ActionResult — invoke the element's default action; dispatched is false when it has none. - performAction(elementId: string, action: string, argument?: JsonValue) -> ActionResult — perform an observed action with an optional direct JSON argument. From fa993abc5ae3789339e160dcb9417812dc970631 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 15:14:59 +0800 Subject: [PATCH 088/149] test(cua-macos): cover runs that only use keyboard shortcuts --- .../cua-macos/tests/provider-lifecycle.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index f65e1a6a61..86bf7b9ba5 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -161,6 +161,20 @@ it('requires an enclosing run_code before sending a directional key', async () = expect(native.request).not.toHaveBeenCalled() }) +it('finishes a shortcut-only run_code without requesting held-key cleanup', async () => { + const f = await setup(undefined, true) + await selectApplication(f) + native.request.mockClear().mockResolvedValue({ dispatched: true }) + const result = await executeModelToolFixture(f.ctx, { agent: f.handle.agent, callId: ToolCallId('shortcut-run'), + name: 'run_code', signal: new AbortController().signal, arguments: { + code: "return await tools.cua({ method: 'pressKey', params: { appHandle: 'app-one', key: 'a', modifiers: ['command'] } });", + description: 'Press Command+A', + } }) + expect(result.isError).toBe(false) + expect(native.request.mock.calls.map(([method]) => method)).toEqual(['pressKey']) + expect(native.cleanup).not.toHaveBeenCalled() +}) + it.each(['return', 'throw', 'pause', 'pause with cleanup error'])('cleans up only the held W when run_code ends by %s', async (ending) => { const f = await setup(undefined, true) await selectApplication(f) From 63e00f87ec8eda5bfc4fa201af524db850917cfa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 15:46:36 +0800 Subject: [PATCH 089/149] fix(forge): keep narrow file header actions inside the card The card's overflow clip cut off the viewed mark and the file menu when a narrow header also held a long path, the changed tag, and a rename badge; before the cards, the list could still scroll sideways to them. The icon, path, changed tag, and badge now share one box that shrinks to any width, so the counts, the viewed mark, and the menu after it stay whole. The path and badge shorten in proportion to their widths, the changed tag is capped at 30% of the box with an ellipsis, and the path keeps its full-path tooltip. A 390px e2e renames a long file, changes it after it is marked viewed, and checks that the mark and menu stay inside the card, the mark still toggles, and hovering the name shows both paths. --- apps/web/tests/forge-panel.e2e.ts | 48 +++++++++++++++++++ .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangesTab.module.css | 24 +++++++++- .../client-ui-forge/src/client/DiffFile.tsx | 16 ++++--- 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index f66c449b52..53d58e3493 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -1844,3 +1844,51 @@ it.skipIf(MODE === 'record')('keeps local viewed marks across reloads, clears a await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }) + +it.skipIf(MODE === 'record')('keeps a narrow file header\'s viewed mark and menu inside its card and its name under the pointer', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-narrow-header', 'normal', { branch: 'feat' }) + const from = 'src/original-review-module-with-a-long-name.ts' + const to = 'src/renamed-review-module-with-an-even-longer-name.ts' + const body = Array.from({ length: 20 }, (_, index) => `export const value${index} = ${index}\n`).join('') + await mkdir(join(project, 'src')) + await writeFile(join(project, from), body) + await git(project, 'add', from) + await git(project, 'commit', '--quiet', '-m', 'Add the module') + // The base holds the old path, so the branch shows a rename. + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await git(project, 'mv', from, to) + await writeFile(join(project, to), `${body}export const extra = 1\n`) + await git(project, 'commit', '--quiet', '-am', 'Rename the module') + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.getByRole('button', { name: '打开右侧边栏', exact: true }).click() + await page.locator('[data-sidebar-right-guide]').getByRole('button', { name: /^代码审查和变更/ }).click() + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + await page.locator('[data-forge-local]').getByRole('button', { name: /^查看本会话的变更/ }).click({ timeout: 20_000 }) + const local = page.locator('[data-forge-changes="local"]') + const name = 'renamed-review-module-with-an-even-longer-name.ts' + const file = local.locator(`[data-forge-diff-file="${to}"]`) + const viewed = file.getByRole('checkbox', { name: `将 ${name} 标记为已审阅`, exact: true }) + await viewed.check({ timeout: 20_000 }) + await writeFile(join(project, to), `${body}export const extra = 2\n`) + await local.getByRole('button', { name: '刷新变更', exact: true }).click() + await expect.poll(() => file.getAttribute('data-viewed'), { timeout: 20_000 }).toBe('changed') + await file.getByText('由 original-review-module-with-a-long-name.ts 重命名', { exact: true }).waitFor() + + // The changed tag, the rename badge, and the long path compete for one narrow header with the counts and actions. + await page.setViewportSize({ width: 390, height: 780 }) + const label = file.locator('[data-forge-file-label]') + const [card, mark, menu, labelBox] = await Promise.all([ + file.boundingBox(), viewed.boundingBox(), file.getByRole('button', { name: `${name} 的更多操作`, exact: true }).boundingBox(), label.boundingBox(), + ]) + if (card === null || mark === null || menu === null || labelBox === null) throw new Error('The header controls must be rendered') + expect(mark.x + mark.width).toBeLessThanOrEqual(card.x + card.width) + expect(menu.x + menu.width).toBeLessThanOrEqual(card.x + card.width) + expect(labelBox.width).toBeGreaterThan(40) + await label.hover() + await page.getByRole('tooltip').filter({ hasText: `${from} → ${to}` }).waitFor() + await viewed.check() + await expect.poll(() => file.getAttribute('data-viewed')).toBe('viewed') + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index d8c84dfd57..c85baa23fe 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 8d017c771840d6de - zh: 0705332c947f825a + en: c7e1b7d87ccccd2e + zh: 42d864b918a6175c /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 7495fe1e0d0ee643 zh: f820a5790ae6e2a7 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 67966cf983..3986c9bffc 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -38,7 +38,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color, with its head branch end filled in the color of running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll. - **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. Cancelled checks have a separate count; a collapsed card names cancellations when no listed check failed. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, whose count ends in `+` when the provider counted only some threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. - **Local changes card** — whenever the `branch` scope changes files, independently of bound change requests, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, which is always the default base below, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered when the code host repository is recognized and no open change request (including drafts) has the checked-out branch as its head, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. -- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. +- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. When the header is narrow, the path, the badges, and Changed since viewed shorten, so the line counts, Viewed, and the menu stay whole. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 0bdfdea790..d5bdf45e9d 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" - **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,head 分支端点填充为检查进行中、已通过或失败对应的颜色;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 - **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。已取消的检查单独计数;列出的检查没有失败项时,折叠卡片显示取消数量。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数(提供方只统计了部分讨论时数字后带 `+`)、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 - **本地变更卡片**——`branch` 范围有文件变更时始终显示,不受已关联变更请求影响。变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`,始终是下文的默认基准)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库、且没有以当前检出分支为源分支的打开中变更请求(含草稿)时,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 -- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。每个文件是一张独立的卡片,带浅色底的标题在滚动时停在顶部。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 +- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。每个文件是一张独立的卡片,带浅色底的标题在滚动时停在顶部。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。标题变窄时,路径、标记和“有新改动”会缩短,行数、“已审阅”和文件菜单始终完整显示。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index ae8a40f3f8..f1603a01d4 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -446,10 +446,23 @@ border-bottom: 0; } +/* The file's own information gives way inside this box, so the counts, the viewed mark, and the menu after it stay + whole at any width. */ +.fileTitle { + display: flex; + flex: 1 1 0; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; +} + .fileIcon { flex: none; } +/* The path shares the shortage with the badge in proportion to their widths, so the longer path keeps the larger part + and stays under the pointer for its full-path tooltip. */ .filePath { display: flex; flex: 0 1 auto; @@ -469,8 +482,16 @@ transform: rotate(-90deg); } +/* Capped rather than shrunk, so a long path never truncates the tag; a longer translation ends in an ellipsis. */ .changedTag { flex: none; + max-width: 30%; +} + +.changedText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } /* The viewed mark: a native checkbox with its label as one bordered chip, peer to the file menu button. */ @@ -526,7 +547,8 @@ } .badge { - flex: none; + flex: 0 1 auto; + min-width: 0; max-width: 50%; overflow: hidden; padding: 0 6px; diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index ad46c66132..a66175afb4 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -458,13 +458,17 @@ export function DiffFile(props: DiffFileProps): ReactNode { > - - - + + + + + + {viewed === 'changed' && ( + {t('viewed.changed')} + )} + + {badge !== '' && {badge}} - {viewed === 'changed' && {t('viewed.changed')}} - - {badge !== '' && {badge}} {!file.binary && }
-
+
{children}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index fb24a887b4..8e0c2b089c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -861,6 +861,8 @@ describe('TextPreview — workspace navigator Factories', () => { return { toggle: header?.lastElementChild?.getAttribute('data-navigator-factory'), navigator: row?.lastElementChild?.getAttribute('data-navigator-factory'), + // The navigator's filled layout hides the body of the row this attribute names. + navigatorRow: row?.hasAttribute('data-files-navigator-row'), } } @@ -876,7 +878,7 @@ describe('TextPreview — workspace navigator Factories', () => { ]) expect(view.container.querySelector('[data-textpreview-state="loading"]')).not.toBeNull() expect(seats(view.container)).toEqual({ - toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true, }) const navigator = view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')! const toggle = view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild @@ -890,7 +892,7 @@ describe('TextPreview — workspace navigator Factories', () => { await settle() expect(view.container.querySelector('[data-textpreview-state="text"]')).not.toBeNull() expect(seats(view.container)).toEqual({ - toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true, }) expect(view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')).toBe(navigator) expect(view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild).toBe(toggle) diff --git a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx index dfe53b637f..dac8bb7f88 100644 --- a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx +++ b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx @@ -41,7 +41,11 @@ export interface FileNavigatorInput { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotFactoryMap { - /** One pane's workspace tree with its width and show/hide state. */ + /** + * One pane's workspace tree with its width and show/hide state. The host renders it as a direct child of + * the content row it shares with the body, and that row carries `data-files-navigator-row`, which the + * filled layout needs to hide the covered body. + */ 'sidebar.files.navigator': { scope: 'session' props: FileNavigatorInput diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css index 4bddc11bb1..315d5afae8 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -74,14 +74,16 @@ border-left: 0; } -/* Covered content stays laid out but cannot receive keyboard focus. */ -:has(> .navigator.fill) > :not(.navigator) { +/* Covered content stays laid out but cannot receive keyboard focus. The rule + names its row through `data-files-navigator-row`: a `:has()` without a + subject makes the browser restyle the whole document after any DOM change. */ +[data-files-navigator-row]:has(> .navigator.fill) > :not(.navigator) { visibility: hidden; } /* navigator-layout `FULLSCREEN_QUERY`: every layout fills the row. */ @media (width < 768px) { - :has(> .navigator:not(.wholePage)) > :not(.navigator) { + [data-files-navigator-row]:has(> .navigator:not(.wholePage)) > :not(.navigator) { visibility: hidden; } diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index f7f5ae385f..0f6b26d7f6 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -113,7 +113,7 @@ export function FilesBody(props: FilesBodyProps): ReactNode { {renderSlot('sidebar.right.tab.files.actions', { absolutePath: cwd })}
-
{navigator}
+
{navigator}
) } diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index a8ef76a271..2cd0aa3d5a 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -56,6 +56,8 @@ describe('FilesBody', () => { expect([...header.querySelectorAll('button:not([data-files-auto-refresh])')].map(button => button.getAttribute('aria-label'))) .toEqual([zh.reload, zh['search.placeholder']]) const content = page.querySelector('[data-files-content]')! + // The filled layout's rule finds the row the navigator covers by this attribute. + expect(content.hasAttribute('data-files-navigator-row')).toBe(true) expect(content.children).toHaveLength(1) const navigator = content.firstElementChild! expect(navigator.getAttribute('data-files-navigator')).toBe(PANE) From 9b456b4e9353e02027b40818ccd26f6cfc5f2285 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:22:24 +0800 Subject: [PATCH 094/149] perf(forge): compare from the recorded base while confirming the head Status snapshots carry the base commit the host recorded with the head (baseSha). ForgeService.diff passes it to the provider when the latest status names the requested head, and forge-github starts the compare read from it while its GraphQL read confirms the head; a stale head or another base cancels and joins the early read. --- docs/subsystems/forge.md | 13 +++-- docs/subsystems/forge.zh.md | 13 +++-- .../forge-github/README.i18n.yaml | 8 +-- packages/experimental/forge-github/README.md | 4 +- .../experimental/forge-github/README.zh.md | 4 +- .../experimental/forge-github/src/graphql.ts | 4 +- .../experimental/forge-github/src/provider.ts | 44 +++++++++++----- .../forge-github/tests/fixtures.ts | 1 + .../forge-github/tests/graphql.spec.ts | 1 + .../forge-github/tests/provider.spec.ts | 51 ++++++++++++++++++- .../tests/loader-composition.spec.ts | 1 + packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 2 +- packages/experimental/forge/README.zh.md | 2 +- packages/experimental/forge/src/index.ts | 7 ++- packages/experimental/forge/src/types.ts | 10 +++- .../experimental/forge/tests/watch.spec.ts | 16 ++++++ .../extensions/tool-cordis/src/api-catalog.ts | 6 +-- 18 files changed, 152 insertions(+), 39 deletions(-) diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index b5156c101f..208c6a5d9f 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -71,9 +71,10 @@ interface ForgeProvider { /** * Read the changes of a change request at head commit `headSha` as the host computes them, as one unified * patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the - * head is another commit, the read resolves to a `stale` failure. + * head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the + * latest status recorded with `headSha`; a provider may start reading from it before it confirms the head. */ - diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise /** Read the text of `path` at `commit` in the change request's repository. */ fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ @@ -199,6 +200,11 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise /** Read the text of `path` at `commit` in the change request's repository. */ fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ @@ -199,6 +200,11 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise --repo //`. Merging calls `PUT repos///pulls//merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`. -`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. +`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. @@ -100,7 +100,7 @@ None. Provider output reaches only host-side watchers. - **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads. - **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated. - **No fork discovery** — head lookups exclude pull requests whose head is in another repository. -- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit. +- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request, and a second REST request when the base commit changed after the latest status. All count against the signed-in account’s rate limit. - **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 205a8f0439..0851001833 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready --repo //`。合并经 `gh api` 调用 `PUT repos///pulls//merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。 -`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 +`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带;拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 @@ -100,7 +100,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 - **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。 - **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。 - **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 -- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。 +- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求;最新状态之后基准提交有变化时再多一次 REST 请求。这些都计入已登录账号的速率配额。 - **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。 diff --git a/packages/experimental/forge-github/src/graphql.ts b/packages/experimental/forge-github/src/graphql.ts index 75922515eb..92bcc4fbb2 100644 --- a/packages/experimental/forge-github/src/graphql.ts +++ b/packages/experimental/forge-github/src/graphql.ts @@ -32,7 +32,7 @@ const THREAD_FIELDS = 'pageInfo { hasNextPage endCursor } nodes { isResolved }' function pullRequestFields(threads: number): string { return `id number url title state isDraft updatedAt closedAt author { login } - headRefName headRefOid baseRefName additions deletions mergeable mergeStateStatus reviewDecision + headRefName headRefOid baseRefName baseRefOid additions deletions mergeable mergeStateStatus reviewDecision reviewThreads(first: ${threads}) { ${THREAD_FIELDS} } commits(last: 1) { nodes { commit { statusCheckRollup { id state contexts(first: ${PAGE_SIZE}) { ${CHECK_FIELDS} @@ -132,6 +132,7 @@ const pullRequestSchema = z.object({ headRefName: z.string(), headRefOid: z.string(), baseRefName: z.string(), + baseRefOid: z.string().refine(isCommit), additions: z.number().int().nonnegative(), deletions: z.number().int().nonnegative(), mergeable: z.string(), @@ -466,6 +467,7 @@ function toSnapshot( headBranch: node.headRefName, headSha: node.headRefOid, baseBranch: node.baseRefName, + baseSha: node.baseRefOid, additions: node.additions, deletions: node.deletions, mergeability: mergeability(node.mergeable), diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index 67e963e3d9..d94a277bb8 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -159,24 +159,44 @@ export class GitHubProvider implements ForgeProvider { * current head and the base commit GitHub recorded with it; a head other * than `headSha` returns `stale`. Otherwise the compare endpoint reads the * patch from the merge base of that base commit and `headSha`, as the pull - * request's own diff does, with both ends fixed. + * request's own diff does, with both ends fixed. Given the `base` the latest + * status recorded with `headSha`, the comparison from it runs while the + * GraphQL read confirms the head; a stale head or another base commit + * cancels that comparison and waits for it to end before the read returns. */ - async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise { const { host, owner, name } = checkedRepository(ref) const failure = this.hostFailure(host) if (failure !== undefined) return failure if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`) - const output = await this.read([ - 'api', 'graphql', '--hostname', host, - '-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`, - ], signal) - if (typeof output !== 'string') return output - const commits = mapPullRequestCommitsResponse(parseJson(output)) - if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' } - if (commits.head.toLowerCase() !== headSha.toLowerCase()) { - return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` } + if (base !== undefined && !isCommit(base)) throw new Error(`forge-github: invalid base commit ${JSON.stringify(base)}`) + const compare = async (from: string, operation: AbortSignal): Promise => + await readComparison(async (args, inner) => await this.read(args, inner), ref.repository, from, headSha, operation) + const speculation = new AbortController() + const early = base === undefined ? undefined : { base, patch: compare(base, AbortSignal.any([signal, speculation.signal])) } + // The early comparison is observed here, so a cancelled or unused one never rejects unhandled. + const settled = early?.patch.then(() => undefined, () => undefined) + const discard = async (): Promise => { + speculation.abort() + await settled + } + try { + const output = await this.read([ + 'api', 'graphql', '--hostname', host, + '-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`, + ], signal) + if (typeof output !== 'string') return output + const commits = mapPullRequestCommitsResponse(parseJson(output)) + if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' } + if (commits.head.toLowerCase() !== headSha.toLowerCase()) { + return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` } + } + if (early?.base.toLowerCase() === commits.base.toLowerCase()) return await early.patch + await discard() + return await compare(commits.base, signal) + } finally { + await discard() } - return await readComparison(async (args, operation) => await this.read(args, operation), ref.repository, commits.base, headSha, signal) } /** Read one file at a commit through the contents API in its raw form. */ diff --git a/packages/experimental/forge-github/tests/fixtures.ts b/packages/experimental/forge-github/tests/fixtures.ts index 66f2f47652..bc5d1297b0 100644 --- a/packages/experimental/forge-github/tests/fixtures.ts +++ b/packages/experimental/forge-github/tests/fixtures.ts @@ -54,6 +54,7 @@ export function pullRequestNode(number: number, overrides: Record { headBranch: 'topic-1', headSha: 'f'.repeat(40), baseBranch: 'master', + baseSha: 'b'.repeat(40), additions: 128, deletions: 14, mergeability: 'mergeable', diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index 1993c8cee6..d923d7425e 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -1,5 +1,5 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts' import type { GhOutput, GhRunner } from '../src/gh.ts' import { PULL_REQUEST_COMMITS_QUERY, THREADS_QUERY } from '../src/graphql.ts' @@ -446,6 +446,55 @@ describe('GitHub provider reads', () => { expect(gh.calls[1]?.at(-1)).toBe(`repos/octo/demo/compare/${base}...${head.toUpperCase()}`) }) + it('starts the comparison from the base the status recorded while it confirms the head', async () => { + const confirmed = Promise.withResolvers() + const gh = fakeGh(args => args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`) + ? confirmed.promise : output('diff --git a/x b/x\n')) + + const read = provider(gh.run).diff(pull(7), head, signal, base) + await vi.waitFor(() => { expect(gh.calls).toHaveLength(2) }) + expect(gh.calls.map(call => call.at(-1))).toContain(`repos/octo/demo/compare/${base}...${head}`) + confirmed.resolve(output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: base.toUpperCase() } } } })) + + await expect(read).resolves.toBe('diff --git a/x b/x\n') + expect(gh.calls).toHaveLength(2) + }) + + it('cancels the early comparison and waits for it when the head moved, the base differs, or the head read fails', async () => { + const moved = 'e'.repeat(40) + for (const [answer, expected, compares] of [ + [output({ data: { repository: { pullRequest: { headRefOid: pushed, baseRefOid: base } } } }), + { code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }, [base]], + [output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: moved } } } }), 'diff --git a/y b/y\n', [base, moved]], + [output('', 1, 'gh: Something went wrong (HTTP 502)'), { code: 'unavailable', message: 'gh: Something went wrong (HTTP 502)' }, [base]], + ] as const) { + const calls: string[][] = [] + const cancelled: string[] = [] + const run: GhRunner = async (args, operation) => { + calls.push([...args]) + if (args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return answer + const range = String(args.at(-1)) + if (range.includes(`${moved}...`)) return output('diff --git a/y b/y\n') + // The early comparison runs until the provider cancels it. + await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) + cancelled.push(range) + throw operation.reason + } + + await expect(provider(run).diff(pull(7), head, signal, base)).resolves.toEqual(expected) + expect(calls.map(call => String(call.at(-1))).filter(target => target.includes('/compare/')) + .map(target => target.split('/compare/')[1]?.split('...')[0])).toEqual(compares) + expect(cancelled).toEqual([`repos/octo/demo/compare/${base}...${head}`]) + } + }) + + it('refuses a base that is not a full commit object name before starting gh', async () => { + const gh = pinned() + + await expect(provider(gh.run).diff(pull(7), head, signal, 'HEAD~1')).rejects.toThrow('forge-github: invalid base commit "HEAD~1"') + expect(gh.calls).toEqual([]) + }) + it('reports a head that moved past the requested commit as stale without reading the diff', async () => { const gh = pinned({ head: pushed, base }) diff --git a/packages/experimental/forge-session/tests/loader-composition.spec.ts b/packages/experimental/forge-session/tests/loader-composition.spec.ts index 486e4f0635..f403e347a4 100644 --- a/packages/experimental/forge-session/tests/loader-composition.spec.ts +++ b/packages/experimental/forge-session/tests/loader-composition.spec.ts @@ -80,6 +80,7 @@ const node = (number) => ({ headRefName: number === 7 ? 'feat' : 'other', headRefOid: 'a'.repeat(40), baseRefName: 'main', + baseRefOid: 'e'.repeat(40), additions: 1, deletions: 0, mergeable: 'MERGEABLE', diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index ce02168f4c..19fb0423c8 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 66ce801db0de565f - zh: 884973fad2ad2540 + en: 4b29b898061bcc4e + zh: fd37c2a8b06e7aaf /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 8727e8cd2b..b316d57a78 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -36,7 +36,7 @@ Mount the registry with at least one provider package, such as the GitHub provid `markReady(ref, signal)` marks a draft ready for review, and `merge(ref, { method, headSha }, signal)` merges a change request only while its head is still `headSha`. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to undefined once the host accepted the change or to a `ForgeActionFailure`: `unauthenticated`, `stale` when the head moved past `headSha`, or `failed` with the host's reason. After either settles, a watched change request is polled at once. Snapshots carry `mergeReady`, whether the host would merge the change request now for the signed-in user, and `mergeMethods`, the methods the repository accepts with that user's usual one first. -`diff(ref, headSha, signal)` reads the change request's unified patch at head commit `headSha` as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. A provider returns a patch only when it describes `headSha`; once the head is another commit, `diff` resolves to `stale`. The first stale read at a head commit polls a watched change request at once so its next status names the current head; later stale reads at that commit wait for the regular poll, which bounds reads while the host's answers briefly disagree. Snapshots carry `unresolvedThreads` with `threadsTruncated`, which marks the count as a lower bound when the provider counted only some review threads. `fileUrl(ref, commit, path)` returns the web page of that file at that commit. +`diff(ref, headSha, signal)` reads the change request's unified patch at head commit `headSha` as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. A provider returns a patch only when it describes `headSha`; once the head is another commit, `diff` resolves to `stale`. The first stale read at a head commit polls a watched change request at once so its next status names the current head; later stale reads at that commit wait for the regular poll, which bounds reads while the host's answers briefly disagree. When the latest status of a watched change request names `headSha` and carries `baseSha`, the base commit the host recorded with that head, `diff` passes it to the provider, which may start reading from it before it confirms the head. Snapshots carry `unresolvedThreads` with `threadsTruncated`, which marks the count as a lower bound when the provider counted only some review threads. `fileUrl(ref, commit, path)` returns the web page of that file at that commit. Providers may implement `commits` and `compare` together. `commits(ref, signal)` lists a change request's commits oldest first, each with its full object name, first parent, title, author, and author time, at one head commit and with a `truncated` flag; `compare(ref, base, head, signal)` reads the unified patch from commit `base` to commit `head`. The registry returns undefined from `commits` and an `unavailable` failure from `compare` when the provider lacks either method, so a consumer can offer commit views only where both exist. diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index 168012126c..e931eec159 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `markReady(ref, signal)` 把草稿标记为可评审;`merge(ref, { method, headSha }, signal)` 只在 head 仍是 `headSha` 时合并变更请求。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错;主机接受时 resolve 为 undefined,否则为 `ForgeActionFailure`:`unauthenticated`、head 已越过 `headSha` 时的 `stale`,或带主机原因的 `failed`。两者结束后,被监听的变更请求会立即重新轮询。快照带有 `mergeReady`(主机现在是否会为已登录用户合并该变更请求)与 `mergeMethods`(仓库接受的合并方式,该用户常用的方式排在最前)。 -`diff(ref, headSha, signal)` 读取托管平台计算出的变更请求在 head 提交 `headSha` 处的统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。提供方只在补丁描述 `headSha` 时返回补丁;head 已是其他提交时,`diff` resolve 为 `stale`。同一 head 提交的第一次 stale 读取会让被 watch 的变更请求立即重新轮询,使下一次状态给出当前 head;之后在该提交上的 stale 读取等待常规轮询,从而在托管平台的应答短暂不一致时限制读取次数。快照携带 `unresolvedThreads` 与 `threadsTruncated`;提供方只统计了部分评审讨论时,后者表示该计数只是下限。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。 +`diff(ref, headSha, signal)` 读取托管平台计算出的变更请求在 head 提交 `headSha` 处的统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。提供方只在补丁描述 `headSha` 时返回补丁;head 已是其他提交时,`diff` resolve 为 `stale`。同一 head 提交的第一次 stale 读取会让被 watch 的变更请求立即重新轮询,使下一次状态给出当前 head;之后在该提交上的 stale 读取等待常规轮询,从而在托管平台的应答短暂不一致时限制读取次数。被 watch 的变更请求的最新状态给出 `headSha` 并携带 `baseSha`(托管平台随该 head 记录的基准提交)时,`diff` 把它传给提供方,提供方可以在确认 head 之前就从它开始读取。快照携带 `unresolvedThreads` 与 `threadsTruncated`;提供方只统计了部分评审讨论时,后者表示该计数只是下限。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。 提供方可以同时实现 `commits` 与 `compare`。`commits(ref, signal)` 在一个 head 提交上按从旧到新列出变更请求的提交,每个提交带完整对象名、第一个父提交、标题、作者与作者时间,并带 `truncated` 标志;`compare(ref, base, head, signal)` 读取从提交 `base` 到提交 `head` 的统一补丁。提供方缺少其中任一方法时,注册表的 `commits` 返回 undefined,`compare` 返回 `unavailable` 失败,因此使用方只在两者都存在时提供按提交查看。 diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index f52f77e818..4040c1f836 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -242,7 +242,8 @@ export class ForgeService extends Service { * computes them. The first `stale` failure at a head commit polls a watched * change request again at once, so its next status names the current head; * later stale reads at the same commit wait for the regular poll, which - * bounds the reads while the host's answers disagree. + * bounds the reads while the host's answers disagree. When the latest status of a watched change request names + * `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head. * @param ref - change request served by a registered provider. * @param headSha - head commit the patch must describe, usually the head of the latest status. * @param signal - cancellation of the read. @@ -250,8 +251,10 @@ export class ForgeService extends Service { * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. */ async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + const latest = this.watches.get(changeRequestKey(ref))?.latest + const base = latest !== undefined && 'snapshot' in latest && latest.snapshot.headSha === headSha ? latest.snapshot.baseSha : undefined const result = await this.invoke(this.registration(ref.repository.forge), signal, - (provider, operation) => provider.diff(ref, headSha, operation)) + (provider, operation) => provider.diff(ref, headSha, operation, base)) const watch = this.watches.get(changeRequestKey(ref)) if (typeof result !== 'string' && result.code === 'stale' && watch !== undefined && watch.staleHead !== headSha) { watch.staleHead = headSha diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 18c8b02e6d..b691744ec1 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -79,6 +79,11 @@ export interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -244,9 +249,10 @@ export interface ForgeProvider { /** * Read the changes of a change request at head commit `headSha` as the host computes them, as one unified * patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the - * head is another commit, the read resolves to a `stale` failure. + * head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the + * latest status recorded with `headSha`; a provider may start reading from it before it confirms the head. */ - diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise /** Read the text of `path` at `commit` in the change request's repository. */ fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 4537d82670..0988bb3ad1 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -223,6 +223,22 @@ describe('forge watch', () => { expect(provider.fetches).toHaveLength(3) }) + it('passes the base commit of the latest status to a diff read at its head only', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1), { baseSha: 'e'.repeat(40) })) + const context = await setup(provider) + collect(context) + await vi.advanceTimersByTimeAsync(0) + const diff = vi.spyOn(provider, 'diff') + const signal = new AbortController().signal + + await context.forge.diff(ref(1), 'a'.repeat(40), signal) + await context.forge.diff(ref(1), 'f'.repeat(40), signal) + await context.forge.diff(ref(2), 'a'.repeat(40), signal) + + expect(diff.mock.calls.map(call => call[3])).toEqual(['e'.repeat(40), undefined, undefined]) + }) + it('polls a watched change request at once after a diff read finds a newer head, and only then', async () => { const provider = new FakeProvider() provider.results.set(key, snapshot(ref(1))) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de37043f87..690a1a7476 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1271,7 +1271,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise', - description: 'Read the changes of a change request at one head commit as its host computes them. The first `stale` failure at a head commit polls a watched change request again at once, so its next status names the current head; later stale reads at the same commit wait for the regular poll, which bounds the reads while the host\'s answers disagree.', + description: 'Read the changes of a change request at one head commit as its host computes them. The first `stale` failure at a head commit polls a watched change request again at once, so its next status names the current head; later stale reads at the same commit wait for the regular poll, which bounds the reads while the host\'s answers disagree. When the latest status of a watched change request names `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.', parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'headSha', description: 'head commit the patch must describe, usually the head of the latest status.' }, { name: 'signal', description: 'cancellation of the read.' }], returns: 'one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.', throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'], @@ -6027,7 +6027,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ChangeRequestSnapshot', - declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly threadsTruncated: boolean;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}', + declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly baseSha?: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly threadsTruncated: boolean;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}', }, { name: 'ChangeRequestState', @@ -6731,7 +6731,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeProvider', - declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n compare?(ref: ChangeRequestRef, base: string, head: string, s /* …truncated — full shape in source */', + declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n compare?(ref: ChangeRequestRef, base: string, /* …truncated — full shape in source */', }, { name: 'ForgeReadiness', From 1754e2ea9843460fbb1546007567166a9cc42009 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:22:26 +0800 Subject: [PATCH 095/149] perf(forge-panel): draw only what changes and what the reader reaches - Read a change request's patch and .gitattributes together. - Publish a panel state only when it differs from the published one. - File sections receive stable values and redraw only when theirs change; tab visibility reaches only the menus that need it. - An expanded file draws its lines once its section comes within reach of the scrollport, and highlights them in idle time in bounded fragments, again only when its segments or its language's grammar change. - A jumped-to file stays aligned while the sections around it draw. --- apps/web/tests/forge-panel.e2e.ts | 4 +- .../client-ui-forge/README.i18n.yaml | 8 +- .../experimental/client-ui-forge/README.md | 8 +- .../experimental/client-ui-forge/README.zh.md | 8 +- .../src/client/ChangesTab.module.css | 6 + .../client-ui-forge/src/client/ChangesTab.tsx | 134 +++++++--- .../client-ui-forge/src/client/DiffFile.tsx | 229 +++++++++++++----- .../src/client/section-reach.ts | 86 +++++++ .../experimental/client-ui-forge/src/index.ts | 41 +++- .../experimental/client-ui-forge/src/panel.ts | 3 + .../tests/changes-tab.client.spec.tsx | 128 +++++++++- .../tests/controller.host.spec.ts | 64 ++++- .../tests/diff-review.client.spec.tsx | 7 +- .../client-ui-forge/tests/harness.ts | 4 +- .../tests/section-reach.client.spec.ts | 112 +++++++++ 15 files changed, 718 insertions(+), 124 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/client/section-reach.ts create mode 100644 packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index b732d7dea5..c7020636d8 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -141,7 +141,7 @@ if (args[0] === 'api' && args[1] === 'graphql') { const pulls = { 7: { id: 'pull-7', number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false, - updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), + updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), baseRefOid: BASE, baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED', reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ isResolved: false }] }, commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: { @@ -152,7 +152,7 @@ if (args[0] === 'api' && args[1] === 'graphql') { 9: { id: 'pull-9', number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED', isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' }, - headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE', + headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', baseRefOid: BASE, additions: 30, deletions: 2, mergeable: 'MERGEABLE', mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-9', state: 'SUCCESS', contexts: { diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5b25741184..a096484521 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: 05ee6d9ba0b8d2c7 zh: 98c988e8f957511f /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 7495fe1e0d0ee643 - zh: f820a5790ae6e2a7 + en: 0afedb76112dfcb8 + zh: 52d5ddc7a4f9d31c /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b @@ -30,8 +30,8 @@ en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 5e29ac36aad15d02 - zh: e46339bb637a69ef + en: 8f8ed6efb2fb7872 + zh: 489730ecefad078b /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 27ff8a01c3..62f8212ad2 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -58,7 +58,7 @@ All files opens a searchable directory tree with the selected file scrolled to i
Implementation internals — click to expand -`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later. +`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` only when the state differs from the published one, so a reinspection that finds the same checkout reaches no follower. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later. Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `rev-parse --path-format=absolute --git-dir`, `symbolic-ref --short HEAD`, `rev-parse --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=`; `diffBases` finds the default fork point as `localDiff` does below, runs `for-each-ref --sort=-committerdate` over `refs/heads/` and `refs/remotes/` within `maxDiffBytes`, leaves out symbolic refs such as `origin/HEAD`, and keeps at most `maxBranches` branches of each kind whose names contain the query. Inspection then sizes the `branch` scope for the local changes card: it finds the fork point, runs `diff --numstat -z -M` against it with the same helper and submodule restrictions as `localDiff` below, and lists untracked files. Every command uses the Session's resolved sandbox mode: `read-only` and `workspace-write` require confinement, while `danger-full-access` runs without a sandbox backend. Commands disable optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches, remove inherited Git selectors and secret-bearing variables, and use their own `gitTimeoutMs` deadline and `maxOutputBytes` limit per stream. The top-level, Git, and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records; a checkout whose Git directory differs from the common one is a linked worktree. The worktree option is usable under the `danger-full-access` sandbox mode, never under `read-only`, and under `workspace-write` only when both directories lie inside the canonical workspace root of the Session's sandbox policy. @@ -69,11 +69,11 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where the first failure cancels the other read and waits for it to end, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; the lines are highlighted with the shared code highlighter when the page is next idle, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| @@ -92,6 +92,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | Base branch menu of the Branch scope | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view | +| [`src/client/section-reach.ts`](src/client/section-reach.ts) | When file sections draw their lines, and idle-time highlighting | | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser | @@ -139,6 +140,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. +- **Find in page over long diffs** — an expanded file draws its lines only once the reader scrolls near it, so the browser's find in page does not match lines of files further down until they are drawn; a page-wide search of the diff needs a find control of the view's own. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. - **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 4aca1d2c38..bc4c12efd9 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -58,7 +58,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后
实现细节——点击展开 -`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 +`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,只在状态与已发布的不同时发布新的 `ForgePanelState`,所以检查结果与原来相同时不会通知任何关注方。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`;`diffBases` 像下文的 `localDiff` 一样找出默认分叉点,在 `maxDiffBytes` 以内对 `refs/heads/` 与 `refs/remotes/` 运行 `for-each-ref --sort=-committerdate`,排除 `origin/HEAD` 等符号引用,每类最多保留 `maxBranches` 个名称包含查询文字的分支。之后,检查会为本地变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,采用与下文 `localDiff` 相同的辅助程序和子模块限制,并列出未跟踪文件。每个命令都使用 Session 解析出的沙箱模式:`read-only` 与 `workspace-write` 需要沙箱,`danger-full-access` 无需沙箱后端。命令禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 @@ -69,11 +69,11 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,先失败的一方会取消另一方并等它结束,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面下次空闲时用共享的代码高亮器高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| @@ -92,6 +92,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | “分支”范围的基准分支菜单 | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 | +| [`src/client/section-reach.ts`](src/client/section-reach.ts) | 文件段何时绘制各行,以及空闲时高亮 | | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 | @@ -139,6 +140,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 - **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index f664c4b2fa..a4ef2ab3e3 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -521,6 +521,12 @@ font: var(--dsw-font-markdown-code-block); } +/* An expanded file the reader has not scrolled near yet: one 22px row (`.line`) per drawn line, so the scroll + range and the positions of the files below stay close to their drawn values. */ +.unreached { + height: calc(var(--forge-rows) * 22px); +} + /* One intrinsic-width track paints every unwrapped unified row across the scrolled width. */ .code:not([data-wrap]):not([data-split]) { display: grid; diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index f57bb937bb..8902be11e2 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -5,6 +5,8 @@ * layout. The view reads the changes when it opens, on each navigation, on * refresh, and when the change request's head or the checkout's `branch` * scope summary changes; a read keeps the previous changes on screen until it settles. + * File sections receive values that keep their identity across renders, so a + * render of the view redraws only the sections whose own values changed. */ import { @@ -39,7 +41,8 @@ import type { ChangesLens, createChangesStore } from './changes-store.ts' import { CommitMenu } from './CommitMenu.tsx' import { HideTestsSwitch, LensControl, LensMenu, LENSES, ViewedProgress } from './ReviewControls.tsx' import { viewedStateOf, type ViewedFiles, type ViewedMarks } from './viewed-files.ts' -import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx' +import { Counts, DiffFile, type DiffFileAction, type DiffFileHandlers } from './DiffFile.tsx' +import { SectionReach, SectionReachContext, ViewVisibleContext } from './section-reach.ts' import { FileTreePane, type FileTreePosition } from './FileTreePane.tsx' import { changedFileTree, filesInTreeOrder } from './file-tree.ts' import { ChangedFilesMenu, type ChangedFilesProps, type ChangedFilesState } from './ChangedFiles.tsx' @@ -145,6 +148,12 @@ const OPEN_LINES = 5000 const NO_GAPS: ReadonlySet = new Set() +/** How long, in milliseconds, a jumped-to file stays at the top while the sections around it draw. */ +const JUMP_PIN_MS = 1000 + +/** Input that scrolls the file list, which ends a jump's pin. */ +const READER_SCROLL = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const + const SCOPE_LABEL: Record = { branch: 'diff.scope.branch', uncommitted: 'diff.scope.uncommitted', @@ -564,7 +573,7 @@ export function ChangesTab({ const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item, allFiles.filter(file => shownBy(file, item, hideTests)).length])) as Record, [allFiles, hideTests]) - const shownSource = data === null ? null : reviewSourceOf(data) + const shownSource = useMemo(() => data === null ? null : reviewSourceOf(data), [data]) const viewedKey = shownSource === null ? '' : viewedSourceOf(shownSource) const marks = useSyncExternalStore(viewedFiles.subscribe, () => viewedFiles.marksOf(viewedKey)) // The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again. @@ -582,9 +591,44 @@ export function ChangesTab({ const expandedByDefault = (file: ForgeDiffFile): boolean => initial.has(file.path) && viewedStateOf(marks, file.path, file.digest) !== 'viewed' const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file) const sections = useRef(new Map()) - const scrollport = useRef(null) + const scrollport = useRef(null) + const reach = useMemo(() => new SectionReach(), []) + const attachScrollport = useCallback((element: HTMLDivElement | null) => { + scrollport.current = element + reach.attach(element) + }, [reach]) // A short final file cannot align with the viewport top; retain its jump until the reader scrolls away. const jumpPosition = useRef<{ readonly data: ChangesData; readonly top: number } | null>(null) + const releasePin = useRef<(() => void) | null>(null) + /** + * Keep a jumped-to section at the top of the scrollport for {@link JUMP_PIN_MS}: the sections around it draw their + * lines after the jump, which moves it. Scrolling input from the reader, another jump, or unmounting ends the pin. + */ + const pinJump = (port: HTMLElement, section: HTMLElement, owner: ChangesData): void => { + releasePin.current?.() + const until = performance.now() + JUMP_PIN_MS + let frame = 0 + const release = (): void => { + cancelAnimationFrame(frame) + for (const type of READER_SCROLL) port.removeEventListener(type, release) + if (releasePin.current === release) releasePin.current = null + } + const keep = (now: number): void => { + if (now > until) { + release() + return + } + const offset = section.getBoundingClientRect().top - port.getBoundingClientRect().top + if (Math.abs(offset) > 1 && port.scrollTop < port.scrollHeight - port.clientHeight - 1) { + section.scrollIntoView({ block: 'start' }) + jumpPosition.current = { data: owner, top: port.scrollTop } + } + frame = requestAnimationFrame(keep) + } + for (const type of READER_SCROLL) port.addEventListener(type, release, { passive: true }) + releasePin.current = release + frame = requestAnimationFrame(keep) + } useLayoutEffect(() => { if (!tab.visible || wanted === null || data === null) return // A file the lens or the test switch hides is shown before the view scrolls to it. @@ -594,10 +638,13 @@ export function ChangesTab({ if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id) return } - sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + const section = sections.current.get(wanted) + section?.scrollIntoView({ block: 'start' }) if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } + if (section !== undefined && scrollport.current !== null) pinJump(scrollport.current, section, data) setWanted(null) }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) + useEffect(() => () => { releasePin.current?.() }, []) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null // A chosen base branch that is gone or shares no history with HEAD offers the automatic fork point instead. @@ -749,6 +796,54 @@ export function ChangesTab({ markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed') } + // What the section handlers and comment actions read when they run; replaced on every render. + const live = useRef({ data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t }) + live.current = { data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t } + const handlers = useMemo((): DiffFileHandlers => ({ + toggle: (file) => { + const byDefault = live.current.expandedByDefault(file) + setChosen(value => ({ ...value, [file.path]: !(value[file.path] ?? byDefault) })) + }, + reveal: (path) => { setRevealed(value => new Set([...value, path])) }, + expand: (file, index) => { + const { data: owner, expand: open } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + if (owner !== null) open(owner, file, index) + }, + view: (file, viewed) => { + const { data: owner, markViewed: mark } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + if (owner !== null) mark(owner, file, viewed) + }, + actions: (file) => { + const { data: owner, fileActions: list } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + return owner === null ? [] : list(owner, file) + }, + section: (path, element) => { + if (element === null) sections.current.delete(path) + else sections.current.set(path, element) + }, + }), []) + const reviewStarted = reviewState.comments.length > 0 + const publishingBlocked = reviewState.uncertain !== null + // Comments on a commit range or on local changes go to the input only; a whole change request also takes GitHub comments. + const publishable = data?.kind === 'change' && data.commits === null + const fileReview = useMemo((): ReviewActions | undefined => shownSource === null ? undefined : { + source: shownSource, + addToInput: addReviewToInput, + records: queue, openLink, reviewStarted, + onQuoted: (selection) => { if (!queue.recordAgent(selection)) live.current.report('review.saveFailed') }, + publishingBlocked, + ...publishable ? { + queue: selection => queue.add(selection), + publish: selection => selection.source.kind === 'change' + ? live.current.publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, + url => queue.published(selection, url)) + : Promise.resolve(live.current.t('review.stale')), + } : {}, + }, [shownSource, addReviewToInput, queue, openLink, reviewStarted, publishingBlocked, publishable]) + let body: ReactNode if (data === null) { if (state === null && snapshot?.disconnected) { @@ -782,7 +877,7 @@ export function ChangesTab({ body =
{orphanRecords}
{t('lens.empty')}
} else { body = ( -
{ if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null @@ -792,40 +887,19 @@ export function ChangesTab({ return ( 0, - onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') }, - publishingBlocked: reviewState.uncertain !== null, - ...data.kind === 'local' || data.commits !== null ? {} : { - queue: selection => queue.add(selection), - publish: selection => selection.source.kind === 'change' - ? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, - url => queue.published(selection, url)) - : Promise.resolve(t('review.stale')), - }, - }} + review={fileReview} expanded={isExpanded(file)} - onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? expandedByDefault(file)) })) }} large={drawnLines(file) > LARGE_FILE_LINES} revealed={revealed.has(path)} - onReveal={() => { setRevealed(value => new Set([...value, path])) }} text={current.texts[path]} open={current.open[path] ?? NO_GAPS} - onExpand={(index) => { expand(data, file, index) }} split={split} wrap={wrap} badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} - actions={fileActions(data, file)} viewed={viewedStateOf(marks, path, file.digest)} - onViewed={(viewed) => { markViewed(data, file, viewed) }} - sectionRef={(element) => { - if (element === null) sections.current.delete(path) - else sections.current.set(path, element) - }} + handlers={handlers} t={t} /> ) @@ -940,7 +1014,9 @@ export function ChangesTab({ {data?.kind === 'local' && data.omitted > 0 && {t('diff.omitted', { count: data.omitted })}} {reviewForm}
- {body} + + {body} + {allFiles.length > 0 && }
diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index ad46c66132..bd0b9ac500 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -1,10 +1,12 @@ /** * One file of the changes view: a sticky header with the path, badges, line * counts, and file actions, then its hunks unified or side by side with the - * unchanged lines between them collapsed into rows that expand them. + * unchanged lines between them collapsed into rows that expand them. A section + * draws its lines once it comes within reach of the scrollport and highlights + * them when the page is next idle; it redraws only when its own props change. */ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import clsx from 'clsx' import { flushSync } from 'react-dom' import { @@ -14,7 +16,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ForgeDiffFile } from '../types.ts' -import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts' +import { drawnLines, segmentsOf, splitRows, type DiffRow, type DiffSegment, type SplitCell, type SplitRow } from './diff-rows.ts' import { codeSideAt, readCodeSelection } from './code-selection.ts' import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' @@ -24,6 +26,7 @@ import reviewCss from './ReviewComposer.module.css' import { FileLabel } from './FileLabel.tsx' import type { ForgePanelKey, NS } from './locales.ts' import type { ViewedState } from './viewed-files.ts' +import { SectionReachContext, ViewVisibleContext, whenIdle } from './section-reach.ts' import css from './ChangesTab.module.css' type Translate = PropsLocale['t'] @@ -39,30 +42,41 @@ export interface DiffFileAction { /** New side of a file: its lines once read, `loading` while a read runs, or undefined before any. */ export type DiffFileText = readonly string[] | 'loading' | undefined +/** + * What every file section of one changes view calls. The view passes the same + * object on every render, so a section redraws only when its own values change. + */ +export interface DiffFileHandlers { + /** Expand or collapse a file. */ + readonly toggle: (file: ForgeDiffFile) => void + /** Show a large file anyway. */ + readonly reveal: (path: string) => void + /** Expand one gap of a file. */ + readonly expand: (file: ForgeDiffFile, index: number) => void + /** Mark a file viewed, which also collapses it, or clear the mark. */ + readonly view: (file: ForgeDiffFile, viewed: boolean) => void + /** Rows of a file's menu, read whenever the menu draws. */ + readonly actions: (file: ForgeDiffFile) => readonly DiffFileAction[] + /** Receive a file's section element, which scrolling to the file targets, or null once it unmounts. */ + readonly section: (path: string, element: HTMLElement | null) => void +} + /** Props of one file section. */ export interface DiffFileProps { - /** Whether the owning tab is visible and may show temporary menus. */ - readonly visible: boolean /** Comment destinations and the source being reviewed. */ - readonly review?: ReviewActions + readonly review?: ReviewActions | undefined /** The file and its hunks. */ readonly file: ForgeDiffFile /** Whether the section shows its lines. */ readonly expanded: boolean - /** Expand or collapse the section. */ - readonly onToggle: () => void /** Whether the file shows the large-file notice instead of its lines until revealed. */ readonly large: boolean /** Whether the user revealed a large file. */ readonly revealed: boolean - /** Show a large file anyway. */ - readonly onReveal: () => void /** The file's new side, which supplies collapsed unchanged lines. */ readonly text: DiffFileText /** Indexes of the expanded gaps. */ readonly open: ReadonlySet - /** Expand one gap. */ - readonly onExpand: (index: number) => void /** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */ readonly split: boolean /** Whether long lines wrap. */ @@ -71,14 +85,10 @@ export interface DiffFileProps { readonly badge: string /** Hover text of the path: both paths of a renamed file, else the path. */ readonly tooltip: string - /** Rows of the file's menu. */ - readonly actions: readonly DiffFileAction[] /** Whether the reader marked this change viewed, or marked an earlier change of the file. */ readonly viewed: ViewedState - /** Mark the file viewed, which also collapses it, or clear the mark. */ - readonly onViewed: (viewed: boolean) => void - /** Receives the section element, which scrolling to the file targets. */ - readonly sectionRef: (element: HTMLElement | null) => void + /** Actions shared by every section of the view. */ + readonly handlers: DiffFileHandlers /** Forge panel copy. */ readonly t: Translate } @@ -103,26 +113,87 @@ export function Counts({ file, t }: { const SIGN: Record = { add: '+', del: '-', context: '' } -/** Highlighted tokens of one side's lines by line number, or undefined while the grammar is unavailable. */ -type SideSpans = ReadonlyMap | undefined +/** Highlighted tokens of one side's lines by line number. */ +type SideSpans = Map -/** A segment ready to draw: collapsed lines, or rows with the tokens of each side. */ -type Prepared = - | { readonly kind: 'gap'; readonly index: number; readonly count: number | null } - | { readonly kind: 'rows'; readonly rows: readonly DiffRow[]; readonly old: SideSpans; readonly new: SideSpans } +/** Highlighted tokens of a file's drawn lines on each side; a line without an entry draws as plain text. */ +interface FileSpans { + readonly old: SideSpans + readonly new: SideSpans +} -/** Highlight one side of a run of rows as one fragment, so tokens spanning lines keep their state. */ -function sideSpans(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): SideSpans { - const numbered: { readonly no: number; readonly text: string }[] = [] - for (const row of rows) { - const no = row[side] - if (no !== null) numbered.push({ no, text: row.text }) +/** Lines highlighted as one fragment; a longer run splits here, where a token spanning the split restarts. */ +const HIGHLIGHT_CHUNK_LINES = 100 + +/** Numbered lines of one side that are highlighted together. */ +interface HighlightChunk { + readonly side: 'old' | 'new' + readonly lines: readonly { readonly no: number; readonly text: string }[] +} + +/** Split each run of rows of each side into fragments of at most {@link HIGHLIGHT_CHUNK_LINES} lines. */ +function highlightChunks(segments: readonly DiffSegment[]): HighlightChunk[] { + const chunks: HighlightChunk[] = [] + for (const segment of segments) { + if (segment.kind === 'gap') continue + for (const side of ['old', 'new'] as const) { + const lines: { readonly no: number; readonly text: string }[] = [] + for (const row of segment.rows) { + const no = row[side] + if (no !== null) lines.push({ no, text: row.text }) + } + for (let at = 0; at < lines.length; at += HIGHLIGHT_CHUNK_LINES) { + chunks.push({ side, lines: lines.slice(at, at + HIGHLIGHT_CHUNK_LINES) }) + } + } } - if (numbered.length === 0) return new Map() - const highlighted = highlighter(numbered.map(line => line.text).join('\n')) - if (highlighted === undefined) return undefined + return chunks +} + +/** Highlight one fragment, so tokens spanning its lines keep their state, into `spans`. */ +function addChunkSpans(spans: FileSpans, chunk: HighlightChunk, highlighter: CodeHighlighter): void { + const highlighted = highlighter(chunk.lines.map(line => line.text).join('\n')) + /* v8 ignore next -- a grammar that reported ready stays registered, so its fragments always tokenize */ + if (highlighted === undefined) return // Shiki omits a final empty token row, so a final empty line gets no tokens. - return new Map(numbered.map((line, index) => [line.no, highlighted[index] ?? []])) + for (const [index, line] of chunk.lines.entries()) spans[chunk.side].set(line.no, highlighted[index] ?? []) +} + +/** + * Highlight a file's drawn lines in idle time, once its language's grammar is ready: each idle period highlights + * fragments while it lasts, at least one, and the lines take their tokens when the last fragment is done. Until + * then, the lines keep the tokens of the previous pass, by line number, and new lines draw as plain text. A grammar + * that loads for another language starts no pass. + */ +function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans | undefined { + const highlighter = useCodeHighlighter(languageForPath(path)) + // The highlighter changes whenever any grammar loads; the empty fragment tells whether this language's is ready. + const ready = highlighter('') !== undefined + const current = useRef(highlighter) + current.current = highlighter + const [spans, setSpans] = useState(undefined) + useEffect(() => { + if (!ready) return + const chunks = highlightChunks(segments) + const pass: FileSpans = { old: new Map(), new: new Map() } + let next = 0 + const step = (deadline: IdleDeadline | undefined): void => { + for (;;) { + const chunk = chunks[next] + if (chunk === undefined) { + setSpans(pass) + return + } + addChunkSpans(pass, chunk, current.current) + next++ + if ((deadline?.timeRemaining() ?? 0) <= 0) break + } + cancel = whenIdle(step) + } + let cancel = whenIdle(step) + return () => { cancel() } + }, [segments, ready]) + return spans } function Code({ text, spans, old, next }: { @@ -143,16 +214,20 @@ function pairKind(pair: SplitRow): 'context' | 'change' { return pair.left !== null && pair.left.kind === 'context' ? 'context' : 'change' } +/** The composer of a new comment, shown with its menus only while the changes tab is visible. */ +function VisibleComposer(props: Omit[0], 'visible'>): ReactNode { + return +} + /** The hunks of one file with their collapsed and expanded unchanged lines. */ -function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { +function Lines({ file, text, open, handlers, split, wrap, review, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { const lines = typeof text === 'object' ? text : undefined const columns = split && file.additions > 0 && file.deletions > 0 const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null let maximum = Math.max(1, lines?.length ?? 0, (lines?.length ?? 0) + file.deletions - file.additions) for (const hunk of file.hunks) maximum = Math.max(maximum, hunk.oldStart + hunk.oldLines - 1, hunk.newStart + hunk.newLines - 1) const style: CSSProperties & { '--forge-number-width': string } = { '--forge-number-width': `calc(${Math.max(2, String(maximum).length)}ch + 12px)` } - const highlighter = useCodeHighlighter(languageForPath(file.path)) const [selection, setSelection] = useState(null) const root = useRef(null) const origin = useRef<{ side: 'L' | 'R'; line: number } | null>(null) @@ -210,9 +285,8 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme window.removeEventListener('pointercancel', finish) } }, [selecting, nativeSelection]) - const prepared = useMemo(() => segmentsOf(file, lines, open).map((segment): Prepared => segment.kind === 'gap' - ? segment - : { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter]) + const segments = useMemo(() => segmentsOf(file, lines, open), [file, lines, open]) + const spans = useFileSpans(file.path, segments) // Returns whether the range was accepted; a range across hunks or outside them keeps the previous selection. const choose = (side: 'L' | 'R', line: number, extend: boolean): boolean => { if (review === undefined || editing) return false @@ -286,15 +360,15 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme )} } - const cell = (value: SplitCell | null, spans: SideSpans, side: 'L' | 'R'): ReactNode => value === null + const cell = (value: SplitCell | null, side: 'L' | 'R'): ReactNode => value === null ? : - {number(side, value.no, true)} + {number(side, value.no, true)} const rows: ReactNode[] = [] let inserted = false - const editor = review === undefined || selection === null || !editing ? null : { const native = window.getSelection() if (root.current?.contains(native?.anchorNode ?? null) === true) native?.removeAllRanges() @@ -315,10 +389,10 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } const endsAt = (old: number | null, next: number | null): boolean => selection !== null && (selection.comment.side === 'L' ? old : next) === selection.comment.endLine - for (const [at, segment] of prepared.entries()) { + for (const [at, segment] of segments.entries()) { if (segment.kind === 'gap') { rows.push() @@ -326,7 +400,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme for (const [index, pair] of splitRows(segment.rows).entries()) { rows.push(
{ extendAdd(event.buttons, pair.left?.no ?? null, pair.right?.no ?? null) }}> - {cell(pair.left, segment.old, 'L')}{cell(pair.right, segment.new, 'R')} + {cell(pair.left, 'L')}{cell(pair.right, 'R')}
) if (!inserted && endsAt(pair.left?.no ?? null, pair.right?.no ?? null)) { rows.push(editor) @@ -341,7 +415,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme rows.push(
{ extendAdd(event.buttons, row.old, row.new) }}> {singleSide !== 'R' && number('L', row.old, row.kind === 'del' && !labeled)}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del' && !labeled)}{SIGN[row.kind]} - +
) if (!inserted && endsAt(row.old, row.new)) { rows.push(editor) @@ -374,8 +448,13 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme }}>{rows} } +/** Whether a file's body draws its hunks line by line rather than a note or a collapsed strip. */ +function drawsLines(file: ForgeDiffFile, large: boolean, revealed: boolean): boolean { + return !file.binary && !file.oversized && file.hunks.length > 0 && (!large || revealed) +} + function Body(props: DiffFileProps): ReactNode { - const { file, large, revealed, onReveal, review, t } = props + const { file, large, revealed, handlers, review, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) @@ -385,7 +464,7 @@ function Body(props: DiffFileProps): ReactNode { if (large && !revealed) { // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. return ( - <>{records} @@ -394,12 +473,20 @@ function Body(props: DiffFileProps): ReactNode { return } -/** File actions live only while their owning tab is visible. */ -function FileMenu({ name, actions, t }: { +/** Props of a file's menu. */ +interface FileMenuProps { readonly name: string - readonly actions: readonly DiffFileAction[] + readonly file: ForgeDiffFile + readonly handlers: DiffFileHandlers readonly t: Translate -}): ReactNode { +} + +/** File actions live only while their owning tab is visible, so hiding the tab closes the menu. */ +function FileMenu(props: FileMenuProps): ReactNode { + return useContext(ViewVisibleContext) ? : null +} + +function OpenableFileMenu({ name, file, handlers, t }: FileMenuProps): ReactNode { const [menuOpen, setMenuOpen] = useState(false) return ( )} > - {actions.map(action => ( + {handlers.actions(file).map(action => ( { @@ -438,13 +525,28 @@ function FileMenu({ name, actions, t }: { } /** - * Render one file of a diff. - * @param props - the file, its section and gap state, layout choices, menu actions, and copy. + * Render one file of a diff. Until the section first comes within reach of the scrollport, an expanded file that + * draws lines shows a box of about their height instead. + * @param props - the file, its section and gap state, layout choices, shared actions, and copy. * @returns the file section. */ -export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props +function DiffFileSection(props: DiffFileProps): ReactNode { + const { file, expanded, large, revealed, badge, tooltip, viewed, handlers, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) + const reach = useContext(SectionReachContext) + const element = useRef(null) + const [reached, setReached] = useState(false) + const waiting = expanded && !reached && reach !== null && drawsLines(file, large, revealed) + useEffect(() => { + /* v8 ignore next -- React attaches the section before it runs effects */ + if (!waiting || element.current === null) return + return reach.observe(element.current, () => { setReached(true) }) + }, [waiting, reach]) + const sectionRef = useCallback((node: HTMLElement | null) => { + element.current = node + handlers.section(file.path, node) + }, [handlers, file.path]) + const placeholder: CSSProperties & { '--forge-rows': number } = { '--forge-rows': drawnLines(file) } return (
@@ -454,7 +556,7 @@ export function DiffFile(props: DiffFileProps): ReactNode { className={css.fileToggle} aria-expanded={expanded} aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })} - onClick={onToggle} + onClick={() => { handlers.toggle(file) }} > @@ -468,12 +570,15 @@ export function DiffFile(props: DiffFileProps): ReactNode { {!file.binary && } - {props.visible && } + - {expanded && } + {expanded && (waiting ?
: )}
) } + +/** One file of a diff, redrawn only when its props change. */ +export const DiffFile = memo(DiffFileSection) diff --git a/packages/experimental/client-ui-forge/src/client/section-reach.ts b/packages/experimental/client-ui-forge/src/client/section-reach.ts new file mode 100644 index 0000000000..d926bd1ee2 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/section-reach.ts @@ -0,0 +1,86 @@ +/** + * Reach of the changes view's file sections: a section's lines mount once the + * section first comes within a margin of the files scrollport, and stay + * mounted, so long diffs draw only what the reader approaches. + */ + +import { createContext } from 'react' + +/** Distance beyond the scrollport's edges within which a section counts as reached. */ +const REACH_MARGIN = '1200px 0px' + +const noop = (): void => {} + +/** + * Watches the file sections of one changes view against its scrollport. Without + * `IntersectionObserver` every section is reached at once. + */ +export class SectionReach { + private observer: IntersectionObserver | undefined + private readonly waiting = new Map void>() + + /** + * Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root. + * @param root - the files scrollport, or null. + */ + attach(root: HTMLElement | null): void { + this.observer?.disconnect() + this.observer = undefined + if (root === null || typeof IntersectionObserver === 'undefined') return + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue + const reached = this.waiting.get(entry.target) + /* v8 ignore next -- the observer reports only sections still waiting, which it stops watching on release */ + if (reached === undefined) continue + this.waiting.delete(entry.target) + observer.unobserve(entry.target) + reached() + } + }, { root, rootMargin: REACH_MARGIN }) + this.observer = observer + for (const element of this.waiting.keys()) observer.observe(element) + } + + /** + * Call `reached` once when `element` comes within reach. + * @param element - a file section inside the scrollport. + * @param reached - runs once, from the observer's callback or at once without `IntersectionObserver`. + * @returns the disposer that stops watching before the section is reached. + */ + observe(element: Element, reached: () => void): () => void { + if (typeof IntersectionObserver === 'undefined') { + reached() + return noop + } + this.waiting.set(element, reached) + this.observer?.observe(element) + return () => { + this.waiting.delete(element) + this.observer?.unobserve(element) + } + } +} + +/** The reach of the enclosing changes view; null outside one, where every section counts as reached. */ +export const SectionReachContext = createContext(null) + +/** Whether the enclosing changes tab is visible and may show temporary menus; true outside one. */ +export const ViewVisibleContext = createContext(true) + +/** Longest wait, in milliseconds, before deferred highlighting runs although the page stays busy. */ +const IDLE_TIMEOUT_MS = 500 + +/** + * Run `task` when the page is next idle, or after other queued work where the browser lacks `requestIdleCallback`. + * @param task - work that may wait for a frame to finish; it receives the idle period, or undefined without one. + * @returns the canceller, which has no effect after `task` ran. + */ +export function whenIdle(task: (deadline: IdleDeadline | undefined) => void): () => void { + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(task, { timeout: IDLE_TIMEOUT_MS }) + return () => { cancelIdleCallback(id) } + } + const id = setTimeout(() => { task(undefined) }, 0) + return () => { clearTimeout(id) } +} diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 708eb757f4..b6dc034eac 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -418,9 +418,7 @@ export class ForgePanelController extends TypertRemoteService { const version = JSON.stringify([headSha, baseBranch]) let read = this.patches.get(key, version) if (read === undefined) { - const patch = await this.home.forge.diff(ref, headSha, signal) - if (typeof patch !== 'string') throw readError(patch) - read = { patch, attributes: await this.attributesAt(ref, headSha, signal) } + read = await this.patchAt(ref, headSha, async operation => await this.home.forge.diff(ref, headSha, operation), signal) this.patches.set(key, version, read) } const files = this.layers.classify(parseDiff(read.patch), read.attributes) @@ -683,13 +681,44 @@ export class ForgePanelController extends TypertRemoteService { if (start === 'not-linear') throw new RemoteError('forgePanel/commits-not-linear', 'The chosen commits are not one run of first parents', {}) const parent = start.parent if (parent === null) throw new RemoteError('forgePanel/diff-failed', 'The first chosen commit has no parent to compare with', {}) - const patch = await this.home.forge.compare(ref, parent, commits.to, signal) - if (typeof patch !== 'string') throw readError(patch) - const files = this.layers.classify(parseDiff(patch), await this.attributesAt(ref, commits.to, signal)) + const { patch, attributes } = await this.patchAt( + ref, commits.to, async operation => await this.home.forge.compare(ref, parent, commits.to, operation), signal, + ) + const files = this.layers.classify(parseDiff(patch), attributes) const fileUrls = Object.fromEntries(files.map(file => [file.path, this.home.forge.fileUrl(ref, commits.to, file.path)])) return { headSha: commits.to, files, localAhead: null, fileUrls } } + /** + * Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. The first failure + * cancels the other read and waits for it to end before it passes on. + * @param ref - change request whose repository holds the commit. + * @param commit - commit the patch ends at. + * @param readPatch - reads the patch under the given cancellation. + * @param signal - request cancellation. + * @returns the patch and the attributes text, empty when the commit has none. + * @throws the Remote error for a refused patch or attributes read, or `signal.reason` when it aborts. + */ + private async patchAt( + ref: ChangeRequestRef, commit: string, readPatch: (signal: AbortSignal) => Promise, signal: AbortSignal, + ): Promise<{ readonly patch: string; readonly attributes: string }> { + const sibling = new AbortController() + const operation = AbortSignal.any([signal, sibling.signal]) + const patch = readPatch(operation).then((result) => { + if (typeof result !== 'string') throw readError(result) + return result + }) + const attributes = this.attributesAt(ref, commit, operation) + try { + const [text, rules] = await Promise.all([patch, attributes]) + return { patch: text, attributes: rules } + } catch (error) { + sibling.abort() + await Promise.allSettled([patch, attributes]) + throw error + } + } + /** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */ private async attributesAt(ref: ChangeRequestRef, commit: string, signal: AbortSignal): Promise { const attributes = await this.home.forge.fileText(ref, commit, ATTRIBUTES_PATH, signal) diff --git a/packages/experimental/client-ui-forge/src/panel.ts b/packages/experimental/client-ui-forge/src/panel.ts index 0c21c934c4..ead8fecc97 100644 --- a/packages/experimental/client-ui-forge/src/panel.ts +++ b/packages/experimental/client-ui-forge/src/panel.ts @@ -1,5 +1,6 @@ /** Live panel state of one followed Session, shared by all of its followers. */ +import { isDeepStrictEqual } from 'node:util' import type { Context } from '@deepseek-ai/cordis' import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' @@ -230,6 +231,8 @@ export class SessionPanel { } private publish(next: ForgePanelState): void { + // A reinspection after a tool result usually finds the same checkout; equal states reach no follower. + if (isDeepStrictEqual(next, this.current)) return this.current = next for (const notify of [...this.followers]) notify() } diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index a4b277e34c..f1d3e62ef1 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** The changes tab: reading each source and scope, the toolbar, file sections, unchanged lines, notices, and failures. */ import { useSyncExternalStore } from 'react' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneId, SidebarRightTabActions, SidebarRightTabInfo, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -713,9 +713,10 @@ describe('ChangesTab base branch', () => { }) it('records review comments under the compared ref and names the base the Host reports', async () => { - const renderDiff = DiffFileModule.DiffFile + // The memoized section draws through its inner component, which the spy observes. + const renderDiff = DiffFileModule.DiffFile.type let source: ReviewActions['source'] | undefined - vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => { + vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => { source = props.review?.source return renderDiff(props) }) @@ -789,6 +790,72 @@ describe('ChangesTab base branch', () => { }) }) +describe('ChangesTab drawing work', () => { + it('draws the lines of an expanded file once its section comes within reach of the scrollport', async () => { + const watched = new Map() + let root: Element | Document | null | undefined + vi.stubGlobal('IntersectionObserver', class { + constructor(readonly report: IntersectionObserverCallback, options?: IntersectionObserverInit) { root = options?.root } + observe(element: Element): void { watched.set(element, this.report) } + unobserve(element: Element): void { watched.delete(element) } + disconnect(): void { watched.clear() } + }) + try { + const m = mount() + await settle() + const app = m.section('src/app.ts') + expect(root).toBe(m.view.container.querySelector('[data-forge-changes-scroll]')) + expect(app.querySelector('[data-forge-diff-unreached]')).not.toBeNull() + expect(rowTexts(app)).toEqual([]) + + act(() => { + // The double reports only the entries, which is all the view reads. + Reflect.apply(watched.get(app)!, undefined, [[{ target: app, isIntersecting: true }], undefined]) + }) + expect(app.querySelector('[data-forge-diff-unreached]')).toBeNull() + expect(rowTexts(app).length).toBeGreaterThan(0) + expect(watched.has(app)).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + + it('highlights a file in idle periods, as many fragments as each period allows, then draws the tokens at once', async () => { + // Idle periods with this much time left, in turn; afterwards each period has none. + const remaining = [5] + const periods: number[] = [] + vi.stubGlobal('requestIdleCallback', (task: IdleRequestCallback) => setTimeout(() => { + const left = remaining.shift() ?? 0 + periods.push(left) + task({ didTimeout: false, timeRemaining: () => left }) + }, 0)) + vi.stubGlobal('cancelIdleCallback', (id: number) => { clearTimeout(id) }) + try { + const m = mount() + await settle() + const app = m.section('src/app.ts') + await waitFor(() => { expect(app.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) }) + // The first period highlights both sides of the file's one run and still has time; the second finds nothing left. + expect(periods.slice(0, 2)).toEqual([5, 0]) + } finally { + // Unmounting cancels pending passes through the stubbed canceller. + cleanup() + vi.unstubAllGlobals() + } + }) + + it('leaves file sections undrawn while the reader filters the file list', async () => { + // The memoized section draws through its inner component, which the spy counts. + const draw = vi.spyOn(DiffFileModule.DiffFile, 'type') + const m = mount() + await settle() + const drawn = draw.mock.calls.length + fireEvent.change(m.view.getAllByRole('textbox', { name: 'Filter files…' })[0]!, { target: { value: 'app' } }) + await settle() + expect(draw.mock.calls.length).toBe(drawn) + }) +}) + describe('ChangesTab layout', () => { it('draws lines side by side, then in one column, and wraps them by choice', async () => { const m = mount() @@ -800,7 +867,8 @@ describe('ChangesTab layout', () => { expect([...app.querySelectorAll('[data-diff-pair]')].map(pair => pair.getAttribute('data-diff-pair'))).toEqual([ 'context', 'change', 'change', 'context', 'context', 'change', ]) - expect(app.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) + // Highlighting waits for the page to be idle. + await waitFor(() => { expect(app.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) }) expect(m.section('docs/gone.txt').querySelector('[data-diff-code]')).toBeNull() expect(rowTexts(m.section('docs/gone.txt'))).toEqual(['bye']) expect(rowTexts(m.section('src/new.ts'))).toEqual(['export const added = 1', '']) @@ -887,6 +955,53 @@ describe('ChangesTab layout', () => { expect(scrolled).toHaveBeenCalledWith({ block: 'start' }) }) + it('keeps a jumped-to file at the top while sections around it draw, until the reader scrolls or a second passes', async () => { + const m = mount() + await settle() + const { viewport } = fileViewport(m) + Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1200 }) + Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 300 }) + const target = m.section('src/new.ts') + // Sections above the target draw after the jump and push it down by this much until it scrolls again. + let shift = 0 + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, shift, 600, 200)) + scrolled.mockImplementation(() => { shift = 0 }) + vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame', 'performance'] }) + try { + const jump = (): void => { + fireEvent.click(m.view.getByRole('button', { name: 'All 6 files' })) + fireEvent.click(m.view.getByRole('menuitem', { name: 'src/new.ts' })) + } + jump() + expect(scrolled).toHaveBeenCalledOnce() + shift = 80 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + + // At the bottom of the list nothing can move the file higher. + viewport.scrollTop = 900 + shift = 40 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + viewport.scrollTop = 0 + + fireEvent.wheel(viewport) + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + + jump() + expect(scrolled).toHaveBeenCalledTimes(3) + act(() => { vi.advanceTimersByTime(1100) }) + shift = 80 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + it('scrolls once to the file each navigation names when its changes arrive', async () => { const m = mount({ params: { path: 'docs/gone.txt' } }) expect(scrolled).not.toHaveBeenCalled() @@ -1323,9 +1438,10 @@ describe('line review actions', () => { expect(tree.getByRole('button', { name: 'src/app.ts' })).toBeTruthy() }) it('refuses a local reference passed to the PR publication callback', async () => { - const renderDiff = DiffFileModule.DiffFile + // The memoized section draws through its inner component, which the spy observes. + const renderDiff = DiffFileModule.DiffFile.type let publish: ReviewActions['publish'] - vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => { + vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => { publish = props.review?.publish return renderDiff(props) }) diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index 51f6ac0d66..92b5a6bdbc 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -306,6 +306,8 @@ describe('forgePanel.follow', () => { expect(moved.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' }) expect(opened.provider.probes).toEqual(['fake.test', 'other.test']) + // A new commit gives the next inspection a state of its own to publish. + await git(repo, 'commit', '--allow-empty', '-m', 'again') shellTurn(opened.session, 2, [{ callId: 'again', command: 'ls', output: '' }]) await follower.next(state => state.checkout.state === 'ready') expect(follower.states.at(-1)?.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' }) @@ -726,6 +728,41 @@ describe('forgePanel change request diffs', () => { .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' }) }) + it('reads the patch and the .gitattributes together and cancels the other read when one fails', async () => { + const { repo } = await project() + const opened = await open(repo) + shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await reader(opened).until(state => polled(state, pullUrl(7))) + const read = async (): Promise => await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal) + const cancelled: string[] = [] + const held = (name: string) => async (operation: AbortSignal): Promise => { + await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) + cancelled.push(name) + throw operation.reason + } + + // Failed reads are not kept, so each case reads again. + const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(async (_ref, _head, operation) => await held('patch')(operation)) + const text = vi.spyOn(opened.provider, 'fileText').mockResolvedValue({ code: 'unavailable', message: 'attributes down' }) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'attributes down' }) + diff.mockResolvedValue({ code: 'unavailable', message: 'patch down' }) + text.mockImplementation(async (_ref, _commit, _path, operation) => await held('attributes')(operation)) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'patch down' }) + + // The patch read finishes only once the attributes read has started, so reading them one after the other never ends. + const attributesStarted = Promise.withResolvers() + diff.mockImplementation(async () => { + await attributesStarted.promise + return PATCH + }) + text.mockImplementation(async () => { + attributesStarted.resolve(undefined) + return 'src/** linguist-generated\n' + }) + await expect(read()).resolves.toMatchObject({ files: [{ path: 'src/a.ts', layer: 'test' }] }) + expect(cancelled).toEqual(['patch', 'attributes']) + }) + it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => { const { repo } = await project() const opened = await open(repo) @@ -1726,13 +1763,12 @@ describe('forgePanel actions', () => { expect(settled).toBe(false) inspection.release() await inspected - await follower.next(() => true) const probe = Promise.withResolvers() opened.provider.readinessImpl = () => probe.promise settled = false const probed = opened.ctx.forgePanel.refresh(opened.session, signal).then(() => { settled = true }) - await follower.next(() => true) + await vi.waitFor(() => { expect(opened.provider.probes).toHaveLength(3) }) await settle() expect(settled).toBe(false) probe.resolve({ state: 'ready', message: null }) @@ -1740,6 +1776,24 @@ describe('forgePanel actions', () => { expect(opened.provider.probes).toHaveLength(3) }) + it('publishes nothing when a reinspection and a sign-in probe find what the panel already shows', async () => { + const { repo } = await project() + const opened = await open(repo) + const follower = reader(opened) + await follower.until(state => ready(state) && state.readiness !== null) + await settle() + const published = follower.states.length + const inspections = opened.sandbox.calls.length + + shellTurn(opened.session, 1, [{ callId: 'list', command: 'ls', output: '' }]) + await opened.ctx.forgePanel.refresh(opened.session, signal) + await settle() + + expect(opened.sandbox.calls.length).toBeGreaterThan(inspections) + expect(opened.provider.probes).toHaveLength(2) + expect(follower.states).toHaveLength(published) + }) + it('refreshes a Session outside a checkout without polling change requests that no provider serves', async () => { const opened = await open(await plain()) shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' }]) @@ -1749,8 +1803,10 @@ describe('forgePanel actions', () => { await opened.ctx.forgePanel.refresh(opened.session, signal) - const refreshed = await follower.next(state => state.checkout.state === 'none') - expect(refreshed.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }]) + // The refresh found the same state, which the panel already published. + const refreshed = follower.states.at(-1) + expect(refreshed?.checkout.state).toBe('none') + expect(refreshed?.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }]) expect(opened.sandbox.calls.length).toBeGreaterThan(inspections) expect(opened.provider.fetches).toEqual([]) expect(opened.provider.probes).toEqual([]) diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index 6abac1781d..33f82ad73c 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -20,9 +20,10 @@ function mount(overrides: Partial = {}) { const addToInput = vi.fn().mockReturnValue(true) const records = new ReviewQueue('diff-review') const review: ReviewActions = { source, addToInput, records, onQuoted: (item) => { records.recordAgent(item) } } - const props: DiffFileProps = { file, visible: true, expanded: true, onToggle: vi.fn(), large: false, revealed: false, onReveal: vi.fn(), - text: undefined, open: new Set(), onExpand: vi.fn(), split: true, wrap: true, badge: '', tooltip: 'a.txt', actions: [], - viewed: 'unviewed', onViewed: vi.fn(), sectionRef: vi.fn(), review, t, ...overrides } + const handlers = { toggle: vi.fn(), reveal: vi.fn(), expand: vi.fn(), view: vi.fn(), actions: () => [], section: vi.fn() } + const props: DiffFileProps = { file, expanded: true, large: false, revealed: false, + text: undefined, open: new Set(), split: true, wrap: true, badge: '', tooltip: 'a.txt', + viewed: 'unviewed', handlers, review, t, ...overrides } const view = render() const line = (name: string) => view.getByRole('button', { name: `Select line ${name}` }) const code = (side: 'L' | 'R', no: number): HTMLElement => { diff --git a/packages/experimental/client-ui-forge/tests/harness.ts b/packages/experimental/client-ui-forge/tests/harness.ts index 961aded76f..5eeaaa6e98 100644 --- a/packages/experimental/client-ui-forge/tests/harness.ts +++ b/packages/experimental/client-ui-forge/tests/harness.ts @@ -347,13 +347,13 @@ export class FakeProvider implements ForgeProvider { return Promise.resolve(this.actionResult) } - diff(ref: ChangeRequestRef, headSha: string): Promise { + diff(ref: ChangeRequestRef, headSha: string, _signal: AbortSignal): Promise { this.diffs.push(ref.number) this.diffHeads.push(headSha) return Promise.resolve(this.patches.get(ref.number) ?? { code: 'not-found', message: `no diff of #${ref.number}` }) } - fileText(_ref: ChangeRequestRef, commit: string, path: string): Promise { + fileText(_ref: ChangeRequestRef, commit: string, path: string, _signal: AbortSignal): Promise { return Promise.resolve(this.texts.get(`${commit}:${path}`) ?? { code: 'not-found', message: `no ${path} at ${commit}` }) } diff --git a/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts new file mode 100644 index 0000000000..3533147a0c --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +/** File sections reach the scrollport once, and deferred highlighting waits for idle time. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SectionReach, whenIdle } from '../src/client/section-reach.ts' + +/** An IntersectionObserver double that records its options and the elements it watches. */ +class FakeObserver { + static readonly made: FakeObserver[] = [] + readonly watched = new Set() + disconnected = false + constructor(readonly report: IntersectionObserverCallback, readonly options: IntersectionObserverInit | undefined) { + FakeObserver.made.push(this) + } + observe(element: Element): void { this.watched.add(element) } + unobserve(element: Element): void { this.watched.delete(element) } + disconnect(): void { + this.disconnected = true + this.watched.clear() + } + /** Report each element as intersecting or not. */ + fire(entries: readonly (readonly [Element, boolean])[]): void { + // The production callback reads only the entries, so the double passes itself as the observer. + Reflect.apply(this.report, undefined, [entries.map(([target, isIntersecting]) => ({ target, isIntersecting })), this]) + } +} + +afterEach(() => { + vi.unstubAllGlobals() + FakeObserver.made.length = 0 +}) + +describe('SectionReach', () => { + it('reaches every section at once where the browser has no IntersectionObserver', () => { + vi.stubGlobal('IntersectionObserver', undefined) + const reach = new SectionReach() + reach.attach(document.createElement('div')) + const reached = vi.fn() + const stop = reach.observe(document.createElement('section'), reached) + expect(reached).toHaveBeenCalledOnce() + stop() + }) + + it('reaches a section once it intersects the scrollport margin and moves waiting sections to a new scrollport', () => { + vi.stubGlobal('IntersectionObserver', FakeObserver) + const reach = new SectionReach() + const near = document.createElement('section') + const far = document.createElement('section') + const gone = document.createElement('section') + const reachedNear = vi.fn() + const reachedFar = vi.fn() + reach.observe(near, reachedNear) + reach.observe(far, reachedFar) + const stopGone = reach.observe(gone, vi.fn()) + expect(FakeObserver.made).toHaveLength(0) + + const root = document.createElement('div') + reach.attach(root) + const first = FakeObserver.made[0]! + expect(first.options).toEqual({ root, rootMargin: '1200px 0px' }) + expect([...first.watched]).toEqual([near, far, gone]) + stopGone() + expect([...first.watched]).toEqual([near, far]) + + first.fire([[near, true], [far, false]]) + expect(reachedNear).toHaveBeenCalledOnce() + expect(reachedFar).not.toHaveBeenCalled() + expect([...first.watched]).toEqual([far]) + + const other = document.createElement('div') + reach.attach(other) + expect(first.disconnected).toBe(true) + const second = FakeObserver.made[1]! + expect(second.options?.root).toBe(other) + expect([...second.watched]).toEqual([far]) + second.fire([[far, true]]) + expect(reachedFar).toHaveBeenCalledOnce() + + reach.attach(null) + expect(second.disconnected).toBe(true) + expect(FakeObserver.made).toHaveLength(2) + }) +}) + +describe('whenIdle', () => { + it('waits for an idle period, at most the timeout, and can be cancelled', () => { + const request = vi.fn<(task: () => void, options: { timeout: number }) => number>(() => 7) + const cancel = vi.fn() + vi.stubGlobal('requestIdleCallback', request) + vi.stubGlobal('cancelIdleCallback', cancel) + const task = vi.fn() + const stop = whenIdle(task) + expect(request).toHaveBeenCalledWith(task, { timeout: 500 }) + stop() + expect(cancel).toHaveBeenCalledWith(7) + }) + + it('runs after queued work where the browser has no requestIdleCallback', () => { + vi.useFakeTimers() + try { + vi.stubGlobal('requestIdleCallback', undefined) + const ran = vi.fn() + whenIdle(ran) + const cancelled = vi.fn() + whenIdle(cancelled)() + vi.runAllTimers() + expect(ran).toHaveBeenCalledOnce() + expect(cancelled).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) From 05517b1dbe312f72a21da626f2952747c5a7bc89 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:30:42 +0800 Subject: [PATCH 096/149] fix(forge): read the card below a gap at the top of the changes view The file being read was the last section starting above the viewport top. With 12px between cards, scrolling the gap to the top kept the card above it, already out of view, as the file that V marks and the arrow keys move from. It is now the first section that still reaches below the viewport top, and the last file once every section is above it. Resolves #8120 --- .../client-ui-forge/src/client/ChangesTab.tsx | 12 ++++++---- .../tests/changes-tab.client.spec.tsx | 24 +++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index f57bb937bb..6c73c8b96f 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -682,8 +682,9 @@ export function ChangesTab({ setWanted(path) } /** - * The file being read: the file last jumped to while the scroll stays there, else the last file whose section starts - * above the viewport top. + * The file being read: the file last jumped to while the scroll stays there, else the first file whose section still + * reaches below the viewport top, so the gap between two cards at the top belongs to the file after it; past the last + * section, the last file. */ const currentIndex = (viewport: HTMLElement): number => { const remembered = jumpPosition.current @@ -691,10 +692,11 @@ export function ChangesTab({ ? files.findIndex(file => file.path === treeState.selected) : -1 if (index >= 0) return index const top = viewport.getBoundingClientRect().top - return Math.max(0, files.findLastIndex((file) => { + const reading = files.findIndex((file) => { const section = sections.current.get(file.path) - return section !== undefined && section.getBoundingClientRect().top <= top + 1 - })) + return section !== undefined && section.getBoundingClientRect().bottom > top + 1 + }) + return reading >= 0 ? reading : Math.max(0, files.length - 1) } /** Whether a key press belongs to a field, a comment, a menu, or a text selection rather than to the file list. */ const typing = (container: HTMLElement, origin: EventTarget): boolean => { diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index a4b277e34c..6d5041327a 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -277,16 +277,17 @@ function rowTexts(section: HTMLElement): string[] { return [...section.querySelectorAll(`.${css.text}`)].map(element => element.textContent ?? '') } -/** Section geometry with a short final page whose scroll position is clamped by the viewport. */ -function fileViewport(m: ReturnType) { +/** Geometry of 200px sections `gap` pixels apart, with a short final page whose scroll position is clamped by the viewport. */ +function fileViewport(m: ReturnType, gap = 0) { const nodes = [...m.view.container.querySelectorAll('[data-forge-diff-file]')] const viewport = m.view.getByRole('region', { name: `All ${nodes.length} files` }) + const step = 200 + gap vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 300)) nodes.forEach((node, index) => { - vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, index * 200 - viewport.scrollTop, 600, 200)) + vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, index * step - viewport.scrollTop, 600, 200)) }) scrolled.mockImplementation(function (this: HTMLElement) { - viewport.scrollTop = Math.min(nodes.indexOf(this) * 200, nodes.length * 200 - 300) + viewport.scrollTop = Math.min(nodes.indexOf(this) * step, nodes.length * step - gap - 300) }) return { viewport, nodes } } @@ -366,6 +367,21 @@ describe('ChangesTab keyboard file navigation', () => { expect(scrolled.mock.contexts.at(-1)).toBe(nodes[2]) }) + it('reads the card below a gap at the viewport top, and the last file once every card is above it', async () => { + const m = mount() + await settle() + const { viewport, nodes } = fileViewport(m, 12) + // The first card ends 5px above the viewport, and the second starts 7px below it. + viewport.scrollTop = 205 + fireEvent.scroll(viewport) + fireEvent.keyDown(viewport, { key: 'ArrowRight' }) + expect(scrolled.mock.contexts.at(-1)).toBe(nodes[2]) + viewport.scrollTop = nodes.length * 212 + fireEvent.scroll(viewport) + fireEvent.keyDown(viewport, { key: 'ArrowLeft' }) + expect(scrolled.mock.contexts.at(-1)).toBe(nodes.at(-2)) + }) + it('leaves vertical arrows, modifiers, composition and handled keys to their owners', async () => { const m = mount() await settle() From c51ecb2bff643e0c756c13c28ecdccc75e0a1c69 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:31:24 +0800 Subject: [PATCH 097/149] feat(cua): request system pointer access on the target mouse move --- ...cua-hover-routes-and-coordinates.i18n.yaml | 4 +- ...-10-13-cua-hover-routes-and-coordinates.md | 2 +- ...-13-cua-hover-routes-and-coordinates.zh.md | 2 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + .../cua-hover-ax-only/guide.i18n.yaml | 8 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.md | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md | 4 +- .../experimental/cua-macos/README.i18n.yaml | 8 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../experimental/cua-macos/src/guidance.ts | 2 +- packages/experimental/cua-macos/src/index.ts | 7 +- .../cua-macos/tests/guidance.spec.ts | 6 +- .../tests/protocol-generation.spec.ts | 1 - .../tests/provider-lifecycle.spec.ts | 231 ++++++++++-------- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/src/api.ts | 14 +- .../cua/src/application-access.ts | 13 +- packages/experimental/cua/src/guidance.ts | 3 +- packages/experimental/cua/src/index.ts | 34 ++- packages/experimental/cua/src/locales.ts | 16 +- .../experimental/cua/src/mouse-control.ts | 15 +- .../experimental/cua/src/native-schema.ts | 4 +- packages/experimental/cua/src/protocol.ts | 14 +- packages/experimental/cua/src/types.ts | 11 +- packages/experimental/cua/tests/api.spec.ts | 17 +- .../session/cua-native/replay.override.json | 4 +- 33 files changed, 263 insertions(+), 193 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 7681ea3630..4450e114ec 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: 93631d45c7ecfb4c - zh: 77a08aafb1115ba1 + en: 42f40e78c2fff8a8 + zh: b7c5fa5fe538ed77 /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index 1f5c18ce74..c46dc02060 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -10,7 +10,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. ## Decision -By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. After the user approves `cua.requestMouseControl()`, each `mouseMove` in that session uses the system pointer until DSH restarts. This lets a caller reach pointer-tracking controls even when the same target also advertises AX alternate UI. The Host passes the session decision with each move. Physical movement leaves the visibility of previously revealed controls to the application, so switching routes cannot dismiss a target before dispatch. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). +By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. With `requireSystemPointerApproval` enabled, the first explicit `mouseMove(target, { useSystemPointer: true })` asks the user. Approval applies to this move and later ordinary moves in the live session. Closing the session or restarting DSH clears it. Without the approval requirement only an explicit request selects real input. This lets a caller reach pointer-tracking controls even when the same target also advertises AX alternate UI. The Host passes the session decision with each move. Physical movement leaves the visibility of previously revealed controls to the application, so switching routes cannot dismiss a target before dispatch. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for `click`, `mouseMove`, `scroll` and `drag`; it does not apply to opaque element IDs. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index d1a14ee9e4..f4c5c39d69 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。用户同意 `cua.requestMouseControl()` 后,本会话的每次 `mouseMove` 都使用系统光标,重启 DSH 后授权失效。有些控件既支持 AX 备用界面,又跟踪真实鼠标。Host 在每次悬停请求中传入本会话的授权结果,让调用方能够触发后者。切换到系统鼠标后,由应用决定是否收起先前展开的控件,以免派发前目标消失。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 +默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `requireSystemPointerApproval` 时,首次显式调用 `mouseMove(target, { useSystemPointer: true })` 会询问用户。同意后,本次移动和本会话后续普通悬停使用系统光标。关闭会话或重启 DSH 后授权失效。免询问配置下,只有显式请求才会移动系统光标。有些控件既支持 AX 备用界面,又跟踪真实鼠标。Host 在每次悬停请求中传入本会话的授权结果,让调用方能够触发后者。切换到系统鼠标后,由应用决定是否收起先前展开的控件,以免派发前目标消失。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 07e8c52196..26a136abb0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1163,6 +1163,8 @@ export interface Config { export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** Ask before a main task may move the system pointer. When false only explicitly requested moves use it. */ + requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8308df3e6f..c7dfeff61e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1165,6 +1165,8 @@ export interface Config { export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** Ask before a main task may move the system pointer. When false only explicitly requested moves use it. */ + requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml index 2f886f19df..1a804c0a53 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 6833a7851486f10d zh: 22983f326fb725ce /macos-hover-keeps-the-system-pointer-in-place-by-default/change: - en: 2369a7daf049311a - zh: 2ad1bfb579be4426 + en: e6d2e56ccb50e46a + zh: a788fc19e0771a46 /macos-hover-keeps-the-system-pointer-in-place-by-default/migration: - en: 7c89c67c9acefd25 - zh: 54a0064c30af043c + en: b1070d660b90318c + zh: 2f2ad1b8897dc044 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md index 733a8a7061..e38e026f1c 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md @@ -9,10 +9,10 @@ English | [中文](guide.zh.md) ## Change -On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. Moving the pointer requires the user’s approval for the current session. +On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. By default, explicitly requesting the system pointer asks the user for this session. ## Migration 1. For an available control, use its advertised action or click it directly. Use browser tools for web content that requires page hover. 2. If your profile sets `helperPath`, rebuild the custom helper to support the per-request `useSystemPointer` field and restart the profile. -3. When the task requires a physical hover, call `cua.requestMouseControl()`. After the user approves, `win.mouseMove` on an unobscured foreground target returns `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. This grant expires when DSH restarts. +3. When the task requires physical movement, call `win.mouseMove(target, { useSystemPointer: true })` on an unobscured foreground target. After approval this move reports `systemPointerMoved: true`. Closing the session or restarting DSH clears approval. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md index 84ac6cbe2b..d5111faf05 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md @@ -9,10 +9,10 @@ description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否 ## 变更 -在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。移动系统光标需要用户单独授权当前会话。 +在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。默认配置下,显式请求移动系统光标时会询问用户是否授权当前会话。 ## 迁移 1. 对已经可用的控件,可执行它声明的动作或直接点击。需要网页悬停时可用浏览器工具。 2. 如果 profile 设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 -3. 任务确实需要移动鼠标时,调用 `cua.requestMouseControl()`。用户同意后,对前台未受遮挡的目标调用 `win.mouseMove`,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。重启 DSH 后授权失效。 +3. 任务确实需要移动鼠标时,在前台未受遮挡的目标上调用 `win.mouseMove(target, { useSystemPointer: true })`。用户同意后,本次移动返回 `systemPointerMoved: true`。关闭会话或重启 DSH 后需要重新授权。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 7cefb93644..45d7cd7ff8 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,11 +15,11 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: c81e5fbf8ff7e110 - zh: f70adab1257ed26d + en: e51a6c3625873430 + zh: 0a6998897532a05f /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: - en: 2391a460e295cfca - zh: 1889a903efb6142d + en: b87eede533256337 + zh: 15c6b1323a60c5b6 /deepseek-ai-dsh-experimental-cua-macos/understand-the-implementation: en: 4e82b85a5aa72eee zh: 4e004b7a205d8688 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 31793059c9..dddb02c2ef 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -60,6 +60,7 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates | `agentPreset` | `ptc` | Register tools and guidance only for Agents bound to this preset; `false` registers globally. | | `allowedApps` | `[]` | Legacy path/signature grants, imported once for removal; they do not authorize new bundle-ID identities. | | `helperPath` | package-owned executable | Absolute executable inside the signed helper `.app`; a bare binary fails with `NATIVE_APPLICATION_REQUIRED`. | +| `requireSystemPointerApproval` | `true` | Ask on the first explicit request to move the system pointer. With `false`, only explicitly requested moves use it. | | `sourceSigning` | `ad-hoc` | Source signing: `ad-hoc`, or `{ identity, keychain }` for an existing certificate and keychain path. Ignored for installed packages and `helperPath`. | | `buildTimeoutMs` | `300000` | Maximum native preparation time. | | `timeoutMs` | `30000` | Per-operation deadline; cancellation can leave partial input. | @@ -116,7 +117,7 @@ Use the `cua` API to discover and select the application (`listApps`, `getState` `getWindow` reads its initial tree silently. An owned window without an AX surface returns an empty snapshot and remains available for screenshots and coordinate input. Within one API instance, the facade keeps one current accessibility snapshot per application handle, shared by every app and window object for that handle, including repeated selections. Reading any window replaces that snapshot; element operations use the latest snapshot ID, so use IDs from the latest tree and do not assume an older ID still identifies the same control. A failed read publishes no partial snapshot; read again without repeating the preceding input. Window objects own screenshots and pointer input. Keyboard methods stay on `app` because input follows the app’s current focus. -`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. If these alternatives cannot complete the task, `cua.requestMouseControl()` asks for this session until DSH restarts. On approval, every subsequent hover moves the system pointer to the foreground, unobscured target and leaves it there. +Ordinary `mouseMove(target)` defaults to AX hover. Without both `AXShowAlternateUI` and `AXShowDefaultUI` on an enabled target or ancestor, it returns `HOVER_UNAVAILABLE` without prompting. Only when the task requires physical movement should the main task call `win.mouseMove(target, { useSystemPointer: true })`. On approval the Host rechecks the window and point, dispatches this move, and uses the visible pointer for later ordinary moves. Refusal sends no input and suppresses further questions. Closing the session or restarting DSH clears the decision. Explicit `useSystemPointer: false` selects AX for one call. `paste(text, { format: "text" | "html" })` temporarily publishes the requested clipboard format and invokes the application’s enabled standard Paste menu command. It keeps the temporary format until a pasteboard reader requests its data, then restores every captured original format while it still owns the clipboard, preserving a later copy. Paste operations share a cross-process lock and cancellation joins helper cleanup. A forced process exit can prevent restoration. Unsupported Paste commands fail without typing fallback; `typeText` remains separate key-event input. @@ -187,7 +188,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index bb9e052a8c..17fd51d708 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -60,6 +60,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. | `agentPreset` | `ptc` | 仅为绑定该 preset 的 Agent 注册工具和指引;`false` 表示全局注册。 | | `allowedApps` | `[]` | 旧路径和签名许可,只导入 profile 存储一次供撤销,不授予新 Bundle ID 身份访问权限。 | | `helperPath` | 包内可执行文件 | 已签名 helper `.app` 内可执行文件的绝对路径;裸二进制会触发 `NATIVE_APPLICATION_REQUIRED`。 | +| `requireSystemPointerApproval` | `true` | 主任务首次显式请求移动系统指针时询问用户。设为 `false` 时仅本次显式移动免询问。 | | `sourceSigning` | `ad-hoc` | 源码签名:`ad-hoc` 临时签名,或 `{ identity, keychain }` 指定已有证书及钥匙串路径。安装版和 `helperPath` 忽略此项。 | | `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 | | `timeoutMs` | `30000` | 单次操作截止时间,取消后可能已发生部分输入。 | @@ -116,7 +117,7 @@ void await window.getScreenshot() `getWindow` 静默读取初始树。已确认归属但没有 AX 界面的窗口返回空快照,仍可截图和发送坐标输入。在单个 API 实例内,门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享它,包括重复选择。读取任一窗口都会替换该快照;元素操作使用最新快照 ID,因此应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。读取失败不会发布部分快照;可以重新读取,但不要重复此前的输入。窗口对象负责截图和指针输入。键盘方法保留在 `app` 上,因为输入跟随应用的当前焦点。 -`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有其他操作都无法完成任务时,才调用 `cua.requestMouseControl()` 询问用户。用户同意后,本会话后续悬停会把系统光标移到前台未受遮挡的目标并停在那里,重启 DSH 后授权失效。 +普通 `mouseMove(target)` 默认通过辅助功能悬停。如果目标或其祖先没有同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`,则返回 `HOVER_UNAVAILABLE`,不会弹窗。只有任务确实需要移动用户的鼠标时,主任务才调用 `win.mouseMove(target, { useSystemPointer: true })`。用户允许后,Host 重新检查窗口和目标位置,执行这一次移动,此后本会话的普通悬停也使用系统指针。拒绝后不移动,也不再提问。关闭会话或重启 DSH 后决定失效。传入 `useSystemPointer: false` 可在本次调用中使用辅助功能。 `paste(text, { format: "text" | "html" })` 临时提供指定格式的剪贴板内容,并调用应用已启用的标准 Paste 菜单命令。临时格式保留到剪贴板读取方请求其数据,然后只在仍持有剪贴板时恢复保存的全部原格式,保留期间产生的新复制内容。粘贴操作共用跨进程锁,取消会等待辅助进程清理。强制终止进程可能阻止恢复。应用不支持 Paste 命令时明确失败,不自动改成逐字输入;`typeText` 仍单独提供按键事件输入。 @@ -187,7 +188,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 16dc733352..cae002d8da 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2a69d89b7fc95998 zh: 084575bb31fb40dd /dsh-cua-native-helper/observation-and-input: - en: 88ad57b61e873f32 - zh: ff8a89697a6c0cc5 + en: 7e63469ddd8c4e72 + zh: dbc87b8d8953c7e7 /dsh-cua-native-helper/verification: en: c87945aeec587bf5 zh: e5d69e4927e2fed3 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index 65ad850f18..16d1e102f0 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -123,7 +123,7 @@ Tree observation and action discovery treat a generic AX failure reading one ele `paste` publishes one plain-text or HTML clipboard format, dispatches the application's enabled standard Paste menu command, and waits for a clipboard data request. It then restores the preserved formats only while its ownership marker and change count still match. The Host serializes its pastes across helpers with a file lock. maxClipboardBytes bounds both input and preserved contents; unsupported menus and oversized data fail before dispatch. A later copy is preserved. Cancellation restores owned clipboard data when possible; forced termination cannot guarantee restoration, and a data request does not prove the target completed editing. After dispatch, `pasteReadTimeoutMs` bounds waiting for clipboard consumption. Expiry returns `PASTE_NOT_CONSUMED`, closes the promised data provider, and restores the original clipboard only while still owned. The failure does not prove the app will never process its queued Paste command; inspect the target before deciding whether to retry. -`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. After session approval, the Host sends `useSystemPointer: true` on each `mouseMove` to select the system pointer directly. +`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. The Host resolves the requested route against session approval or the configured exemption before sending `useSystemPointer` to the helper. The opt-in system pointer route returns `FOREGROUND_REQUIRED` if the target is obscured or belongs to a background app. The result reports the route and whether the visible cursor moved in `hoverRoute` and `systemPointerMoved`. It never presses a button or restores the cursor after a read. Later input may interrupt the hover. Clicks send their button events at the target without an off-window priming click. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 8dc1427c7e..7acb51fd17 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -123,7 +123,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `paste` 提供一种纯文本或 HTML 剪贴板格式,调用应用已启用的标准 Paste 菜单命令,并等待剪贴板数据请求。随后仅在所有权标记和修改计数仍匹配时恢复原有格式。Host 通过文件锁串行化不同 helper 的粘贴。maxClipboardBytes 同时限制输入和保留内容,不支持的菜单和过大的数据在分派前失败。后续复制会被保留。取消会尽可能恢复仍持有的剪贴板数据,强制结束不保证恢复,数据请求也不证明目标已完成编辑。 分派完成后,`pasteReadTimeoutMs` 限制等待剪贴板读取的时间。到期返回 `PASTE_NOT_CONSUMED`,关闭延迟数据提供方,并仅在仍持有剪贴板时恢复原内容。该失败不能证明应用以后不会处理已排队的 Paste 命令;决定是否重试前先检查目标内容。 -默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。用户授权后,Host 在本会话的每次 `mouseMove` 请求中传入 `useSystemPointer: true`,直接使用系统光标。 +默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。Host 根据本会话的授权或免询问配置决定本次悬停的路径,再向 helper 传入 `useSystemPointer`。 开启后,系统指针路线会检查前台应用和坐标命中的窗口,不满足条件时返回 `FOREGROUND_REQUIRED`。返回值 `hoverRoute` 和 `systemPointerMoved` 说明走了哪条路线以及是否移动了可见光标。悬停不会按下鼠标,也不会在读取后还原光标。后续输入可能中断悬停。点击只在目标点发送按键事件,不先在窗口外点击。 diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index b1ab6ad168..63a81e0b40 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -6,7 +6,7 @@ export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are avail const methodDetails = new Map([ ['getAccessibilityTree', 'If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current.'], - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 757cc11050..26142f654c 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -16,7 +16,7 @@ const CAPABILITIES: CuaStartupCapabilities = { platform: 'macos', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', 'selectText', 'sendKeyEvent', 'setValue', 'show', 'typeText', ], appLevelAccessibility: true, @@ -33,6 +33,8 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** Ask before a main task may move the system pointer. When false only explicitly requested moves use it. */ + requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -43,6 +45,7 @@ export interface Config extends CuaConfig { /** Validate shared operation bounds and macOS source signing before preparing the helper. */ export const Config = Schema.object({ ...CuaConfigFields, + requireSystemPointerApproval: Schema.boolean().default(true), permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000), sourceSigning: Schema.union([ Schema.const('ad-hoc'), @@ -62,7 +65,7 @@ export const Config = Schema.object({ */ export async function apply(ctx: Context, config: Config): Promise { const backend = { createChannel: createSharedNativeChannel, sendKeyEvent: installHeldKeys(ctx), - guidance: GUIDANCE, capabilities: CAPABILITIES, + guidance: GUIDANCE, capabilities: CAPABILITIES, requireSystemPointerApproval: config.requireSystemPointerApproval, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index a0f6ac2339..7f0cb9ea86 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -4,7 +4,7 @@ import type { CuaApi, CuaApp, CuaSurface, CuaWindow } from '@deepseek-ai/dsh-cua import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' import { GUIDANCE, PLATFORM_GUIDANCE } from '../src/guidance.ts' -const apiNames: Array = ['getCapabilities', 'requestMouseControl', 'listApps', 'findApps', 'getState', 'getApp'] +const apiNames: Array = ['getCapabilities', 'listApps', 'findApps', 'getState', 'getApp'] const accessibilityNames: Array> = [ 'getAccessibilityTree', 'listActions', 'invoke', 'performAction', 'setValue', 'selectText', ] @@ -56,8 +56,8 @@ it('enumerates every available method once in its catalog entry', () => { for (const catalog of [COMMON_GUIDANCE, GUIDANCE]) { for (const signature of signatures) expect(catalog.split('\n').filter(line => line.startsWith(`- ${signature}`))).toHaveLength(1) } - expect(signatures).toHaveLength(25) - expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(24) + expect(signatures).toHaveLength(24) + expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(23) expect(COMMON_GUIDANCE).toContain('app.appHandle') expect(COMMON_GUIDANCE).toContain('window.windowId') }) diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index c5ca6b90c0..36c6dd94c9 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -33,7 +33,6 @@ describe('native protocol generation', () => { { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', maxClipboardBytes: 1024 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } }, - { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, useSystemPointer: true } }, { method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } }, { method: 'releaseHeldKeys', params: { runId: 'execution' } }, { method: 'launchApp', params: { appHandle: 'pending' } }, diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 52202a0774..ea69351f0d 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -67,7 +67,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ })) const config = { - permissionPollIntervalMs: 1000, + permissionPollIntervalMs: 1000, requireSystemPointerApproval: true, agentPreset: false, helperPath: resolve('fixture-helper'), sourceSigning: 'ad-hoc', buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20, graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, @@ -77,6 +77,7 @@ const { helperPath: _helperPath, ...automaticConfig } = config it('defaults to ad-hoc signing and requires a keychain path for certificate signing', () => { expect(NativeProvider.Config({}).sourceSigning).toBe('ad-hoc') + expect(NativeProvider.Config({}).requireSystemPointerApproval).toBe(true) expect(NativeProvider.Config({ sourceSigning: 'ad-hoc' }).sourceSigning).toBe('ad-hoc') for (const sourceSigning of [{ identity: 'Apple Development: Example', keychain: '/keychains/custom.keychain-db' }, { identity: 'ABC123', keychain: '/keychains/custom.keychain-db' }]) { expect(NativeProvider.Config({ sourceSigning }).sourceSigning).toEqual(sourceSigning) @@ -290,7 +291,7 @@ it('merges startup capabilities into runtime native fields', async () => { expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false, coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', 'selectText', 'sendKeyEvent', 'setValue', 'show', 'typeText', ] }) expect(native.request).toHaveBeenCalledExactlyOnceWith('getCapabilities', {}, expect.any(AbortSignal), ToolCallId('lifecycle-1')) @@ -970,111 +971,149 @@ it('routes permission actions through the macOS provider with the Client languag }, expect.any(AbortSignal)) }) -it('moves only for the consenting session, without granting application access or retaining consent after reload', async () => { +const hoverTarget = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } +const physicalHover = { ...hoverTarget, useSystemPointer: true } + +async function hoverFixture(requireApproval = true) { const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.fn((id: string) => ({ answers: [{ id, selected: ['Allow for this session'] }] })) - f.ctx.on('user-questions/request', async ({ questions }) => ask(questions[0]!.id)) - const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } - const application = { appHandle: 'app-one', name: 'Fixture', running: true, - authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } } - native.request.mockResolvedValueOnce(application) - expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) - const move = async (useSystemPointer: boolean) => { - expect((await f.execute('mouseMove', target)).isError).toBe(false) - expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer }, expect.any(AbortSignal), expect.any(String)) + if (!requireApproval) { + await f.provider.dispose() + await f.ctx.plugin(NativeProvider, { ...config, requireSystemPointerApproval: false }) } - await move(false) - expect((await f.execute('mouseMove', { ...target, useSystemPointer: true })).isError).toBe(true) - for (let i = 0; i < 2; i++) expect((await f.execute('requestMouseControl')).value).toBe(true) - await move(true) - const other = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) - const otherCall = (method: string, params: Record) => executeToolFixture(f.ctx, { - agent: other.agent, signal: new AbortController().signal, callId: ToolCallId(method), name: 'cua', arguments: { method, params }, + const questions = await f.ctx.plugin(UserQuestions) + const questionId = 'cua-mouse-control' + const approved = { answers: [{ id: questionId, selected: ['Allow for this session'] }] } + const ask = vi.fn(async () => approved) + f.ctx.on('user-questions/request', async ({ questions }) => questions[0]!.id === questionId ? ask() + : { answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) + let index = 0 + const call = (agent: typeof f.handle.agent, method: string, params: Record) => executeToolFixture(f.ctx, { + agent, signal: new AbortController().signal, callId: ToolCallId(`hover-${++index}`), name: 'cua', arguments: { method, params }, }) - expect(JSON.stringify((await otherCall('mouseMove', target)).content)).toContain('APP_ACCESS_REQUIRED') - native.request.mockResolvedValueOnce(application) - expect((await otherCall('getApp', { selector: 'Fixture' })).isError).toBe(false) - ask.mockImplementationOnce(id => ({ answers: [{ id, selected: ['Deny'] }] })) - expect((await otherCall('requestMouseControl', {})).value).toBe(false) - expect((await otherCall('mouseMove', target)).isError).toBe(false) - expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) - await move(true) + const select = async (agent = f.handle.agent) => { + native.request.mockResolvedValueOnce({ appHandle: 'app-one', name: 'Fixture', running: true, + authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } }) + expect((await call(agent, 'getApp', { selector: 'Fixture' })).isError).toBe(false) + } + await select() + native.request.mockClear() + return { ...f, ask, approved, call, select, questions } +} + +it('asks only on an explicit move and dispatches that same move after approval', async () => { + const f = await hoverFixture() + native.request.mockRejectedValueOnce(new Error('HOVER_UNAVAILABLE')) + expect(JSON.stringify((await f.execute('mouseMove', hoverTarget)).content)).toContain('HOVER_UNAVAILABLE') + expect(f.ask).not.toHaveBeenCalled() + const entered: PromiseWithResolvers = Promise.withResolvers() + const answer = Promise.withResolvers() + f.ask.mockImplementationOnce(() => { entered.resolve(); return answer.promise }) + onTestFinished(() => { answer.resolve(f.approved) }) + const pending = f.execute('mouseMove', physicalHover) + await entered.promise + expect(native.request).toHaveBeenCalledTimes(1) + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_PENDING') + answer.resolve(f.approved) + expect((await pending).isError).toBe(false) + for (const [params, physical] of [ + [hoverTarget, true], [{ ...hoverTarget, useSystemPointer: false }, false], [physicalHover, true], + ] as const) { + expect((await f.execute('mouseMove', params)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: physical }, expect.any(AbortSignal), expect.any(String)) + } + expect(native.request.mock.calls[1]?.[1]).toEqual(physicalHover) + expect(f.ask).toHaveBeenCalledTimes(1) +}) + +it('keeps refusals and grants separate and forgets a closed session on reopen', async () => { + const f = await hoverFixture() + expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false) + const { agent } = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) + expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('APP_ACCESS_REQUIRED') + await f.select(agent) + f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Deny'] }] }) + native.request.mockClear() + for (let i = 0; i < 2; i++) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_DENIED') + expect(native.request).not.toHaveBeenCalled() + expect((await f.call(agent, 'mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) + expect((await f.execute('mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', physicalHover, expect.any(AbortSignal), expect.any(String)) + const sessionId = f.handle.agent.id + await f.handle.dispose() + const reopened = await f.ctx.agents.create({ sessionId }) + await f.select(reopened.agent) + expect((await f.call(reopened.agent, 'mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) + expect((await f.call(reopened.agent, 'mouseMove', physicalHover)).isError).toBe(false) + expect(f.ask).toHaveBeenCalledTimes(3) +}) + +it('rechecks the selection after a pending approval and does not dispatch revoked or stale input', async () => { + const f = await hoverFixture() + f.ask.mockImplementationOnce(async () => { + await f.ctx.cuaAccess.revoke('a'.repeat(64) as Parameters[0]) + return f.approved + }) + expect((await f.execute('mouseMove', physicalHover)).isError).toBe(true) + expect(native.request).not.toHaveBeenCalled() +}) + +it('revalidates window coordinates after the user answers', async () => { + const f = await hoverFixture() + native.request.mockResolvedValueOnce([{ windowId: 7, appName: 'Fixture', title: 'Window', bounds: { x: 0, y: 0, width: 100, height: 100 }, + isOnScreen: true, onCurrentSpace: true }]) + await f.execute('listWindows', { appHandle: 'app-one' }) + f.ask.mockImplementationOnce(async () => { + native.request.mockResolvedValueOnce([]) + await f.execute('listWindows', { appHandle: 'app-one' }) + return f.approved + }) + const result = await f.execute('mouseMove', { appHandle: 'app-one', windowId: 7, x: 500, y: 500, relative: true, useSystemPointer: true }) + expect(JSON.stringify(result.content)).toContain('STALE_SNAPSHOT') + expect(native.request.mock.calls.map(([name]) => name)).toEqual(['listWindows', 'listWindows']) +}) + +it('drops dismissal, aborted answers and missing human answerers without allowing input', async () => { + const f = await hoverFixture() + f.ask.mockRejectedValueOnce(new UserQuestionError('dismissed', 'ASK_CANCELLED')) + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_APPROVAL_CANCELLED') + const controller = new AbortController() + f.ask.mockImplementationOnce(async () => { controller.abort(); return f.approved }) + await Promise.allSettled([f.execute('mouseMove', physicalHover, controller.signal)]) + expect(native.request).not.toHaveBeenCalled() + expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false) + expect(f.ask).toHaveBeenCalledTimes(3) await f.provider.dispose() await f.ctx.plugin(NativeProvider, config) - expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(ask.mock.calls.map(([id]) => id)).toEqual(['cua-app-access', 'cua-mouse-control', 'cua-app-access', 'cua-mouse-control', 'cua-mouse-control']) + await f.select() + await f.questions.dispose() + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_APPROVAL_UNAVAILABLE') }) -it.each(['Deny', 'custom'] as const)('remembers a %s answer without moving the pointer or asking again', async (answer) => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.fn(() => { - return { answers: [{ id: 'cua-mouse-control', selected: [answer === 'custom' ? 'Allow for this session' : answer], - ...(answer === 'custom' ? { custom: 'maybe later' } : {}) }] } - }) - f.ctx.on('user-questions/request', async () => ask()) - expect((await f.execute('requestMouseControl')).value).toBe(false) - expect((await f.execute('requestMouseControl')).value).toBe(false) - expect(ask).toHaveBeenCalledTimes(1) - expect(native.request).not.toHaveBeenCalled() +it('allows only the explicitly requested physical move when approval is disabled', async () => { + const f = await hoverFixture(false) + for (const [target, expected] of [[hoverTarget, false], [physicalHover, true], [hoverTarget, false]] as const) { + expect((await f.execute('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: expected }, expect.any(AbortSignal), expect.any(String)) + } + expect(f.ask).not.toHaveBeenCalled() }) -it('allows a fresh request after dismissal without authorizing the first one', async () => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.spyOn(f.ctx.userQuestions, 'ask') - .mockRejectedValueOnce(new UserQuestionError('User dismissed the request', 'ASK_CANCELLED')) - .mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] }) - const dismissed = await f.execute('requestMouseControl') - expect(dismissed.isError).toBe(true) - expect(JSON.stringify(dismissed.content)).toContain('MOUSE_APPROVAL_CANCELLED') - expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(ask).toHaveBeenCalledTimes(2) - expect(native.request).not.toHaveBeenCalled() -}) - -it('refuses delegated mouse control before asking the user even when the parent has approval', async () => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.spyOn(f.ctx.userQuestions, 'ask').mockResolvedValue({ - answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }], - }) - expect((await f.execute('requestMouseControl')).value).toBe(true) - ask.mockClear() +it.each([true, false])('rejects a delegated pointer move before asking or dispatching with approval=%s', async (approval) => { + const f = await hoverFixture(approval) const { agent } = await f.ctx.agents.create({ sessionId: SessionId('delegated-mouse-session'), meta: { origin: 'subagent', parentSession: f.handle.agent.id } }) - const refused = await executeToolFixture(f.ctx, { agent, signal: new AbortController().signal, - callId: ToolCallId('delegated-mouse-control'), name: 'cua', arguments: { method: 'requestMouseControl', params: {} } }) - expect(refused.isError).toBe(true) + // This case starts with application access already granted to the child. + const selectAnswer = vi.spyOn(f.ctx.userQuestions, 'ask') + .mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) + await f.select(agent) + selectAnswer.mockClear() + native.request.mockClear() + const refused = await f.call(agent, 'mouseMove', physicalHover) expect(JSON.stringify(refused.content)).toContain('MOUSE_CONTROL_UNAVAILABLE') - expect(JSON.stringify(refused.content)).toContain('report that step in your final result for the main task') - expect(ask).not.toHaveBeenCalled() - expect(native.request).not.toHaveBeenCalled() -}) - -it('drops late approval after cancellation and admits only one pending question', async () => { - const f = await setup() - expect((await f.execute('requestMouseControl')).isError).toBe(true) - await f.ctx.plugin(UserQuestions) - const entered = Promise.withResolvers() - let count = 0 - const release = Promise.withResolvers() - f.ctx.on('user-questions/request', async () => { - count++ - entered.resolve(undefined) - await release.promise - return { answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] } - }) - const controller = new AbortController() - const first = f.execute('requestMouseControl', {}, controller.signal) - await entered.promise - expect(JSON.stringify((await f.execute('requestMouseControl')).content)).toContain('MOUSE_CONTROL_PENDING') - controller.abort() - release.resolve(undefined) - await Promise.allSettled([first]) - expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(count).toBe(2) + expect(selectAnswer).not.toHaveBeenCalled() + expect(f.ask).not.toHaveBeenCalled() expect(native.request).not.toHaveBeenCalled() }) @@ -1089,8 +1128,8 @@ it('keeps provider-specific pointer flags and questions off providers without mo keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, }) } }) - expect(JSON.stringify((await f.execute('requestMouseControl')).content)).toContain('UNSUPPORTED_OPERATION') await selectApplication(f) + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('UNSUPPORTED_OPERATION') const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } expect((await f.execute('mouseMove', target)).isError).toBe(false) expect(native.request).toHaveBeenLastCalledWith('mouseMove', target, expect.any(AbortSignal), expect.any(String)) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index b72fd91584..62318feee3 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -15,8 +15,8 @@ en: 5db4d4fea25493b9 zh: 65ab64fc24eb0d09 /deepseek-ai-dsh-cua/use-this-package: - en: a55ba012aedca751 - zh: fcddea780c0ec58e + en: 5e95c138cb107385 + zh: b126c56fb7d35538 /deepseek-ai-dsh-cua/use-this-package/permission-controls: en: d92d5948a7941e8d zh: d43491ae6521b87e diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 39a3e522d8..c3d2ce62c8 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -41,7 +41,7 @@ Observation methods emit and return their result. Use `void await app.getAccessi Application consent precedes launch, observation, capture, and input. Session access is stored by conversation and application identity and survives DSH restart and Computer Use disablement. Other conversations require their own consent. Saved grants require a platform-provided reusable application identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect. -Use `cua.requestMouseControl()` only when the task requires moving the user’s pointer. Approval applies to `mouseMove` in this session until DSH restarts. Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. +On supported providers the main task can request a physical move with `win.mouseMove(target, { useSystemPointer: true })`. Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. `click(target, { holdMs: 800 })` holds the mouse button for 800 milliseconds before release. `holdMs` applies only to `click`; it accepts nonnegative integers and applies to each press of a double-click; zero releases without an additional hold. The provider config `maxClickHoldMs` limits explicit holds and defaults to 5000 ms. Explicit holds use pointer input; omission preserves ordinary click behavior. The operation deadline includes the hold, and cancellation or timeout releases the button. Received input cannot be undone; forced helper termination cannot guarantee release. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index c2933dc0d1..bde2ccd1a3 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 应用授权发生在启动、观察、截图和输入之前。会话许可按对话和应用身份持久化,重启 DSH 或关闭 Computer Use 后仍可复用,其他对话需要单独授权。长期许可要求平台提供可复用的应用身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。 -只有任务确实需要移动用户的鼠标时,才调用 `cua.requestMouseControl()` 询问用户。授权仅适用于当前会话的 `mouseMove`,重启 DSH 后失效。动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 +在支持系统鼠标的提供方上,主任务可通过 `win.mouseMove(target, { useSystemPointer: true })` 请求真实移动。动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 `click(target, { holdMs: 800 })` 按住鼠标按钮 800 毫秒后松开。`holdMs` 仅适用于 `click`;它接受非负整数,双击时应用于每次按下;0 表示按下后立即松开,不额外等待。提供方配置 `maxClickHoldMs` 限制显式按住时长,默认 5000 毫秒。指定时长后使用鼠标事件;省略时保留普通点击行为。按住时间计入操作超时,取消或超时会松开按钮。已接收的输入无法撤销;辅助进程被强制终止时无法保证松开。 diff --git a/packages/experimental/cua/src/api.ts b/packages/experimental/cua/src/api.ts index 930db84195..a33f4ca9a7 100644 --- a/packages/experimental/cua/src/api.ts +++ b/packages/experimental/cua/src/api.ts @@ -1,6 +1,6 @@ /** Object facade over logged native operations; its caller owns evaluator persistence. */ import { Buffer } from 'node:buffer' -import { z } from 'zod' +import type { z } from 'zod' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { ActionNamesResult, ActionResult, AppResult, AppsResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, StateResult, WindowsResult } from './protocol.ts' import type { CuaApi, CuaApp, CuaAppHandle, CuaCapabilities, CuaDispatch, CuaObservationOptions, CuaObservationSink, CuaOperation, CuaScreenshot, CuaSurface, CuaWindow } from './types.ts' @@ -51,9 +51,6 @@ export function createCuaApi(dispatch: CuaDispatch, sink?: CuaObservationSink): const objects = createCuaObjects(call, sink) const api: CuaApi = { - async requestMouseControl() { - return z.boolean().parse(await call('requestMouseControl', {})) - }, async getCapabilities(options) { const result = CapabilitiesResult.parse(await call('getCapabilities', {})) capabilities = result @@ -203,12 +200,15 @@ function createCuaObjects(call: ReturnType, sink: CuaObser return screenshot }, async mouseMove(targetPoint, options) { - const relative = options?.relative === undefined ? {} : { relative: options.relative } + const movement = { + ...options?.relative === undefined ? {} : { relative: options.relative }, + ...options?.useSystemPointer === undefined ? {} : { useSystemPointer: options.useSystemPointer }, + } if (typeof targetPoint === 'string') { - return HoverResult.parse(await base.call('mouseMove', { ...base.elementParams(), elementId: targetPoint, ...relative })) + return HoverResult.parse(await base.call('mouseMove', { ...base.elementParams(), elementId: targetPoint, ...movement })) } const [x, y] = targetPoint - return HoverResult.parse(await base.call('mouseMove', { ...coordinateParams(appHandle, windowId), x, y, ...relative })) + return HoverResult.parse(await base.call('mouseMove', { ...coordinateParams(appHandle, windowId), x, y, ...movement })) }, async click(targetPoint, options) { const mouseButton = options?.mouseButton ?? 'left' diff --git a/packages/experimental/cua/src/application-access.ts b/packages/experimental/cua/src/application-access.ts index 02066411e1..32bc31b811 100644 --- a/packages/experimental/cua/src/application-access.ts +++ b/packages/experimental/cua/src/application-access.ts @@ -231,16 +231,17 @@ export class ApplicationAccess { * @param handle Selected application. * @param signal Calling tool lifetime. * @param dispatch One native operation, with revocation cancellation. + * @param prepare Optional interactive preparation outside the application lock. Dispatch rechecks access after it settles. * @returns The native result, with no implicit retry after consent or cancellation. */ async run(client: NativeClient, handle: CuaAppHandle, signal: AbortSignal, - dispatch: (signal: AbortSignal) => Promise): Promise { + dispatch: (signal: AbortSignal) => Promise, prepare?: (signal: AbortSignal) => Promise): Promise { const state = this.clients.get(client) const selected = state?.handles.get(handle) if (!state || !selected) throw new NativeError('APP_ACCESS_REQUIRED', 'Select the application with getApp and obtain user consent before reading or controlling it') if (selected.lifetime.signal.aborted) throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application') const lifetime = AbortSignal.any([signal, this.lifetime.signal, selected.lifetime.signal]) - return this.options.store.withApplication(selected.identity.id, lifetime, async () => { + const requireAccess = async () => { const permitted = await this.options.store.withState(lifetime, ({ state: permissions }) => this.permitted(selected, permissions.applications[selected.identity.id] ?? this.empty())) if (!permitted) { @@ -248,6 +249,14 @@ export class ApplicationAccess { throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application') } if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles') + } + if (prepare) { + await requireAccess() + await prepare(lifetime) + lifetime.throwIfAborted() + } + return this.options.store.withApplication(selected.identity.id, lifetime, async () => { + await requireAccess() return this.withOperation(state.agent, client, selected.identity, lifetime, dispatch) }) } diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index a729901370..d11a575e37 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -8,7 +8,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -33,7 +32,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 410225856a..24f216fb78 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -127,6 +127,8 @@ export interface CuaBackend { createChannel: NativeChannelFactory guidance: string capabilities: CuaStartupCapabilities + /** Whether explicit system-pointer moves require consent. Omit for providers without this route. */ + requireSystemPointerApproval?: boolean /** Dispatch a directional key through the provider's execution-scoped input owner, after application authorization. */ sendKeyEvent?: (client: NativeClient, params: Record, execution: Readonly) => Promise @@ -160,7 +162,8 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) - const mouseControl = backend.capabilities.methods.includes('requestMouseControl') ? new MouseControl(ctx) : undefined + const mouseControl = backend.requireSystemPointerApproval === undefined + ? undefined : new MouseControl(ctx, backend.requireSystemPointerApproval) const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -209,7 +212,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) for (const result of [...await permissions, ...results, ...releases]) if (result.status === 'rejected') errors.push(result.reason) if (errors.length > 0) throw new AggregateError(errors, 'dsh-cua helper cleanup failed') } - const callNative = async (request: CuaOperation, execution: Readonly) => { + const callNative = async (request: CuaOperation, execution: Readonly, pointerRoute?: boolean) => { const { method, params: parsedParams } = request const params = execution.coordinates.parameters(request) const agent = execution.agent @@ -226,7 +229,10 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) ? { ...params, maxElements: config.maxElements, maxDepth: config.maxDepth } : method === 'drag' ? { ...params, durationMs: config.dragDurationMs, steps: config.dragSteps } : params - if (method === 'mouseMove' && mouseControl) nativeParams.useSystemPointer = mouseControl.allowed(agent) + if (method === 'mouseMove') { + delete nativeParams.useSystemPointer + if (mouseControl) nativeParams.useSystemPointer = pointerRoute ?? mouseControl.allowed(agent) + } const requestNative = () => client.request(method, nativeParams, signal, execution.callId) if (method === 'screenshot') { const { attachments, target } = await screenshotStore(ctx, execution) @@ -313,10 +319,6 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const signal = AbortSignal.any([execution.signal, lifetime.signal, owner.signal]) signal.throwIfAborted() const operation = (async () => { - if (request.method === 'requestMouseControl') { - if (!mouseControl) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') - return mouseControl.request(agent, signal) - } const previous = clients.get(agent) if (previous?.disposed) { try { await previous.dispose() } catch (error) { @@ -342,14 +344,28 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) owner.coordinates = new CuaCoordinates() } const nativeClient = client + let pointerRoute: boolean | undefined + let prepare: ((signal: AbortSignal) => Promise) | undefined + if (request.method === 'mouseMove') { + if (request.params.useSystemPointer === true) { + prepare = async (signal) => { + if (!mouseControl) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') + owner.coordinates.parameters(request) + if (!await mouseControl.request(agent, signal)) { + throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.') + } + pointerRoute = true + } + } else pointerRoute = request.params.useSystemPointer + } const dispatch = async (signal: AbortSignal) => { const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates } - const result = await callNative(request, scoped) + const result = await callNative(request, scoped, pointerRoute) signal.throwIfAborted() return result } return 'appHandle' in request.params - ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch) + ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, prepare) : dispatch(signal) })() pending.set(operation, agent) diff --git a/packages/experimental/cua/src/locales.ts b/packages/experimental/cua/src/locales.ts index 7c2cba49eb..fbd9b7ac43 100644 --- a/packages/experimental/cua/src/locales.ts +++ b/packages/experimental/cua/src/locales.ts @@ -117,14 +117,14 @@ export const mouseControlDisplay = { pt: 'Permitir que o agente mova o ponteiro do mouse?', }, detail: { - en: 'Hovering will move your pointer and leave it at the target, even while you are using the mouse. This permission applies only to this session until DSH restarts. If you decline, this session will not ask again before restart.', - zh: '智能体悬停时会移动你的鼠标指针,并将它留在目标位置,即使你正在使用鼠标。授权仅限当前会话,重启 DSH 后失效。如果拒绝,重启前本会话不会再次询问。', - ja: 'ホバーすると、マウスを使用中でもポインタが移動し、移動先に残ります。許可はこのセッションに限り、DSH を再起動すると失効します。拒否すると、再起動までこのセッションでは再度確認しません。', - es: 'Al pasar sobre un elemento, el puntero se moverá y quedará allí aunque estés usando el ratón. El permiso solo se aplica a esta sesión y caduca al reiniciar DSH. Si lo deniegas, esta sesión no volverá a preguntar hasta que reinicies DSH.', - fr: 'Lors d’un survol, le pointeur se déplace et reste sur la cible, même si vous utilisez la souris. Cette autorisation vaut pour cette session et expire au redémarrage de DSH. Si vous refusez, cette session ne redemandera pas avant le redémarrage.', - de: 'Beim Überfahren eines Ziels bewegt sich der Zeiger und bleibt dort, auch wenn Sie die Maus verwenden. Die Erlaubnis gilt nur für diese Sitzung und erlischt beim Neustart von DSH. Wenn Sie ablehnen, fragt diese Sitzung bis zum Neustart nicht erneut.', - ru: 'При наведении указатель переместится и останется на цели, даже если вы пользуетесь мышью. Разрешение действует только в этом сеансе до перезапуска DSH. Если вы откажете, запрос в этом сеансе не повторится до перезапуска.', - pt: 'Ao passar sobre um elemento, o ponteiro se moverá e ficará no destino, mesmo enquanto você usa o mouse. A permissão vale apenas para esta sessão e expira ao reiniciar o DSH. Se você recusar, esta sessão não perguntará novamente até a reinicialização.', + en: 'Allowing moves your pointer to this target now. Later hovers can also move it. If you decline, this session will not ask again. The decision expires when you close the session or restart DSH.', + zh: '允许后,你的鼠标指针会立即移到目标位置,本会话后续悬停也会移动指针。如果拒绝,本会话不会再询问。关闭会话或重启 DSH 后,这个决定失效。', + ja: '許可するとポインタが今すぐ移動し、以後のホバーでも移動します。拒否すると、このセッションでは再度確認しません。セッションを閉じるか DSH を再起動すると、この選択は失効します。', + es: 'Si aceptas, el puntero se moverá ahora al destino y también podrá moverse al pasar sobre otros elementos. Si rechazas, no se volverá a preguntar. La decisión caduca al cerrar la sesión o reiniciar DSH.', + fr: 'Si vous acceptez, le pointeur se déplace immédiatement sur la cible, puis lors des survols suivants. Si vous refusez, la question ne sera plus posée. Ce choix expire à la fermeture de la session ou au redémarrage de DSH.', + de: 'Bei Zustimmung bewegt sich der Zeiger sofort zum Ziel und auch bei späteren Mausbewegungen. Bei Ablehnung wird nicht erneut gefragt. Die Entscheidung gilt, bis Sie die Sitzung schließen oder DSH neu starten.', + ru: 'При разрешении указатель сразу переместится на цель и сможет перемещаться при следующих наведениях. При отказе вопрос не повторится. Решение действует до закрытия сеанса или перезапуска DSH.', + pt: 'Se permitir, o ponteiro irá para o alvo agora e poderá se mover nas próximas ações. Se recusar, a pergunta não será repetida. A decisão expira ao encerrar a sessão ou reiniciar o DSH.', }, approve: applicationAccessDisplay.approve, } diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts index 595cef0f0b..8482554943 100644 --- a/packages/experimental/cua/src/mouse-control.ts +++ b/packages/experimental/cua/src/mouse-control.ts @@ -7,9 +7,9 @@ import { NativeError } from './native-client.ts' /** Mouse control applies only to mouseMove and never grants access to an application. */ export class MouseControl { - private readonly decisions = new Map() + private readonly decisions = new WeakMap() - constructor(private readonly ctx: Context) {} + constructor(private readonly ctx: Context, private readonly requireApproval: boolean) {} /** * Select the native route without prompting the user. @@ -17,7 +17,7 @@ export class MouseControl { * @returns Whether this Session explicitly approved moving the system pointer. */ allowed(agent: Agent): boolean { - return this.decisions.get(agent.id) === true + return this.decisions.get(agent) === true } /** @@ -30,12 +30,13 @@ export class MouseControl { if (agent.session.header.origin === 'subagent') { throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") } - const decision = this.decisions.get(agent.id) + if (!this.requireApproval) return true + const decision = this.decisions.get(agent) if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'Wait for the pending mouse control request in this session before continuing') if (decision !== undefined) return decision const questions = this.ctx.get('userQuestions') if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') - this.decisions.set(agent.id, 'asking') + this.decisions.set(agent, 'asking') const id = 'cua-mouse-control' const approve = mouseControlDisplay.approve.en const reject = 'Deny' @@ -49,10 +50,10 @@ export class MouseControl { const [answer] = result.answers const granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() && answer.selected.length === 1 && answer.selected[0] === approve - this.decisions.set(agent.id, granted) + this.decisions.set(agent, granted) return granted } catch (error) { - this.decisions.delete(agent.id) + this.decisions.delete(agent) if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) } diff --git a/packages/experimental/cua/src/native-schema.ts b/packages/experimental/cua/src/native-schema.ts index 72487f2108..5bb061383f 100644 --- a/packages/experimental/cua/src/native-schema.ts +++ b/packages/experimental/cua/src/native-schema.ts @@ -1,7 +1,7 @@ /** Build-time native admission metadata derived from the shared operation definitions. */ import { z } from 'zod' import type { JSONSchema } from 'zod/v4/core' -import { NativeOnlyOperations, NativeResourceParameters, NativeToolOperations, type Request } from './protocol.ts' +import { NativeOnlyOperations, NativeResourceParameters, Request } from './protocol.ts' type Schema = JSONSchema.JSONSchema @@ -26,7 +26,7 @@ export function nativeProtocol( json: string } { const resources = new Map(Object.entries({ ...NativeResourceParameters, ...platformFields })) - const schemas = [...NativeToolOperations, ...NativeOnlyOperations, ...privateOperations] + const schemas = [...Request.options, ...NativeOnlyOperations, ...privateOperations] .map(schema => z.toJSONSchema(schema)) const fields = new Map>() const enums = new Map>>() diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 161da04ee7..d3f0d6a0b6 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -34,8 +34,10 @@ const imageBase64 = z.string().min(1).refine(value => Buffer.from(value, 'base64 message: 'Expected canonical base64 image data', }) -/** Operations shared by the model and native helper, before Host parameters are added. */ -export const NativeToolOperations = [ +const mouseMove = { useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.') } + +/** Model-submittable operations, before the Host applies permission decisions and resource limits. */ +export const Request = z.discriminatedUnion('method', [ operation('getCapabilities', {}), operation('listApps', {}), operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), operation('getApp', { selector: z.string().min(1) }), @@ -50,7 +52,8 @@ export const NativeToolOperations = [ operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }), operation('invoke', snapshot), z.object({ method: z.literal('mouseMove'), params: z.union([ - z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(), + z.object({ ...snapshot, ...mouseMove }).strict(), z.object({ ...absoluteCoordinate, ...mouseMove }).strict(), + z.object({ ...relativeCoordinate, ...mouseMove }).strict(), ]) }).strict(), z.object({ method: z.literal('click'), params: z.union([ z.object({ ...snapshot, ...click }).strict(), @@ -77,10 +80,7 @@ export const NativeToolOperations = [ fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), ]) }).strict(), operation('screenshot', window), -] as const - -/** Model-submittable operations. The Host answers mouse-control requests without dispatching native input. */ -export const Request = z.discriminatedUnion('method', [...NativeToolOperations, operation('requestMouseControl', {})]) +]) /** Resource parameters supplied by the Host, never accepted from model tool arguments. */ export const NativeResourceParameters = { diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index 77923d64be..7c596c87d4 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -196,10 +196,11 @@ export interface CuaWindow extends CuaSurface { * Returning and subsequent observations do not restore the previous pointer position. * Other input and application changes can interrupt the interaction; observe before continuing. * @param target - current element id or screenshot-local [x,y]. - * @param options - coordinate mode; applies only to [x,y]. + * @param options - Coordinate mode for points and optional system-pointer request. False explicitly selects pointer-free hover. * @returns dispatch evidence and the selected route; observe the resulting UI after any UI delay. */ - mouseMove(target: CuaElementId | readonly [number, number], options?: CuaCoordinateOptions): Promise + mouseMove(target: CuaElementId | readonly [number, number], + options?: CuaCoordinateOptions & { useSystemPointer?: boolean }): Promise /** * Click a current element id or screenshot-local point. * Cancellation or timeout releases the held button; received input cannot be undone. The operation deadline includes holdMs. @@ -246,12 +247,6 @@ export interface CuaApp extends CuaSurface { } /** Native app API; its caller owns global bindings and variable persistence. */ export interface CuaApi { - /** - * Ask to move the user's pointer with mouseMove for this session until restart. Only the main task can ask. - * @returns True on approval, false on refusal. Both decisions last until restart for this session. - * @throws If the user dismisses the question without deciding or another request is pending. - */ - requestMouseControl(): Promise /** * Inspect permissions without requesting them. * @param options - output options. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index 053010af02..50eb722493 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -891,10 +891,15 @@ it('expands menu contents and details only for the requested observation', async expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'getState', params: { appHandle: selected.appHandle, includeMenus: false, details: false } }) }) -it('requests mouse control without dispatching a move and returns the decision', async () => { - const dispatch = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) - const cua = createCuaApi(dispatch) - expect(await cua.requestMouseControl()).toBe(false) - expect(await cua.requestMouseControl()).toBe(true) - expect(dispatch.mock.calls).toEqual(Array(2).fill([{ method: 'requestMouseControl', params: {} }])) +it('passes explicit system-pointer intent for both mouseMove target forms', async () => { + const f = fixture() + const win = await (await f.api.getApp('Editor')).getWindow(23) + await win.mouseMove(element('0'), { useSystemPointer: true }) + expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: { + appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', elementId: '0', useSystemPointer: true, + } }) + await win.mouseMove([250, 750], { relative: true, useSystemPointer: false }) + expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: { + appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', x: 250, y: 750, relative: true, useSystemPointer: false, + } }) }) diff --git a/snapshots/session/cua-native/replay.override.json b/snapshots/session/cua-native/replay.override.json index e6bf49a2c3..eb110a4715 100644 --- a/snapshots/session/cua-native/replay.override.json +++ b/snapshots/session/cua-native/replay.override.json @@ -1431,9 +1431,9 @@ "index": 0, "block": { "type": "tool-call", - "id": "cua-pointer-request", + "id": "cua-explicit-pointer-move", "name": "cua", - "arguments": "{\"method\":\"requestMouseControl\",\"params\":{}}" + "arguments": "{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}" } }, { From def08e19a63ddabb7ccf4604717b4cc1ad282358 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:33:10 +0800 Subject: [PATCH 098/149] test(cua): record approval on the requested mouse move --- snapshots/session/cua-native/session.v4.jsonl | 8 ++--- .../cua-native/system-prompt.expected.md | 3 +- .../cua-native/tool-schemas.expected.json | 31 +++++++------------ .../session/cua-ptc/system-prompt.expected.md | 3 +- .../system-prompt.expected.md | 3 +- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 2dc362b2af..8e343139cc 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889225,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889225,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"requestMouseControl\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889794,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889794,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -227,9 +227,9 @@ {"type":"tool/result","data":{"turn":1,"step":43,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-unavailable"},"toolCallId":"cua-hover-unavailable","content":[{"type":"text","text":"Error: HOVER_UNAVAILABLE: No AX alternate UI at this target. No input was sent and the pointer did not move."}],"isError":true,"id":"{{message:89}}"}},"sourceEventSeqs":[224],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":43}} {"type":"step/start","data":{"turn":1,"step":44}} -{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890679,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890679,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-pointer-request"},"toolCallId":"cua-pointer-request","content":[{"type":"text","text":"true"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-explicit-pointer-move","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890679,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890679,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-explicit-pointer-move","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}"}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-explicit-pointer-move","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-explicit-pointer-move"},"toolCallId":"cua-explicit-pointer-move","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"system-pointer\",\"systemPointerMoved\":true}"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":44}} {"type":"step/start","data":{"turn":1,"step":45}} {"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890696,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890696,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 87c08e7425..037bde95e5 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -43,7 +43,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -68,7 +67,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 77b7653284..b9bb435797 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -484,6 +484,10 @@ "type": "string", "minLength": 1, "description": "Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot." + }, + "useSystemPointer": { + "description": "Request use of the system pointer. The Host checks permission before dispatch.", + "type": "boolean" } }, "required": [ @@ -521,6 +525,10 @@ "description": "Omitted or false uses pixels in the latest screenshot returned by this tool.", "type": "boolean", "const": false + }, + "useSystemPointer": { + "description": "Request use of the system pointer. The Host checks permission before dispatch.", + "type": "boolean" } }, "required": [ @@ -561,6 +569,10 @@ "type": "boolean", "const": true, "description": "Use integer thousandths of the window width and height, from 0 to 999." + }, + "useSystemPointer": { + "description": "Request use of the system pointer. The Host checks permission before dispatch.", + "type": "boolean" } }, "required": [ @@ -1413,25 +1425,6 @@ "params" ], "additionalProperties": false - }, - { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "requestMouseControl" - }, - "params": { - "type": "object", - "properties": {}, - "additionalProperties": false - } - }, - "required": [ - "method", - "params" - ], - "additionalProperties": false } ], "$defs": { diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 2155b8c9ea..d5580e4964 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -45,7 +45,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -70,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index ed2cfda2e8..3e0d591989 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -45,7 +45,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -70,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From 6f088b13e7d383a69202f28eaca354efea6d4e76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:42:40 +0800 Subject: [PATCH 099/149] perf(forge-panel): highlight only after scrolling settles Highlighting waits until the page is idle and the scrollport has been still for 200 ms, so a slow grammar's first tokenization never interrupts a scroll. Adds the Agent Note recording the changes view decisions. --- ...orge-changes-view-responsiveness.i18n.yaml | 19 ++++++++++ ...11-07-forge-changes-view-responsiveness.md | 33 +++++++++++++++++ ...07-forge-changes-view-responsiveness.zh.md | 33 +++++++++++++++++ .../client-ui-forge/README.i18n.yaml | 4 +-- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../client-ui-forge/src/client/ChangesTab.tsx | 3 +- .../client-ui-forge/src/client/DiffFile.tsx | 17 +++++---- .../src/client/section-reach.ts | 30 ++++++++++++++++ .../tests/diff-review.client.spec.tsx | 8 ++++- .../tests/section-reach.client.spec.ts | 36 +++++++++++++++++++ 11 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md create mode 100644 .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml new file mode 100644 index 0000000000..85b111788b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-07-forge-changes-view-responsiveness.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md +/agent-note-forge-changes-view-responsiveness: + en: e809ffea0d880a4b + zh: 6725e6a9bd1c3bd1 +/agent-note-forge-changes-view-responsiveness/problem: + en: 77fab27f3d4a4a59 + zh: 46e0fa8b9ef978a7 +/agent-note-forge-changes-view-responsiveness/decision: + en: 8a0a9ef23a83ce2b + zh: 840d67e79ae39ff1 +/agent-note-forge-changes-view-responsiveness/alternatives-considered: + en: 9d12ccf1d236676d + zh: 0ea63c5c16c62de7 +/agent-note-forge-changes-view-responsiveness/consequences: + en: 4087fff405624d95 + zh: 2f0e577eea4daf40 diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md new file mode 100644 index 0000000000..197552f117 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md @@ -0,0 +1,33 @@ +# Agent Note: Forge changes view responsiveness + +Status: implemented + +English | [中文](2026-11-07-forge-changes-view-responsiveness.zh.md) + +## Problem + +The forge changes view of a large change request froze the browser. Opening #7985 (366 files, 37,563 drawn lines) kept the main thread busy for about five seconds, "expand all" for fifteen, and every later state change, such as a poll result, a tool result, a file jump, or a keystroke in the file filter, re-rendered every file section. While the view was visible, the whole app slowed down: two subject-less `:has()` rules in `ui-sidebar-files` made each DOM change restyle the entire document, and the view's hundred thousand nodes made each restyle cost 170–260 ms, so a streaming Agent turn kept the main thread saturated. + +## Decision + +**Scoped `:has()` rules.** The navigator's covering rules name their row through `data-files-navigator-row`, which both hosts of `sidebar.files.navigator` set, so a DOM change outside such a row schedules no document-wide invalidation. + +**Sections draw what changed and what the reader reaches.** `ChangesTab` passes each memoized `DiffFile` values whose identity survives its renders: one handler object, one comment-actions object, and per-file strings and booleans. Tab visibility reaches only the menus and the composer through a context. An expanded file holds a 22 px row per drawn line until its section first comes within 1200 px of the scrollport, then draws its lines and keeps them. A jump keeps its target aligned for one second while nearby sections draw. + +**Idle-time highlighting.** Lines draw as plain text and take their tokens once the page is idle and the scrollport has been still for 200 ms, so tokenization never interrupts a scroll in progress. A pass highlights 100-line fragments while each idle period lasts and runs again only when the file's segments change or its own language's grammar loads; the shared highlighter's identity, which changes on every grammar load, no longer triggers one. + +**Fewer serial reads.** A panel state equal to the published one reaches no follower. `changeRequestDiff` reads the patch and `.gitattributes` together. Snapshots carry the base commit as `baseSha`; `ForgeService.diff` passes it to the provider, and forge-github starts the compare read while its GraphQL read confirms the head. + +## Alternatives considered + +**`content-visibility: auto` alone** cut style work as much as scoping the rules did, but kept the React render and Shiki tokenization of every expanded file at open, about 3 s of script, and its placeholder sizes would need estimates for every collapsed and expanded section. + +**Full virtualization that also unmounts far sections** would bound the DOM after long scrolls, but a section holds a comment draft and a text selection, which unmounting loses. + +**Highlighting in a Web Worker** removes tokenization from the main thread entirely, including a heavy grammar's first use, but the shared highlighter in `ui-primitives` owns Shiki; moving it is a separate change for every consumer. + +**A provider-private cache of base commits** would avoid the optional `baseSha` field, but it adds retained state without an owner that knows when a change request stops being watched. + +## Consequences + +Opening #7985 now takes 0.5 s of main-thread work instead of 5.2 s, and file jumps, filtering, polling, and Agent turns no longer produce long tasks. In exchange, the browser's find in page does not see lines of files the reader has not approached, highlighting restarts at 100-line fragment boundaries, so a multi-line comment or string crossing one can be mis-colored, and the first use of a slow grammar, such as C++, still produces a 400–550 ms long task while scrolling. A diff read costs a second compare request when the base commit changed after the latest status. diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md new file mode 100644 index 0000000000..80be6bf6fa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Forge 变更视图的响应速度 + +Status: implemented + +[English](2026-11-07-forge-changes-view-responsiveness.md) | 中文 + +## Problem + +大型变更请求的 forge 变更视图会让浏览器卡住。打开 #7985(366 个文件、37,563 个绘制行)时主线程忙约 5 秒,“全部展开”约 15 秒;之后的每次状态变化,例如轮询结果、工具结果、跳转文件或在文件筛选框中按键,都会重渲染所有文件段。视图可见时整个应用都变慢:`ui-sidebar-files` 中两条没有主体的 `:has()` 规则让每次 DOM 变化都重算整个文档的样式,而视图的十万个节点让每次重算耗时 170–260 ms,所以 Agent 流式输出的回合会让主线程一直饱和。 + +## Decision + +**限定 `:has()` 规则。** 导航器的遮盖规则通过 `data-files-navigator-row` 指定所在行,`sidebar.files.navigator` 的两个宿主都会设置它,因此这类行之外的 DOM 变化不再触发全文档失效。 + +**文件段只绘制变化的部分和读者接近的部分。** `ChangesTab` 传给每个 memo 化的 `DiffFile` 的值在多次渲染之间保持同一标识:一个处理函数对象、一个评论操作对象,以及每个文件的字符串和布尔值。标签页可见性通过 context 只传给菜单和评论编辑器。展开的文件在它的段第一次进入滚动区域 1200 px 范围之前,按每个绘制行 22 px 占位,之后绘制各行并一直保留。跳转后的一秒内,目标文件在周围文件段绘制时保持对齐。 + +**空闲时高亮。** 各行先以纯文本绘制,在页面空闲且滚动区域静止 200 ms 后得到 token,因此分词不会打断正在进行的滚动。一次高亮在每个空闲时段内处理多个 100 行的片段,只有文件的分段变化或该语言自己的语法加载完成时才重新运行;共享高亮器的标识在任何语法加载时都会变化,不再触发重新高亮。 + +**减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。 + +## Alternatives considered + +**只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。 + +**同时卸载远处文件段的完整虚拟化** 可以在长距离滚动后限制 DOM 规模,但文件段持有评论草稿和文本选区,卸载会丢失它们。 + +**在 Web Worker 中高亮** 能把分词完全移出主线程,包括重型语法的首次使用,但 Shiki 由 `ui-primitives` 中的共享高亮器持有;迁移它是影响所有使用方的另一项改动。 + +**由提供方私有缓存基准提交** 可以省去可选的 `baseSha` 字段,但会引入保留状态,而且没有一个知道变更请求何时不再被 watch 的所有者。 + +## Consequences + +打开 #7985 的主线程工作从 5.2 秒降到 0.5 秒,跳转文件、筛选、轮询和 Agent 回合不再产生长任务。代价是:浏览器的页内查找看不到读者尚未接近的文件中的行;高亮在 100 行片段边界处重新开始,跨越边界的多行注释或字符串可能着色错误;慢语法(例如 C++)首次使用时,滚动中仍会出现 400–550 ms 的长任务。最新状态之后基准提交有变化时,一次 diff 读取会多一次 compare 请求。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5bb4a25d1e..d2e078a9fc 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: c7e1b7d87ccccd2e zh: 42d864b918a6175c /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 0afedb76112dfcb8 - zh: 52d5ddc7a4f9d31c + en: d0756f4b236e1e6f + zh: 9a02f6fa80118c5a /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index ddbd23b094..bc116fbb1d 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -73,7 +73,7 @@ Each inspection also decides whether the worktree is missing: the Session's dire `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; the lines are highlighted with the shared code highlighter when the page is next idle, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; the lines are highlighted with the shared code highlighter in 100-line fragments when the page is idle and the scrollport has been still for 200 ms, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index bd8de3f4bf..81f2bed6f0 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -73,7 +73,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面下次空闲时用共享的代码高亮器高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面空闲且滚动区域静止 200 ms 后,用共享的代码高亮器按 100 行一片高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 8902be11e2..c47a16f9a6 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -611,7 +611,7 @@ export function ChangesTab({ const release = (): void => { cancelAnimationFrame(frame) for (const type of READER_SCROLL) port.removeEventListener(type, release) - if (releasePin.current === release) releasePin.current = null + releasePin.current = null } const keep = (now: number): void => { if (now > until) { @@ -880,6 +880,7 @@ export function ChangesTab({
{ + reach.scrolled() if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null }}> {files.map((file) => { diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index cb6c3ad121..db48c61457 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -160,12 +160,13 @@ function addChunkSpans(spans: FileSpans, chunk: HighlightChunk, highlighter: Cod } /** - * Highlight a file's drawn lines in idle time, once its language's grammar is ready: each idle period highlights - * fragments while it lasts, at least one, and the lines take their tokens when the last fragment is done. Until - * then, the lines keep the tokens of the previous pass, by line number, and new lines draw as plain text. A grammar - * that loads for another language starts no pass. + * Highlight a file's drawn lines in idle time while the scrollport is still, once its language's grammar is ready: + * each idle period highlights fragments while it lasts, at least one, and the lines take their tokens when the last + * fragment is done. Until then, the lines keep the tokens of the previous pass, by line number, and new lines draw as + * plain text. A grammar that loads for another language starts no pass. */ function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans | undefined { + const reach = useContext(SectionReachContext) const highlighter = useCodeHighlighter(languageForPath(path)) // The highlighter changes whenever any grammar loads; the empty fragment tells whether this language's is ready. const ready = highlighter('') !== undefined @@ -188,11 +189,13 @@ function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans next++ if ((deadline?.timeRemaining() ?? 0) <= 0) break } - cancel = whenIdle(step) + cancel = later(step) } - let cancel = whenIdle(step) + // Outside a changes view nothing scrolls the sections, so idle time alone gates the pass. + const later = reach === null ? whenIdle : (task: (deadline: IdleDeadline | undefined) => void) => reach.whenSettled(task) + let cancel = later(step) return () => { cancel() } - }, [segments, ready]) + }, [segments, ready, reach]) return spans } diff --git a/packages/experimental/client-ui-forge/src/client/section-reach.ts b/packages/experimental/client-ui-forge/src/client/section-reach.ts index d926bd1ee2..1cb3997531 100644 --- a/packages/experimental/client-ui-forge/src/client/section-reach.ts +++ b/packages/experimental/client-ui-forge/src/client/section-reach.ts @@ -9,6 +9,9 @@ import { createContext } from 'react' /** Distance beyond the scrollport's edges within which a section counts as reached. */ const REACH_MARGIN = '1200px 0px' +/** Time, in milliseconds, the scrollport stays still before deferred highlighting runs. */ +const SCROLL_QUIET_MS = 200 + const noop = (): void => {} /** @@ -18,6 +21,33 @@ const noop = (): void => {} export class SectionReach { private observer: IntersectionObserver | undefined private readonly waiting = new Map void>() + private scrolledAt = Number.NEGATIVE_INFINITY + + /** Record that the scrollport scrolled, which holds back work started through {@link whenSettled}. */ + scrolled(): void { + this.scrolledAt = performance.now() + } + + /** + * Run `task` once the page is idle and the scrollport has been still for {@link SCROLL_QUIET_MS}, so work that + * can wait never interrupts a scroll in progress. + * @param task - deferrable work; it receives the idle period, or undefined without one. + * @returns the canceller, which has no effect after `task` ran. + */ + whenSettled(task: (deadline: IdleDeadline | undefined) => void): () => void { + let cancel = noop + const attempt = (deadline: IdleDeadline | undefined): void => { + const wait = this.scrolledAt + SCROLL_QUIET_MS - performance.now() + if (wait <= 0) { + task(deadline) + return + } + const id = setTimeout(() => { cancel = whenIdle(attempt) }, wait) + cancel = () => { clearTimeout(id) } + } + cancel = whenIdle(attempt) + return () => { cancel() } + } /** * Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root. diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index 33f82ad73c..71bc95a914 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** Diff gestures, browser clipboard output, and retained comments at their captured lines. */ -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { DiffFile, type DiffFileProps } from '../src/client/DiffFile.tsx' @@ -249,3 +249,9 @@ it('places left and right records in unified rows and retains outdated records f expect(m.container.querySelectorAll('[data-forge-review-thread]')).toHaveLength(3) expect(within(m.container).getByRole('button', { name: /changed lines/ })).toBeTruthy() }) + +it('highlights a section drawn outside a changes view once the page is idle', async () => { + const m = mount({ file: { ...file, path: 'a.ts' } }) + expect(m.container.querySelector('[data-diff-code]')).toBeNull() + await waitFor(() => { expect(m.container.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) }) +}) diff --git a/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts index 3533147a0c..f33a2422ec 100644 --- a/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts @@ -81,6 +81,42 @@ describe('SectionReach', () => { }) }) +describe('SectionReach.whenSettled', () => { + it('waits for the scrollport to stay still before running deferred work, and can be cancelled while it waits', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + try { + vi.stubGlobal('requestIdleCallback', undefined) + const reach = new SectionReach() + const settled = vi.fn() + reach.whenSettled(settled) + vi.advanceTimersByTime(0) + expect(settled).toHaveBeenCalledOnce() + + reach.scrolled() + const held = vi.fn() + reach.whenSettled(held) + vi.advanceTimersByTime(0) + expect(held).not.toHaveBeenCalled() + vi.advanceTimersByTime(100) + reach.scrolled() + vi.advanceTimersByTime(100) + expect(held).not.toHaveBeenCalled() + vi.advanceTimersByTime(200) + expect(held).toHaveBeenCalledOnce() + + reach.scrolled() + const cancelled = vi.fn() + const stop = reach.whenSettled(cancelled) + vi.advanceTimersByTime(0) + stop() + vi.advanceTimersByTime(500) + expect(cancelled).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + describe('whenIdle', () => { it('waits for an idle period, at most the timeout, and can be cancelled', () => { const request = vi.fn<(task: () => void, options: { timeout: number }) => number>(() => 7) From b07f93af189d6d1723723647d87f7689ae122779 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:46:56 +0800 Subject: [PATCH 100/149] feat(forge-panel): filter review threads by resolution and fit the thread list to narrow views The thread list chooses Unresolved, Resolved, or All in the segmented radio group the lens uses, and drops the Outdated and Awaiting my reply filters with the Awaiting my reply tag. Its footer keeps only View on GitHub, after a note when the read was truncated, and each file group names the file before its directory. The list's width rules queried the changes view's container by name, which CSS Modules rename per file, so they never applied and a narrow view squeezed the files beside a 320px list. The rules now query the nearest container: a view at least 1040px wide docks the list; a narrower one floats it over the files with a scrim, moves the focus into it, and closes it before opening a thread; below 480px it takes the files' whole width with Back to changes. Escape, the scrim, and the close actions return the focus to the Threads button. Below 480px a file header shows the unresolved count alone and thread cards keep 6px margins. --- .../review-thread-list.expected.md | 14 +- apps/web/tests/forge-panel.e2e.ts | 17 ++- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangesTab.module.css | 8 ++ .../client-ui-forge/src/client/ChangesTab.tsx | 29 ++-- .../client-ui-forge/src/client/DiffFile.tsx | 5 +- .../src/client/ReviewControls.module.css | 7 +- .../src/client/ReviewControls.tsx | 64 ++++++--- .../src/client/ReviewThreads.module.css | 12 ++ .../src/client/ThreadList.module.css | 86 +++++++++--- .../client-ui-forge/src/client/ThreadList.tsx | 126 +++++++++++------- .../client-ui-forge/src/client/locales.ts | 40 +++--- .../src/client/review-threads.ts | 29 +--- .../tests/changes-tab.client.spec.tsx | 56 ++++++-- .../tests/review-thread-cards.client.spec.tsx | 20 +-- .../tests/review-threads.client.spec.ts | 27 +--- 18 files changed, 346 insertions(+), 202 deletions(-) diff --git a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md index 0a2d0acbd7..bdfb2dc803 100644 --- a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md +++ b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md @@ -1,17 +1,15 @@ - complementary "讨论列表,共 5 个": - text: 讨论 5 - button "关闭讨论列表" - - group "筛选讨论": - - button "未解决 4" - - button "已过期 1" - - button "已解决 1" - - button "需要我回复 0" - - heading "panel.md" [level=3] + - radiogroup "筛选讨论": + - radio "未解决 4" + - radio "已解决 1" + - radio "全部 5" [checked] + - heading "docs/panel.md" [level=3] - button "文件级讨论 Add a screenshot." - - heading "panel.ts" [level=3] + - heading "src/panel.ts" [level=3] - button "R2 · 1 条回复 Why two?" - button "L15 The old fifteen was a number." - button "R15 Settled. 已解决" - button "原在 R9 · 在 fffffff 上 This line moved. 已过期" - - text: github.com 上共 5 个讨论 · - button "在 GitHub 中查看" diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 1b0c0bc8fb..e8804e0253 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -1901,17 +1901,24 @@ it.skipIf(MODE === 'record')('overlays the review threads of a change request an await view.getByRole('button', { name: '讨论列表,共 5 个', exact: true }).click() const list = view.getByRole('complementary', { name: '讨论列表,共 5 个' }) - await expect.poll(() => list.getByRole('button', { pressed: true }).allTextContents()).toEqual(['未解决 4']) + await expect.poll(() => list.getByRole('radio', { checked: true }).allTextContents()).toEqual(['未解决4']) await expect.poll(() => list.locator('[data-forge-thread-item]').evaluateAll(items => items.map(item => item.getAttribute('data-forge-thread-item')))) .toEqual(['whole-file', 'on-line', 'old-side', 'outdated']) - await list.getByRole('button', { name: '已解决 1', exact: true }).click() + await list.getByRole('radio', { name: /^已解决/ }).click() await list.locator('[data-forge-thread-item="resolved"]').click() - // Opening a resolved thread unfolds it inside its file. + // Opening a resolved thread unfolds it inside its file, and the docked list stays. await panel.locator('[data-forge-thread="resolved"]').getByText('Settled.', { exact: true }).waitFor() - await list.getByRole('button', { name: '已解决 1', exact: true }).click() - await list.getByRole('button', { name: '需要我回复 0', exact: true }).waitFor() + await list.getByRole('radio', { name: /^全部/ }).click() + await expect.poll(() => list.locator('[data-forge-thread-item]').count()).toBe(5) await compareOrRefreshGolden(join(EXPECTED, 'review-thread-list.expected.md'), await captureStableAria(page, '[data-forge-thread-list]', scaffold.workspaceCwd), MODE) + // A view narrower than 480px gives the list the files' whole width and closes it before opening a thread. + await view.evaluate((element) => { element.style.width = '420px'; element.style.flex = 'none' }) + await expect.poll(() => list.evaluate(element => [getComputedStyle(element).position, element.getBoundingClientRect().width])) + .toEqual(['absolute', 420]) + await list.locator('[data-forge-thread-item="on-line"]').click() + await list.waitFor({ state: 'detached' }) + await view.evaluate((element) => { element.style.width = ''; element.style.flex = '' }) await onLine.getByRole('button', { name: '添加到输入框', exact: true }).click() await page.locator('[data-composer-input]').first().getByText('panel.ts R2', { exact: true }).first().waitFor() diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index a0392da274..b9dab01161 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: e9ecdf9670bd6324 - zh: 6f5e3027afb18a3e + en: 18b49590a4cda9ed + zh: 71e6de165fa4febc /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 78cab599dbd4aa57 zh: 87265844f34efbc5 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 190c23e7d0..230c028eb1 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -41,7 +41,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. -- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads; the count in a header, like the Threads toolbar button, opens the thread list, which groups the threads by file in file tree order, filters Unresolved, Outdated, Resolved, or Awaiting my reply, which keeps unresolved threads the signed-in user wrote in where someone else wrote last, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list names a truncated read. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. Threads are read again at each status while the view shows; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. +- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads; the count in a header, like the Threads toolbar button, opens the thread list, which groups the threads by file in file tree order, shows the Unresolved, Resolved, or All threads, Unresolved first, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list docks beside the files in a view at least 1,040px wide, floats over them in a narrower one and closes before it opens a thread there, and takes their whole width below 480px. The list names a truncated read. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. Threads are read again at each status while the view shows; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index f7ced4299b..a3ce630d6a 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 -- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数;点击文件标题上的计数或工具栏的“讨论”按钮会打开讨论列表。列表按文件树顺序分组,可按未解决、已过期、已解决和“需要我回复”筛选(“需要我回复”保留当前登录用户参与过、且最后一条评论来自他人的未解决讨论),点击讨论会跳到它的位置;若所选提交中没有该讨论,会先切换到全部提交。读取被截断时,列表会注明。讨论上只有“添加到输入框”(把整个讨论作为引用插入)和“在 GitHub 中查看”两个操作。视图显示期间,每收到一次状态就重新读取讨论;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 +- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数;点击文件标题上的计数或工具栏的“讨论”按钮会打开讨论列表。列表按文件树顺序分组,可显示未解决、已解决或全部讨论,默认显示未解决的讨论;点击讨论会跳到它的位置,若所选提交中没有该讨论,会先切换到全部提交。视图宽度不小于 1,040px 时列表停靠在文件旁;更窄时浮在文件之上,并在跳到讨论前先关闭;窄于 480px 时占满文件区的宽度。读取被截断时,列表会注明。讨论上只有“添加到输入框”(把整个讨论作为引用插入)和“在 GitHub 中查看”两个操作。视图显示期间,每收到一次状态就重新读取讨论;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 23452bbc00..8d5c5b2a1d 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -840,6 +840,14 @@ .threadCount:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); } } +/* A view narrower than 480px shows the count alone. */ +.threadCountNumber { display: none; } + +@container forge-changes (max-width: 479px) { + .threadCountText { display: none; } + .threadCountNumber { display: inline; } +} + /* Opens the thread list; pressed while the list shows. */ .threadsToggle { display: inline-flex; diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index d550841145..ec7fc28444 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -581,10 +581,13 @@ export function ChangesTab({ const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) const [threadList, setThreadList] = useState<{ readonly open: boolean - readonly filter: ThreadFilter | null + readonly filter: ThreadFilter /** File whose group the list scrolls to, or null. */ readonly file: string | null - }>({ open: false, filter: 'unresolved', file: null }) + /** Whether the reader just opened the list and it has not taken the focus yet. */ + readonly claim: boolean + }>({ open: false, filter: 'unresolved', file: null, claim: false }) + const threadsToggle = useRef(null) const [focus, setFocus] = useState(null) const allFiles = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) @@ -720,7 +723,13 @@ export function ChangesTab({ const threadsOf = (path: string): FileThreads | undefined => { const group = placed.files.get(path) return threadActions === null || group === undefined ? undefined - : { ...group, actions: threadActions, openList: () => { setThreadList(value => ({ ...value, open: true, file: path })) } } + : { ...group, actions: threadActions, + openList: () => { setThreadList(value => ({ ...value, open: true, file: path, claim: true })) } } + } + /** Close the thread list at the reader's request and return the focus to its toggle. */ + const closeThreadList = (): void => { + setThreadList(value => ({ ...value, open: false })) + threadsToggle.current?.focus() } /** Show a thread from the list: all commits for a thread the chosen ones do not hold, then its file, unfolded and centered. */ const openThread = ({ thread, place }: PlacedThread): void => { @@ -948,9 +957,9 @@ export function ChangesTab({ } {review !== null && review.threads.length > 0 && ( - )} @@ -1015,10 +1024,14 @@ export function ChangesTab({ {body} {allFiles.length > 0 && } {review !== null && data?.kind === 'change' && threadList.open && ( - file.path)} viewer={review.viewer} - truncated={review.truncated} host={new URL(data.url).host} url={data.url} + file.path)} truncated={review.truncated} url={data.url} filter={threadList.filter} choose={(filter) => { setThreadList(value => ({ ...value, filter })) }} file={threadList.file} - open={openThread} openLink={openLink} close={() => { setThreadList(value => ({ ...value, open: false })) }} t={t} /> + claim={threadList.claim} claimed={() => { setThreadList(value => ({ ...value, claim: false })) }} + open={(entry, covering) => { + if (covering) setThreadList(value => ({ ...value, open: false })) + openThread(entry) + }} + openLink={openLink} close={closeThreadList} t={t} /> )}
diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index 0e7c561125..97478379ef 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -501,7 +501,10 @@ export function DiffFile(props: DiffFileProps): ReactNode { {viewed === 'changed' && {t('viewed.changed')}} {threads !== undefined && threads.unresolved > 0 && ( + onClick={threads.openList}> + {t('thread.unresolvedCount', { count: threads.unresolved })} + {threads.unresolved} + )} {badge !== '' && {badge}} diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css b/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css index a08dc8e88e..655cf83171 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css @@ -1,6 +1,7 @@ -/* Review controls of the changes toolbar. The lens draws the segmented track and raised indicator of the shared - segmented control at the toolbar's 28px control height and 12px text, with each segment's file count after its label. */ -.lens { +/* Review controls of the changes toolbar. The segmented radio group of the lens and the thread list's filter draws the + track and raised indicator of the shared segmented control at the toolbar's 28px control height and 12px text, with + each segment's count after its label. */ +.segmented { box-sizing: border-box; display: grid; grid-auto-columns: minmax(0, 1fr); diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx index 735c0148c4..b6765c158a 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx @@ -1,7 +1,7 @@ /** * Review controls of the changes toolbar: the lens that keeps the files of one review layer, as a full-width * radio group above the file tree or as a menu where no tree shows; the switch that hides test files; and the - * share of shown files the reader marked viewed. + * share of shown files the reader marked viewed. The thread list shares the segmented radio group. */ import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react' @@ -33,17 +33,35 @@ export interface LensProps extends PropsLocale { readonly choose: (lens: ChangesLens) => void } +/** One segment of a {@link SegmentedChoice}. */ +export interface Segment { + /** Value the segment chooses. */ + readonly value: Value + /** Localized label. */ + readonly label: string + /** Count shown after the label, or null for none. */ + readonly count: number | null + /** Localized hover text of a disabled segment, or null for an enabled one. */ + readonly disabledTip: string | null +} + /** - * Render the lens as a radio group whose segments share its width; arrow keys, Home, and End move between enabled lenses. - * @param props - the lens, its counts, whether test files hide, the choose action, and copy. + * Render a radio group whose segments share its width, each with a count after its label; arrow keys, Home, and End + * move between enabled segments. + * @param props - the segments, the chosen value, the choose action, and the group's localized accessible name. * @returns the radio group. */ -export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): ReactNode { +export function SegmentedChoice({ segments, value, choose, label }: { + readonly segments: readonly Segment[] + readonly value: Value + readonly choose: (value: Value) => void + readonly label: string +}): ReactNode { const group = useRef(null) - const enabled = LENSES.filter(item => item !== 'test' || !hideTests) + const enabled = segments.filter(segment => segment.disabledTip === null).map(segment => segment.value) const move = (event: KeyboardEvent): void => { - const at = enabled.indexOf(lens) - let next: ChangesLens | undefined + const at = enabled.indexOf(value) + let next: Value | undefined switch (event.key) { case 'ArrowLeft': case 'ArrowUp': next = enabled[(at + enabled.length - 1) % enabled.length]; break case 'ArrowRight': case 'ArrowDown': next = enabled[(at + 1) % enabled.length]; break @@ -53,28 +71,40 @@ export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): } event.preventDefault() event.stopPropagation() - /* v8 ignore next -- at most the test lens is disabled, so every key lands on an enabled lens */ + /* v8 ignore next -- every caller keeps a segment enabled, so every key lands on one */ if (next === undefined) return choose(next) - group.current?.querySelector(`[data-forge-lens="${next}"]`)?.focus() + group.current?.querySelector(`[data-segment="${next}"]`)?.focus() } return ( -
- {LENSES.map((item) => { - const disabled = item === 'test' && hideTests +
+ {segments.map((segment) => { const button = ( - ) - return disabled ? {button} : button + return segment.disabledTip === null ? button : {button} })}
) } +/** + * Render the lens as a radio group whose segments share its width. + * @param props - the lens, its counts, whether test files hide, the choose action, and copy. + * @returns the radio group. + */ +export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): ReactNode { + // A disabled lens shows no count, since hidden test files are not counted. + return item === 'test' && hideTests + ? { value: item, label: t(LENS_LABEL[item]), count: null, disabledTip: t('lens.testHidden') } + : { value: item, label: t(LENS_LABEL[item]), count: counts[item], disabledTip: null })} /> +} + /** * Render the lens as a menu button, for a toolbar without the file tree beside it. * @param props - the lens, its counts, whether test files hide, the choose action, copy, and whether the owning tab is visible. diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css index 00ed916016..b84b5a8428 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -217,3 +217,15 @@ font: var(--dsw-font-xs-13); font-family: Inter, var(--dsw-font-family); } + +/* A changes view narrower than 480px gives the code more width; the query names no container, since CSS Modules rename + a container name per file, and the nearest inline-size container is the changes view. */ +@container (max-width: 479px) { + .card, + .folded, + .card[data-split][data-side="R"], + .folded[data-split][data-side="R"] { + width: calc(100% - 12px); + margin: 6px; + } +} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.module.css b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css index c38bf4b549..2e91c9681f 100644 --- a/packages/experimental/client-ui-forge/src/client/ThreadList.module.css +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css @@ -1,4 +1,9 @@ -/* The thread list docks at the end of the changes body on wide panes and floats over the files on narrow ones. */ +/* The thread list docks at the end of the changes body in a view at least 1040px wide, which leaves the code about + 480px beside the file tree and the list; it floats over the files with a scrim behind it in a narrower view, and + takes the files' whole width below 480px. The queries name no container, since CSS Modules rename a container name + per file; the nearest inline-size container is the changes view. */ +.scrim { display: none; } + .pane { display: flex; flex: none; @@ -13,16 +18,7 @@ font-family: Inter, var(--dsw-font-family); } -@container forge-changes (max-width: 759px) { - .pane { - position: absolute; - z-index: 3; - inset-block: 0; - inset-inline-end: 0; - width: min(320px, 100%); - box-shadow: var(--dsw-shadow-l2, 0 4px 16px rgb(0 0 0 / 12%)); - } -} +.pane:focus { outline: none; } .header { display: flex; @@ -34,6 +30,25 @@ .title { font-weight: 500; } +/* Takes the place of the title and the close button where the list covers the files' whole width. */ +.back { + display: none; + align-items: center; + gap: 2px; + height: 28px; + padding: 0 8px 0 4px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +@media (hover: hover) { + .back:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + .total { padding: 0 6px; border-radius: var(--dsw-radius-xs); @@ -64,9 +79,6 @@ } .filters { - display: flex; - flex-wrap: wrap; - gap: 6px; padding: 0 12px 10px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } @@ -83,12 +95,9 @@ .path { margin: 0; padding: 8px 6px 4px; - overflow: hidden; - color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: 500; } .item { @@ -155,6 +164,9 @@ } .link { + display: inline-flex; + align-items: center; + gap: 2px; padding: 0; border: 0; background: transparent; @@ -166,3 +178,41 @@ @media (hover: hover) { .link:hover { text-decoration: underline; } } + +/* Above the sticky file headers (z-index 2). */ +@container (max-width: 1039px) { + .scrim { + position: absolute; + z-index: 3; + inset: 0; + display: block; + background: var(--dsw-alias-bg-mask-1); + } + + .pane { + position: absolute; + z-index: 3; + inset-block: 0; + inset-inline-end: 0; + width: min(340px, calc(100% - 40px)); + box-shadow: var(--dsw-elevation-panel); + } +} + +@container (max-width: 479px) { + .scrim { display: none; } + + .pane { + width: 100%; + border-inline-start: 0; + box-shadow: none; + } + + .header { padding-inline-start: 6px; } + + .back { display: inline-flex; } + + .title, + .total, + .close { display: none; } +} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.tsx b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx index 66a968c788..cdebe62f5b 100644 --- a/packages/experimental/client-ui-forge/src/client/ThreadList.tsx +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx @@ -1,15 +1,18 @@ /** * The thread list of a change request beside its changes: every thread grouped by file in file tree order, narrowed - * by one filter, each opening the place it shows at. + * by whether it is resolved, each opening the place it shows at. A wide changes view docks the list beside the files; + * a narrower one floats it over them, and the narrowest gives it their whole width. */ -import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react' -import { IconCloseOutlineRegular, Pill, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { useLayoutEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react' +import { IconChevronLeftOutlineRegular, IconCloseOutlineRegular, IconRightUpOutlineRegular, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import { assertNever } from '@deepseek-ai/dsh-util-values' +import { FileLabel } from './FileLabel.tsx' +import { SegmentedChoice } from './ReviewControls.tsx' import { Avatar } from './ReviewThreads.tsx' -import { anchorRange, awaitsViewer, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts' +import { anchorRange, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts' import type { NS } from './locales.ts' import type { PlacedThread } from './thread-state.ts' import css from './ThreadList.module.css' @@ -22,25 +25,25 @@ export interface ThreadListProps extends PropsLocale { readonly entries: readonly PlacedThread[] /** Changed file paths in file tree order; threads on other paths follow them. */ readonly order: readonly string[] - /** Login of the signed-in user. */ - readonly viewer: string | null /** Whether the host has more threads than were read. */ readonly truncated: boolean - /** Host name the threads were read from, such as `github.com`. */ - readonly host: string /** Web page of the change request. */ readonly url: string - /** The active filter, or null for every thread. */ - readonly filter: ThreadFilter | null - /** Choose a filter, or null for every thread. */ - readonly choose: (filter: ThreadFilter | null) => void + /** The active filter. */ + readonly filter: ThreadFilter + /** Choose a filter. */ + readonly choose: (filter: ThreadFilter) => void /** File whose group the list scrolls to when it changes, or null. */ readonly file: string | null - /** Open the place of a thread. */ - readonly open: (entry: PlacedThread) => void + /** Whether the reader just opened the list, so a list that covers the files takes the focus. */ + readonly claim: boolean + /** Report that the list handled `claim`. */ + readonly claimed: () => void + /** Open the place of a thread; `covering` says the list covers the files, so the owner closes it first. */ + readonly open: (entry: PlacedThread, covering: boolean) => void /** Open a URL outside the app. */ readonly openLink: (url: string) => void - /** Close the list. */ + /** Close the list at the reader's request. */ readonly close: () => void } @@ -68,6 +71,11 @@ function meta(thread: ForgeReviewThread, place: ThreadPlace, t: Translate): stri return parts.join(' · ') } +/** Whether the list floats over the files or takes their width, which its narrow-view styles decide. */ +function covers(pane: HTMLElement | null): pane is HTMLElement { + return pane !== null && getComputedStyle(pane).position === 'absolute' +} + /** * The first comment as one line of plain text: without raw HTML, strong, strikethrough, and code marks, quote or * heading marks, and link targets. @@ -77,9 +85,8 @@ function plainExcerpt(body: string): string { .replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim() } -function Item({ entry, viewer, open, t }: { +function Item({ entry, open, t }: { readonly entry: PlacedThread - readonly viewer: string | null readonly open: (entry: PlacedThread) => void readonly t: Translate }): ReactNode { @@ -94,7 +101,6 @@ function Item({ entry, viewer, open, t }: { {place.kind === 'outdated' && {t('thread.outdatedTag')}} {place.kind === 'elsewhere' && {t('thread.elsewhereTag')}} {thread.resolved && {t('thread.filter.resolved')}} - {awaitsViewer(thread, viewer) && {t('thread.filter.mine')}} {first?.author?.bot === true && {t('thread.bot')}} @@ -104,25 +110,26 @@ function Item({ entry, viewer, open, t }: { /** * Render the thread list. * @param props - threads, their places, the file order, the filter, and the list's actions. - * @returns the list pane. + * @returns the list pane and the scrim behind it where it floats over the files. */ export function ThreadList(props: ThreadListProps): ReactNode { - const { entries, order, viewer, filter, t } = props + const { entries, order, filter, t } = props const threads = useMemo(() => entries.map(entry => entry.thread), [entries]) const counts = useMemo(() => Object.fromEntries(THREAD_FILTERS.map(item => [item, - threads.filter(thread => passesFilter(thread, item, viewer)).length])) as Record, [threads, viewer]) + threads.filter(thread => passesFilter(thread, item)).length])) as Record, [threads]) const groups = useMemo(() => { const position = new Map(order.map((path, index) => [path, index])) const byPath = new Map() for (const entry of entries) { - if (!passesFilter(entry.thread, filter, viewer)) continue + if (!passesFilter(entry.thread, filter)) continue const list = byPath.get(entry.thread.path) if (list === undefined) byPath.set(entry.thread.path, [entry]) else list.push(entry) } return [...byPath].sort(([a], [b]) => (position.get(a) ?? order.length) - (position.get(b) ?? order.length) || a.localeCompare(b)) .map(([path, list]) => [path, list.toSorted((a, b) => rank(a.place) - rank(b.place))] as const) - }, [entries, order, filter, viewer]) + }, [entries, order, filter]) + const pane = useRef(null) const rows = useRef(null) useLayoutEffect(() => { const container = rows.current @@ -132,32 +139,49 @@ export function ThreadList(props: ThreadListProps): ReactNode { if (group.dataset.forgeThreadGroup === props.file) group.scrollIntoView({ block: 'start' }) } }, [props.file]) - return + useLayoutEffect(() => { + if (!props.claim) return + props.claimed() + if (covers(pane.current)) pane.current.focus({ preventScroll: true }) + }, [props.claim]) + const escape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopPropagation() + props.close() + } + const open = (entry: PlacedThread): void => { props.open(entry, covers(pane.current)) } + return <> + -
+
{threads.length === 0 ?
{t('thread.empty')}
: groups.length === 0 ?
{t('thread.none')}
: groups.map(([path, list]) =>
diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 9ba50586b3..5a95837897 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -339,7 +339,8 @@ export const zh = { 'thread.originalFailed': '无法读取当时的文件:{message}', 'thread.openOnHost': '在 GitHub 中查看', 'thread.unresolvedCount': '{count} 条未解决', - 'thread.unresolvedAria': '{name} 有 {count} 条未解决的讨论,打开讨论列表', + 'thread.unresolvedAria': '{name} 有 {count} 条未解决的讨论,跳到下一条', + 'thread.next': '跳到下一条未解决的讨论', 'thread.treeCount': '{count} 条未解决的讨论', 'thread.loadFailed': '无法读取 GitHub 讨论:{message}', 'thread.inputFailed': '输入框暂时无法添加这个讨论,请稍后重试', @@ -690,7 +691,8 @@ export const en: Record = { 'thread.originalFailed': 'Could not read the file at that commit: {message}', 'thread.openOnHost': 'View on GitHub', 'thread.unresolvedCount': '{count} unresolved', - 'thread.unresolvedAria': '{name} has {count} unresolved threads; open the thread list', + 'thread.unresolvedAria': '{name} has {count} unresolved threads; go to the next one', + 'thread.next': 'Go to the next unresolved thread', 'thread.treeCount': '{count} unresolved threads', 'thread.loadFailed': 'Could not read GitHub threads: {message}', 'thread.inputFailed': 'The input could not take this thread; try again later', @@ -1045,7 +1047,8 @@ export const ja: Record = { 'thread.originalFailed': '当時のファイルを読み込めません:{message}', 'thread.openOnHost': 'GitHub で表示', 'thread.unresolvedCount': '未解決 {count} 件', - 'thread.unresolvedAria': '{name} に未解決のスレッドが {count} 件あります。スレッド一覧を開く', + 'thread.unresolvedAria': '{name} に未解決のスレッドが {count} 件あります。次のスレッドへ移動', + 'thread.next': '次の未解決のスレッドへ移動', 'thread.treeCount': '未解決のスレッド {count} 件', 'thread.loadFailed': 'GitHub のスレッドを読み込めません:{message}', 'thread.inputFailed': '入力欄にこのスレッドを追加できません。しばらくしてから再試行してください', @@ -1393,7 +1396,8 @@ export const es: Record = { 'thread.originalFailed': 'No se pudo leer el archivo de ese commit: {message}', 'thread.openOnHost': 'Ver en GitHub', 'thread.unresolvedCount': '{count} sin resolver', - 'thread.unresolvedAria': '{name} tiene {count} hilos sin resolver; abrir la lista de hilos', + 'thread.unresolvedAria': '{name} tiene {count} hilos sin resolver; ir al siguiente', + 'thread.next': 'Ir al siguiente hilo sin resolver', 'thread.treeCount': '{count} hilos sin resolver', 'thread.loadFailed': 'No se pudieron leer los hilos de GitHub: {message}', 'thread.inputFailed': 'El campo de entrada no pudo recibir este hilo; inténtalo más tarde', @@ -1741,7 +1745,8 @@ export const fr: Record = { 'thread.originalFailed': 'Impossible de lire le fichier à ce commit : {message}', 'thread.openOnHost': 'Voir sur GitHub', 'thread.unresolvedCount': '{count} non résolus', - 'thread.unresolvedAria': '{name} a {count} fils non résolus ; ouvrir la liste des fils', + 'thread.unresolvedAria': '{name} a {count} fils non résolus ; aller au suivant', + 'thread.next': 'Aller au fil non résolu suivant', 'thread.treeCount': '{count} fils non résolus', 'thread.loadFailed': 'Impossible de lire les fils GitHub : {message}', 'thread.inputFailed': 'Le champ de saisie n’a pas pu recevoir ce fil ; réessayez plus tard', @@ -2089,7 +2094,8 @@ export const de: Record = { 'thread.originalFailed': 'Die Datei in diesem Commit konnte nicht gelesen werden: {message}', 'thread.openOnHost': 'Auf GitHub ansehen', 'thread.unresolvedCount': '{count} ungelöst', - 'thread.unresolvedAria': '{name} hat {count} ungelöste Threads; Threadliste öffnen', + 'thread.unresolvedAria': '{name} hat {count} ungelöste Threads; zum nächsten springen', + 'thread.next': 'Zum nächsten ungelösten Thread springen', 'thread.treeCount': '{count} ungelöste Threads', 'thread.loadFailed': 'GitHub-Threads konnten nicht gelesen werden: {message}', 'thread.inputFailed': 'Das Eingabefeld konnte diesen Thread nicht aufnehmen; später erneut versuchen', @@ -2437,7 +2443,8 @@ export const ru: Record = { 'thread.originalFailed': 'Не удалось прочитать файл в том коммите: {message}', 'thread.openOnHost': 'Открыть на GitHub', 'thread.unresolvedCount': 'Нерешённых: {count}', - 'thread.unresolvedAria': 'В {name} нерешённых обсуждений: {count}. Открыть список обсуждений', + 'thread.unresolvedAria': 'В {name} нерешённых обсуждений: {count}. Перейти к следующему', + 'thread.next': 'Перейти к следующему нерешённому обсуждению', 'thread.treeCount': 'Нерешённых обсуждений: {count}', 'thread.loadFailed': 'Не удалось прочитать обсуждения GitHub: {message}', 'thread.inputFailed': 'Не удалось добавить обсуждение в поле ввода; повторите попытку позже', @@ -2785,7 +2792,8 @@ export const pt: Record = { 'thread.originalFailed': 'Não foi possível ler o arquivo naquele commit: {message}', 'thread.openOnHost': 'Ver no GitHub', 'thread.unresolvedCount': '{count} não resolvidas', - 'thread.unresolvedAria': '{name} tem {count} discussões não resolvidas; abrir a lista de discussões', + 'thread.unresolvedAria': '{name} tem {count} discussões não resolvidas; ir para a próxima', + 'thread.next': 'Ir para a próxima discussão não resolvida', 'thread.treeCount': '{count} discussões não resolvidas', 'thread.loadFailed': 'Não foi possível ler as discussões do GitHub: {message}', 'thread.inputFailed': 'O campo de entrada não pôde receber esta discussão; tente novamente mais tarde', diff --git a/packages/experimental/client-ui-forge/src/client/review-threads.ts b/packages/experimental/client-ui-forge/src/client/review-threads.ts index b560da934c..84091aae7f 100644 --- a/packages/experimental/client-ui-forge/src/client/review-threads.ts +++ b/packages/experimental/client-ui-forge/src/client/review-threads.ts @@ -1,12 +1,13 @@ /** - * Where the changes view shows each review thread of a change request, which threads each filter of the thread list - * keeps, and the composer quote of a whole thread. + * Where the changes view shows each review thread of a change request, the order a file shows them in, which threads + * each filter of the thread list keeps, and the composer quote of a whole thread. */ import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' import type { ForgeAnchor, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import { assertNever } from '@deepseek-ai/dsh-util-values' -import type { ForgeCommitRange, ForgeDiffFile } from '../types.ts' +import type { ForgeCommitRange, ForgeDiffFile, ForgeDiffHunk } from '../types.ts' +import { hunkRows } from './diff-rows.ts' /** The changes a view shows, which decides where a thread can sit. */ export interface ThreadView { @@ -65,6 +66,19 @@ export function anchorRange(side: 'L' | 'R', anchor: Pick(items: readonly Item[], file: ForgeDiffFile | undefined): Item[] { + const rank = ({ place }: Item): readonly [number, number, number] => { + switch (place.kind) { + case 'file': return [0, 0, 0] + case 'line': return [1, ...rowOf(file, place.side, place.endLine)] + case 'outdated': return [2, 0, 0] + case 'elsewhere': return [3, 0, 0] + /* v8 ignore next -- closed ThreadPlace union exhaustiveness guard */ + default: return assertNever(place, 'ThreadPlace') + } } - return file.hunks.length + const keyed = items.map(item => [rank(item), item] as const) + return keyed.toSorted(([a], [b]) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]).map(([, item]) => item) +} + +/** + * Where a line draws: twice the index of the collapsed lines that hold it, or twice a hunk's index plus one with the + * line's row in that hunk, so rows of one hunk keep their patch order across both sides. + */ +function rowOf(file: ForgeDiffFile | undefined, side: 'L' | 'R', line: number): readonly [number, number] { + if (file === undefined) return [0, line] + const spot = spotOf(file, side, line) + if (spot.kind === 'gap') return [2 * spot.index, line] + return [2 * spot.index + 1, hunkRows(spot.hunk).findIndex(row => (side === 'L' ? row.old : row.new) === line)] } /** diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index c7e4c4e00a..1448f60b4e 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -1693,7 +1693,7 @@ describe('ChangesTab review threads', () => { expect(card(m, 'in-gap')?.previousElementSibling?.getAttribute('data-forge-diff-gap')).toBe('1') expect(card(m, 'resolved')?.textContent).toContain('Resolved thread · reviewer') // The count alone shows below 480px. - expect([...within(app).getByRole('button', { name: 'app.ts has 4 unresolved threads; open the thread list' }).children] + expect([...within(app).getByRole('button', { name: 'app.ts has 4 unresolved threads; go to the next one' }).children] .map(text => text.textContent)).toEqual(['4 unresolved', '4']) expect(app.querySelector('[data-forge-outdated-threads]')?.textContent).toContain('1 outdated threads') fireEvent.click(within(card(m, 'outdated')!).getByRole('button', { name: en['thread.viewOriginal'] })) @@ -1746,9 +1746,7 @@ describe('ChangesTab review threads', () => { fireEvent.keyDown(list, { key: 'Escape' }) expect(m.view.queryByRole('complementary')).toBeNull() expect(document.activeElement).toBe(toggle) - scrolled.mockClear() - fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: /^app\.ts has 4 unresolved threads/ })) - expect(scrolled.mock.contexts).toContain(m.view.container.querySelector('[data-forge-thread-group="src/app.ts"]')) + fireEvent.click(toggle) fireEvent.click(within(m.view.getByRole('complementary')).getByRole('button', { name: en['thread.close'] })) expect(m.view.queryByRole('complementary')).toBeNull() fireEvent.click(toggle) @@ -1759,6 +1757,21 @@ describe('ChangesTab review threads', () => { expect(m.view.queryByRole('complementary')).toBeNull() }) + it('steps through a file\'s unresolved threads in display order from the count in its header', async () => { + const m = mount({ source, setup: reviewed() }) + await settle() + const shown: (string | undefined)[] = [] + for (let step = 0; step < 5; step += 1) { + scrolled.mockClear() + fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: 'app.ts has 4 unresolved threads; go to the next one' })) + await settle() + shown.push((scrolled.mock.contexts as HTMLElement[]).find(element => element.dataset.forgeThread !== undefined)?.dataset.forgeThread) + } + // R4 in the first hunk, R8 in the unchanged lines after it, L11 in the second hunk, then the outdated thread, then R4 again. + expect(shown).toEqual(['on-row', 'in-gap', 'old-side', 'outdated', 'on-row']) + expect(m.view.queryByRole('complementary')).toBeNull() + }) + it('takes the focus where the thread list covers the files, and closes it before opening a thread', async () => { // The narrow-view styles float the list; jsdom computes this rule in their place. const style = document.createElement('style') diff --git a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx index 172bf55921..0e27ce88c4 100644 --- a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx @@ -8,7 +8,7 @@ import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol import { en } from '../src/client/locales.ts' import { Avatar, OutdatedThreads, ThreadCard, type ThreadActions } from '../src/client/ReviewThreads.tsx' import { ThreadList, type ThreadListProps } from '../src/client/ThreadList.tsx' -import { reviewComment, reviewThread } from './thread-fixtures.client.ts' +import { diffFile, reviewComment, reviewThread } from './thread-fixtures.client.ts' const t = makeTranslate(en) const OLD = 'b'.repeat(40) @@ -157,7 +157,7 @@ describe('OutdatedThreads', () => { describe('ThreadList', () => { function list(overrides: Partial = {}) { const props: ThreadListProps = { - entries: [], order: [], truncated: false, url: 'https://github.com/octo/demo/pull/7', filter: 'all', file: null, claim: false, + entries: [], files: [], truncated: false, url: 'https://github.com/octo/demo/pull/7', filter: 'all', claim: false, claimed: vi.fn(), choose: vi.fn(), open: vi.fn(), openLink: vi.fn(), close: vi.fn(), t, ...overrides, } return { props, view: render() } @@ -199,15 +199,19 @@ describe('ThreadList', () => { expect(props.close).toHaveBeenCalledOnce() }) - it('groups threads by file in file order, scrolls to a file\'s group, and moves the filter with arrow keys', () => { - const entry = { thread: reviewThread({ path: 'b.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } - const other = { thread: reviewThread({ path: 'a.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } + it('groups threads by file in file order, orders them by row, and moves the filter with arrow keys', () => { + const at = (id: string, path: string, side: 'L' | 'R', line: number) => ({ thread: reviewThread({ id: id as ForgeThreadId, path }), + place: { kind: 'line', side, startLine: line, endLine: line } as const }) const empty = { thread: reviewThread({ path: 'c.ts', comments: [] }), place: { kind: 'file' } as const } - const { view, props } = list({ entries: [empty, entry, other], order: ['b.ts'], filter: 'unresolved', file: 'a.ts' }) + // In b.ts the added R2 row follows the deleted L2 row, and L9 sits in the unchanged lines after the hunk. + const files = [diffFile('b.ts', [{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' a', '-b', '+B'] }])] + const { view, props } = list({ entries: [empty, at('late', 'b.ts', 'L', 9), at('added', 'b.ts', 'R', 2), at('other', 'a.ts', 'R', 3), + at('deleted', 'b.ts', 'L', 2)], files, filter: 'unresolved' }) expect([...view.container.querySelectorAll('[data-forge-thread-group]')].map(group => group.getAttribute('data-forge-thread-group'))) .toEqual(['b.ts', 'a.ts', 'c.ts']) + expect([...view.container.querySelectorAll('[data-forge-thread-group="b.ts"] [data-forge-thread-item]')].map(item => item.getAttribute('data-forge-thread-item'))) + .toEqual(['deleted', 'added', 'late']) expect(view.container.querySelector('[data-forge-thread-group="c.ts"] [class*="excerpt"]')?.textContent).toBe('') - expect(scrolled.mock.contexts).toEqual([view.container.querySelector('[data-forge-thread-group="a.ts"]')]) fireEvent.keyDown(view.getByRole('radio', { name: /^Unresolved/ }), { key: 'ArrowRight' }) expect(props.choose).toHaveBeenCalledWith('resolved') }) diff --git a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts index 1ac3c0120a..f2f5f6e644 100644 --- a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from 'vitest' import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import { - anchorRange, formatThreadQuote, gapOf, hunkExcerpt, passesFilter, placeThread, threadQuoteSummary, THREAD_FILTERS, + anchorRange, formatThreadQuote, gapOf, inFileOrder, hunkExcerpt, passesFilter, placeThread, threadQuoteSummary, THREAD_FILTERS, } from '../src/client/review-threads.ts' -import type { ForgeDiffFile } from '../src/types.ts' -import { reviewComment, reviewThread } from './thread-fixtures.client.ts' +import { diffFile, reviewComment, reviewThread } from './thread-fixtures.client.ts' const HEAD = 'a'.repeat(40) const OLD = 'b'.repeat(40) @@ -44,14 +43,10 @@ it('names a side and its lines', () => { }) it('finds the collapsed unchanged lines that hold a line on either side', () => { - const file: ForgeDiffFile = { - path: 'a.ts', previousPath: null, status: 'modified', binary: false, oversized: false, untracked: false, additions: 2, deletions: 2, - digest: 'digest', layer: 'implementation', - hunks: [ - { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, - { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, - ], - } + const file = diffFile('a.ts', [ + { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, + { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, + ]) expect(gapOf(file, 'R', 2)).toBe(0) expect(gapOf(file, 'R', 6)).toBeUndefined() expect(gapOf(file, 'R', 10)).toBe(1) @@ -60,6 +55,23 @@ it('finds the collapsed unchanged lines that hold a line on either side', () => expect(gapOf(file, 'R', 40)).toBe(2) }) +it('orders a file\'s threads as its section shows them', () => { + const file = diffFile('a.ts', [ + { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, + { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, + ]) + const line = (id: string, side: 'L' | 'R', at: number) => ({ id, place: { kind: 'line', side, startLine: at, endLine: at } as const }) + const items = [ + { id: 'away', place: { kind: 'elsewhere' } as const }, { id: 'gone', place: { kind: 'outdated' } as const }, + line('tail', 'R', 40), line('added', 'R', 6), line('second-hunk', 'L', 20), line('before', 'R', 2), line('deleted', 'L', 6), + { id: 'whole', place: { kind: 'file' } as const }, line('between', 'L', 10), + ] + expect(inFileOrder(items, file).map(item => item.id)) + .toEqual(['whole', 'before', 'deleted', 'added', 'between', 'second-hunk', 'tail', 'gone', 'away']) + // Without the file's diff, lines sort by their number. + expect(inFileOrder([line('b', 'R', 9), line('a', 'L', 2)], undefined).map(item => item.id)).toEqual(['a', 'b']) +}) + describe('thread filters', () => { it('keeps the threads each filter names', () => { const outdated = reviewThread({ current: null }) diff --git a/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts index fe08cb6a60..d4aa1d0f02 100644 --- a/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts +++ b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts @@ -1,6 +1,7 @@ -/** Host review threads and comments shared by the browser-side thread tests. */ +/** Host review threads, comments, and diff files shared by the browser-side thread tests. */ import type { ForgeCommentId, ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgeDiffFile, ForgeDiffHunk } from '../src/types.ts' let comments = 0 @@ -41,3 +42,14 @@ export function reviewThread(overrides: Partial = {}): ForgeR ...overrides, } } + +/** + * Build a modified text file with hunks. + * @param path - the file's path. + * @param hunks - its hunks. + * @returns the diff file. + */ +export function diffFile(path: string, hunks: readonly ForgeDiffHunk[]): ForgeDiffFile { + return { path, previousPath: null, status: 'modified', binary: false, oversized: false, untracked: false, additions: 1, deletions: 1, + digest: 'digest', layer: 'implementation', hunks } +} From 040c93f9b9384de5c012bfdf7a5774dd84a7551e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:06 +0800 Subject: [PATCH 123/149] fix(forge-panel): report a refused patch before a failed .gitattributes read Both reads still run in parallel, and a failed patch read still cancels the attributes read, but the result now waits for both: the patch's failure passes on first, so a too-large or stale patch is never hidden by an attributes failure that happened to end earlier. Also documents the estimated height of undrawn files. --- .../client-ui-forge/README.i18n.yaml | 8 ++--- .../experimental/client-ui-forge/README.md | 3 +- .../experimental/client-ui-forge/README.zh.md | 3 +- .../experimental/client-ui-forge/src/index.ts | 25 ++++++-------- .../tests/controller.host.spec.ts | 34 ++++++++++++++----- 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index d2e078a9fc..2cbf20d4c3 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: c7e1b7d87ccccd2e zh: 42d864b918a6175c /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: d0756f4b236e1e6f - zh: 9a02f6fa80118c5a + en: 0d8e666fc1cd71db + zh: 69f3d10283c2edfc /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b @@ -30,8 +30,8 @@ en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 8f8ed6efb2fb7872 - zh: 489730ecefad078b + en: 4124d453778daf7b + zh: 33cab3137381942e /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index bc116fbb1d..784a05bfa2 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -69,7 +69,7 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where the first failure cancels the other read and waits for it to end, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where a failed patch read cancels the attributes read and both reads end before the result, the patch's failure passing on before the attributes failure, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. @@ -140,6 +140,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. +- **Undrawn files** — until the reader scrolls near an expanded file, it holds 22 px per drawn line, which leaves out side-by-side pairing, wrapped lines, unchanged-line rows, and review cards, so the scroll range shifts a little as files draw; its saved review comments also appear only once it draws. - **Find in page over long diffs** — an expanded file draws its lines only once the reader scrolls near it, so the browser's find in page does not match lines of files further down until they are drawn; a page-wide search of the diff needs a find control of the view's own. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 81f2bed6f0..e4154cc47b 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -69,7 +69,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,先失败的一方会取消另一方并等它结束,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,补丁读取失败时取消 `.gitattributes` 的读取,两者都结束后才给出结果,补丁的失败优先于 `.gitattributes` 的失败,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 @@ -140,6 +140,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **尚未绘制的文件**——读者滚动到展开的文件附近之前,它按每个绘制行 22 px 占位,没有计入左右对比的配对、自动换行、未变更行按钮和评审卡片,所以文件绘制时滚动范围会略有变化;它已保存的评审评论也要等它绘制后才显示。 - **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index b6dc034eac..6b05219291 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -690,8 +690,9 @@ export class ForgePanelController extends TypertRemoteService { } /** - * Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. The first failure - * cancels the other read and waits for it to end before it passes on. + * Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. A failed patch read + * cancels the attributes read. Both reads end before the result: the patch's failure passes on first, and the + * attributes failure only after the patch was read, so the outcome does not depend on which read ends first. * @param ref - change request whose repository holds the commit. * @param commit - commit the patch ends at. * @param readPatch - reads the patch under the given cancellation. @@ -702,21 +703,17 @@ export class ForgePanelController extends TypertRemoteService { private async patchAt( ref: ChangeRequestRef, commit: string, readPatch: (signal: AbortSignal) => Promise, signal: AbortSignal, ): Promise<{ readonly patch: string; readonly attributes: string }> { - const sibling = new AbortController() - const operation = AbortSignal.any([signal, sibling.signal]) - const patch = readPatch(operation).then((result) => { + const unneeded = new AbortController() + const patch = readPatch(signal).then((result) => { if (typeof result !== 'string') throw readError(result) return result }) - const attributes = this.attributesAt(ref, commit, operation) - try { - const [text, rules] = await Promise.all([patch, attributes]) - return { patch: text, attributes: rules } - } catch (error) { - sibling.abort() - await Promise.allSettled([patch, attributes]) - throw error - } + const attributes = this.attributesAt(ref, commit, AbortSignal.any([signal, unneeded.signal])) + void patch.then(undefined, () => { unneeded.abort() }) + const [text, rules] = await Promise.allSettled([patch, attributes]) + if (text.status === 'rejected') throw text.reason + if (rules.status === 'rejected') throw rules.reason + return { patch: text.value, attributes: rules.value } } /** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */ diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index 92b5a6bdbc..96257f8c29 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -728,26 +728,43 @@ describe('forgePanel change request diffs', () => { .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' }) }) - it('reads the patch and the .gitattributes together and cancels the other read when one fails', async () => { + it('reads the patch and the .gitattributes together, and reports the patch\'s failure before the attributes failure', async () => { const { repo } = await project() const opened = await open(repo) shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) await reader(opened).until(state => polled(state, pullUrl(7))) const read = async (): Promise => await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal) const cancelled: string[] = [] - const held = (name: string) => async (operation: AbortSignal): Promise => { - await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) - cancelled.push(name) - throw operation.reason + // The patch read ends only after the attributes read has failed, so the attributes failure always comes first. + let attributesFailed = Promise.withResolvers() + const later = (value: T) => async (): Promise => { + await attributesFailed.promise + return value } // Failed reads are not kept, so each case reads again. - const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(async (_ref, _head, operation) => await held('patch')(operation)) - const text = vi.spyOn(opened.provider, 'fileText').mockResolvedValue({ code: 'unavailable', message: 'attributes down' }) + const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(later({ code: 'too-large', message: 'diff over 300 files' })) + const text = vi.spyOn(opened.provider, 'fileText').mockImplementation(async () => { + attributesFailed.resolve(undefined) + return { code: 'unavailable', message: 'attributes down' } + }) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'diff over 300 files' }) + attributesFailed = Promise.withResolvers() + diff.mockImplementation(later({ code: 'stale', message: 'head moved' })) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/stale-head', message: 'head moved' }) + attributesFailed = Promise.withResolvers() + diff.mockImplementation(later(PATCH)) await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'attributes down' }) + + // A failed patch read cancels the attributes read it no longer needs. diff.mockResolvedValue({ code: 'unavailable', message: 'patch down' }) - text.mockImplementation(async (_ref, _commit, _path, operation) => await held('attributes')(operation)) + text.mockImplementation(async (_ref, _commit, _path, operation) => { + await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) + cancelled.push('attributes') + throw operation.reason + }) await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'patch down' }) + expect(cancelled).toEqual(['attributes']) // The patch read finishes only once the attributes read has started, so reading them one after the other never ends. const attributesStarted = Promise.withResolvers() @@ -760,7 +777,6 @@ describe('forgePanel change request diffs', () => { return 'src/** linguist-generated\n' }) await expect(read()).resolves.toMatchObject({ files: [{ path: 'src/a.ts', layer: 'test' }] }) - expect(cancelled).toEqual(['patch', 'attributes']) }) it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => { From 508b078373310038042d7c0e2dbff7f0ffa297e2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:08 +0800 Subject: [PATCH 124/149] fix(forge-github): keep the status when GitHub reports no full base commit The base commit only gives the diff read a head start, so a missing or malformed baseRefOid now leaves baseSha out instead of failing the status. --- packages/experimental/forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- packages/experimental/forge-github/README.zh.md | 2 +- packages/experimental/forge-github/src/graphql.ts | 5 +++-- packages/experimental/forge-github/tests/graphql.spec.ts | 7 +++++++ 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 9cee871506..ad2931d2cd 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 7cc428526bce20c2 - zh: e58b72d47ad8da87 + en: 937728d83a92e0f0 + zh: ea0805e75f2f4da0 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: f98e77171cba809f zh: 1b330cb342d69efe diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 2f7bbf6355..31ed39a9bc 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -46,7 +46,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready --repo //`. Merging calls `PUT repos///pulls//merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`. -`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. +`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`, and leaves it out when GitHub reports no full commit there; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 0851001833..d867f19592 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready --repo //`。合并经 `gh api` 调用 `PUT repos///pulls//merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。 -`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带;拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 +`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带(GitHub 返回的不是完整提交时省略);拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 diff --git a/packages/experimental/forge-github/src/graphql.ts b/packages/experimental/forge-github/src/graphql.ts index 92bcc4fbb2..ec97758af6 100644 --- a/packages/experimental/forge-github/src/graphql.ts +++ b/packages/experimental/forge-github/src/graphql.ts @@ -132,7 +132,8 @@ const pullRequestSchema = z.object({ headRefName: z.string(), headRefOid: z.string(), baseRefName: z.string(), - baseRefOid: z.string().refine(isCommit), + // Only the diff read's head start uses the base commit, so a missing or malformed one leaves the rest of the status intact. + baseRefOid: z.unknown().transform(value => typeof value === 'string' && isCommit(value) ? value : undefined), additions: z.number().int().nonnegative(), deletions: z.number().int().nonnegative(), mergeable: z.string(), @@ -467,7 +468,7 @@ function toSnapshot( headBranch: node.headRefName, headSha: node.headRefOid, baseBranch: node.baseRefName, - baseSha: node.baseRefOid, + ...node.baseRefOid === undefined ? {} : { baseSha: node.baseRefOid }, additions: node.additions, deletions: node.deletions, mergeability: mergeability(node.mergeable), diff --git a/packages/experimental/forge-github/tests/graphql.spec.ts b/packages/experimental/forge-github/tests/graphql.spec.ts index 58abd1f67f..36bb799343 100644 --- a/packages/experimental/forge-github/tests/graphql.spec.ts +++ b/packages/experimental/forge-github/tests/graphql.spec.ts @@ -45,6 +45,13 @@ describe('snapshot query', () => { }) describe('snapshot mapping', () => { + it.each([['missing', undefined], ['null', null], ['not a full commit', 'HEAD~1']])( + 'keeps the status and leaves out the base commit when GitHub reports it %s', async (_case, baseRefOid) => { + const snapshot = await single(pullRequestNode(1, { baseRefOid })) + expect(snapshot).not.toHaveProperty('baseSha') + expect(snapshot.headSha).toBe('f'.repeat(40)) + }) + it('maps the pull request fields into a provider-neutral snapshot', async () => { expect((await single(pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')])))).toEqual({ ref: pull(1), From 1f0eb449a3438862e5307b44045caf52ed1b3870 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:09 +0800 Subject: [PATCH 125/149] fix(forge-panel): end a jump's alignment when the tab hides Also explains why the alignment measures every frame and why the section handlers read values assigned during render. --- .../client-ui-forge/src/client/ChangesTab.tsx | 11 +++++++++-- .../client-ui-forge/tests/changes-tab.client.spec.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 4b4a06a920..cc01ad2a19 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -602,7 +602,9 @@ export function ChangesTab({ const releasePin = useRef<(() => void) | null>(null) /** * Keep a jumped-to section at the top of the scrollport for {@link JUMP_PIN_MS}: the sections around it draw their - * lines after the jump, which moves it. Scrolling input from the reader, another jump, or unmounting ends the pin. + * lines after the jump, which moves it. Scrolling input from the reader, another jump, hiding the tab, or unmounting + * ends the pin. Each frame measures the section once, which covers every cause of movement, such as a section + * drawing its lines, deferred highlighting, or a review card opening, without each one reporting it. */ const pinJump = (port: HTMLElement, section: HTMLElement, owner: ChangesData): void => { releasePin.current?.() @@ -644,6 +646,8 @@ export function ChangesTab({ if (section !== undefined && scrollport.current !== null) pinJump(scrollport.current, section, data) setWanted(null) }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) + // A hidden tab has nothing to keep in place, and its scrollport measures as empty. + useEffect(() => { if (!tab.visible) releasePin.current?.() }, [tab.visible]) useEffect(() => () => { releasePin.current?.() }, []) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null @@ -798,7 +802,10 @@ export function ChangesTab({ markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed') } - // What the section handlers and comment actions read when they run; replaced on every render. + // What the section handlers and comment actions read when they run, replaced on every render. The assignment happens + // during render rather than in an effect because the file menus read their actions while this same render draws them; + // an effect would hand them the previous diff's links. A render React discards leaves values derived from the same + // props and state, which the retried render assigns again. const live = useRef({ data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t }) live.current = { data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t } const handlers = useMemo((): DiffFileHandlers => ({ diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index 69121f7e76..230f00809e 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -971,7 +971,7 @@ describe('ChangesTab layout', () => { expect(scrolled).toHaveBeenCalledWith({ block: 'start' }) }) - it('keeps a jumped-to file at the top while sections around it draw, until the reader scrolls or a second passes', async () => { + it('keeps a jumped-to file at the top while sections around it draw, until the reader scrolls, the tab hides, or a second passes', async () => { const m = mount() await settle() const { viewport } = fileViewport(m) @@ -1013,6 +1013,15 @@ describe('ChangesTab layout', () => { shift = 80 act(() => { vi.advanceTimersToNextFrame() }) expect(scrolled).toHaveBeenCalledTimes(3) + + // Hiding the tab ends the pin at once. + shift = 0 + jump() + expect(scrolled).toHaveBeenCalledTimes(4) + act(() => { m.visibility.set(false) }) + shift = 80 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(4) } finally { vi.useRealTimers() } From c5054b5fffb4ec6459421d60727aee550ce99aed Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:09 +0800 Subject: [PATCH 126/149] docs(forge): record why the changes view measures pixels and how hosts mark the navigator row --- .../2026-11-07-forge-changes-view-responsiveness.i18n.yaml | 4 ++-- .../bug-fix/2026-11-07-forge-changes-view-responsiveness.md | 2 ++ .../2026-11-07-forge-changes-view-responsiveness.zh.md | 2 ++ packages/client/ui-sidebar-files/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-files/README.md | 2 +- packages/client/ui-sidebar-files/README.zh.md | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml index 85b111788b..cc2e9c4b68 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml @@ -9,8 +9,8 @@ en: 77fab27f3d4a4a59 zh: 46e0fa8b9ef978a7 /agent-note-forge-changes-view-responsiveness/decision: - en: 8a0a9ef23a83ce2b - zh: 840d67e79ae39ff1 + en: a7127dc65ac6d22c + zh: 8582e17cf52db811 /agent-note-forge-changes-view-responsiveness/alternatives-considered: en: 9d12ccf1d236676d zh: 0ea63c5c16c62de7 diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md index 197552f117..73583334b2 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md @@ -18,6 +18,8 @@ The forge changes view of a large change request froze the browser. Opening #798 **Fewer serial reads.** A panel state equal to the published one reaches no follower. `changeRequestDiff` reads the patch and `.gitattributes` together. Snapshots carry the base commit as `baseSha`; `ForgeService.diff` passes it to the provider, and forge-github starts the compare read while its GraphQL read confirms the head. +**Pixel measurement.** Two parts measure layout in pixels, which CSS cannot replace. Whether a section is near the scrollport decides whether React renders its lines at all; `content-visibility` only skips layout and paint of nodes that already exist. Keeping a jumped-to section at the top needs its offset after each later layout change, since no CSS property scrolls a container to follow one child. The client packages offer no injected measurement hook, so both read the DOM directly, as the view's file navigation already does: the reach uses one `IntersectionObserver`, which measures off the main thread, and the pin reads two rectangles per frame for at most one second. + ## Alternatives considered **`content-visibility: auto` alone** cut style work as much as scoping the rules did, but kept the React render and Shiki tokenization of every expanded file at open, about 3 s of script, and its placeholder sizes would need estimates for every collapsed and expanded section. diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md index 80be6bf6fa..999ac762fc 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md @@ -18,6 +18,8 @@ Status: implemented **减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。 +**像素测量。** 有两处需要按像素测量布局,CSS 无法替代。文件段是否接近滚动区域,决定 React 是否渲染它的各行;`content-visibility` 只能跳过已有节点的布局与绘制。让跳转后的文件段保持在顶部,需要在之后每次布局变化后读取它的偏移,没有哪个 CSS 属性能让容器跟随某个子元素滚动。客户端包没有可注入的测量 hook,所以这两处像视图已有的文件导航一样直接读取 DOM:接近判断用一个 `IntersectionObserver`,在主线程之外测量;对齐每帧读取两个矩形,最多持续一秒。 + ## Alternatives considered **只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。 diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index 0dfbf36f96..dd3d34d879 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -18,8 +18,8 @@ en: c4b8f3a52f68bd51 zh: 30b580320637572c /deepseek-ai-dsh-client-ui-sidebar-files/the-navigator: - en: 7e3aebc6079b023b - zh: 11911cc59fcec9b1 + en: 3424600b15bde2d2 + zh: 2cf1c99ffef2ad50 /deepseek-ai-dsh-client-ui-sidebar-files/the-tree: en: 3d71e50801761213 zh: e4e9ab19dd22288d diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index 2aea08292d..3991ccec18 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -41,7 +41,7 @@ The [registration](src/client/index.ts) assembles the page, navigator, toggle, a ## The navigator -Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. +Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. Render the Factory as a direct child of the row it shares with the body, and mark that row with `data-files-navigator-row`: the layout that fills the row hides the row's other children only under that attribute, so a host without it leaves the covered body reachable by keyboard. The navigator is drawn below the preview's header, which spans the pane, at the right of the row it shares with the body. Beside a preview, its pane's record decides whether it shows: a pane starts with it hidden, and the choice holds for every file tab of the pane until the toggle, Escape, the workspace command, or navigation to a covered preview changes it. A pane without a record of its own, such as a floating panel a tab was dragged out into, starts hidden at the default width. A click outside the tree does not hide it, and opening content in another pane does not hide it either. On the files page the tree fills the page below the header, docked or floating, without a separator, and always shows: the page draws no navigator toggle and Escape leaves it. A file the page opens starts with the destination pane's own choice, hidden for a pane that has made none. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index 18e9caef84..5c5e776ad2 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" ## 导航栏 -用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。 +用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。Factory 要作为它与正文共用的那一行的直接子元素渲染,并给该行加上 `data-files-navigator-row`:铺满整行的布局只在带这个属性的行里隐藏其他子元素,宿主不加时,被盖住的正文仍可用键盘访问。 导航栏画在横跨分栏的预览标题行下方,位于它与正文共用的那一行的右侧。在预览旁,是否显示由其分栏的记录决定:分栏默认隐藏,这一选择对分栏内的每个文件 tab 都有效,直到开关、Escape、工作区命令,或导航到被目录树遮住的预览改变它。自身没有记录的分栏(例如 tab 拖出后所在的浮窗)从隐藏、默认宽度开始。点击目录树以外的地方不会隐藏它;在另一分栏打开内容也不会隐藏它。在文件页上,目录树铺满标题行下方的整页,停靠与浮动时都一样,不显示分隔条,且始终显示:文件页不画导航栏开关,Escape 也不关闭它。文件页打开的文件沿用落定分栏自己的选择,尚未做过选择的分栏为隐藏。 From ac53809d66c89f83943f7d3378024634b80e2ca4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 18:18:03 +0800 Subject: [PATCH 127/149] refactor(cua): own system-pointer consent in the shared host --- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../experimental/cua-macos/src/guidance.ts | 3 +- packages/experimental/cua-macos/src/index.ts | 23 +---- .../cua-macos/src/mouse-control.ts | 55 ----------- .../cua-macos/tests/guidance.spec.ts | 6 +- .../tests/protocol-generation.spec.ts | 11 +-- .../tests/provider-lifecycle.spec.ts | 58 ++++++----- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/package.json | 5 - .../cua/src/client/ApplicationApproval.tsx | 4 +- packages/experimental/cua/src/config.ts | 1 + packages/experimental/cua/src/guidance.ts | 18 +++- packages/experimental/cua/src/index.ts | 32 ++++--- .../src/mouse-control-locales.ts | 0 .../experimental/cua/src/mouse-control.ts | 63 ++++++++++++ packages/experimental/cua/src/protocol.ts | 95 +++++++++++-------- .../cua/src/session-permissions.ts | 54 ----------- packages/experimental/cua/src/types.ts | 12 ++- packages/experimental/cua/tests/api.spec.ts | 2 +- .../application-approval.client.spec.tsx | 2 +- .../experimental/cua/tests/bridge.spec.ts | 2 +- .../cua/tests/fixtures/exports.mjs | 2 - .../experimental/cua/tests/protocol.spec.ts | 4 +- packages/experimental/cua/tsdown.config.ts | 1 - scripts/check-workspace-constraints.ts | 2 +- tsconfig.base.json | 1 - 31 files changed, 219 insertions(+), 265 deletions(-) delete mode 100644 packages/experimental/cua-macos/src/mouse-control.ts rename packages/experimental/{cua-macos => cua}/src/mouse-control-locales.ts (100%) create mode 100644 packages/experimental/cua/src/mouse-control.ts delete mode 100644 packages/experimental/cua/src/session-permissions.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 52d8ee4329..07e8c52196 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1156,15 +1156,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:48`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Ask once per session before the first explicitly requested system-pointer move. */ - requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 73281cb49a..8308df3e6f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1158,15 +1158,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:48`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Ask once per session before the first explicitly requested system-pointer move. */ - requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index ad896c5764..7b5df96219 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -187,7 +187,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -227,6 +227,8 @@ Available at app or window level depending on the platform. - sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. + +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token effect diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 973613327c..8db5bee329 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -187,7 +187,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -227,6 +227,8 @@ Available at app or window level depending on the platform. - sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. + +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token 影响 diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index c5c1a07381..0428941edb 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -6,7 +6,7 @@ export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are avail const methodDetails = new Map([ ['getAccessibilityTree', 'If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current.'], - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) @@ -16,7 +16,6 @@ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { if (method === 'pressKey') return `${entry} - sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` - if (method === 'mouseMove') entry = entry.replace('relative?: boolean', 'relative?: boolean, useSystemPointer?: boolean') const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 5156e30ab1..fb3fada07e 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -4,31 +4,16 @@ import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' import { installCua, type CuaConfig, type CuaStartupCapabilities } from '@deepseek-ai/dsh-cua' -import type { CuaCoordinateOptions, CuaElementId, CuaHoverResult } from '@deepseek-ai/dsh-cua/api' import { CuaConfigFields } from '@deepseek-ai/dsh-cua/config' import { createPermissionParameters } from './permission-guide.ts' import { nativeHostArchitecture, prepareNativeExecutable, type SourceSigning } from './native-build.ts' import { createSharedNativeChannel } from './shared-native.ts' -import { mouseMovePreparation, MacRequest } from './mouse-control.ts' import { installHeldKeys } from './held-keys.ts' import { GUIDANCE } from './guidance.ts' export { GUIDANCE } from './guidance.ts' -declare module '@deepseek-ai/dsh-cua/api' { - interface CuaWindow { - /** - * Request the macOS system pointer or explicitly select accessibility hover for this move. - * @param target Current element id or screenshot-local point. - * @param options Coordinate mode and requested macOS pointer behavior. - * @returns Dispatch evidence and the selected hover route. - */ - mouseMove(target: CuaElementId | readonly [number, number], - options: CuaCoordinateOptions & { useSystemPointer: boolean }): Promise - } -} - const CAPABILITIES: CuaStartupCapabilities = { - platform: 'macos', + platform: 'macos', systemPointer: true, methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', @@ -48,8 +33,6 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Ask once per session before the first explicitly requested system-pointer move. */ - requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -60,7 +43,6 @@ export interface Config extends CuaConfig { /** Validate shared operation bounds and macOS source signing before preparing the helper. */ export const Config = Schema.object({ ...CuaConfigFields, - requireSystemPointerApproval: Schema.boolean().default(true), permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000), sourceSigning: Schema.union([ Schema.const('ad-hoc'), @@ -80,8 +62,7 @@ export const Config = Schema.object({ */ export async function apply(ctx: Context, config: Config): Promise { const backend = { createChannel: createSharedNativeChannel, sendKeyEvent: installHeldKeys(ctx), - guidance: GUIDANCE, capabilities: CAPABILITIES, requestSchema: MacRequest, - prepareMouseMove: mouseMovePreparation(ctx, config.requireSystemPointerApproval), + guidance: GUIDANCE, capabilities: CAPABILITIES, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/src/mouse-control.ts b/packages/experimental/cua-macos/src/mouse-control.ts deleted file mode 100644 index 94499ffa8a..0000000000 --- a/packages/experimental/cua-macos/src/mouse-control.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** macOS system-pointer options and session approval for physical hover. */ -import type { Context } from '@deepseek-ai/cordis' -import type { CuaBackend } from '@deepseek-ai/dsh-cua' -import { CuaSessionPermissions } from '@deepseek-ai/dsh-cua/session-permissions' -import { NativeError } from '@deepseek-ai/dsh-cua/native-client' -import { MouseMoveTargets, Request } from '@deepseek-ai/dsh-cua/protocol' -import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import { z } from 'zod' -import { mouseControlPrompt } from './mouse-control-locales.ts' - -const pointerOptions = { useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.') } -const mouseMove = z.strictObject({ method: z.literal('mouseMove'), params: z.union(MouseMoveTargets.map(target => target.extend(pointerOptions))) }) - -const [first, ...rest] = Request.options -const macOperation = (op: typeof Request.options[number]) => op.shape.method.value === 'mouseMove' ? mouseMove : op - -/** macOS advertises and validates its additional option for element and coordinate moves. */ -export const MacRequest = z.discriminatedUnion('method', [macOperation(first), ...rest.map(macOperation)]) - -/** - * Select the native route on the provider's parsed request, before coordinates are rechecked and dispatched. - * @param ctx Provider context owning this set of Session decisions. - * @param requireApproval Whether physical moves need the user's approval. - * @returns Preparation for mouseMove with an optional user interaction. - */ -export function mouseMovePreparation(ctx: Context, requireApproval: boolean): NonNullable { - const permissions = new CuaSessionPermissions(ctx) - const permission = 'system-pointer' - return (agent, params) => { - if (params.useSystemPointer !== true) { - params.useSystemPointer = false - return undefined - } - if (agent.session.header.origin === 'subagent') { - throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") - } - if (!requireApproval) return undefined - params.useSystemPointer = false - return async (signal) => { - try { - if (!await permissions.request(agent, permission, mouseControlPrompt, signal)) { - throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.') - } - } catch (error) { - if (error instanceof UserQuestionError) { - if (error.code === 'APPROVAL_PENDING') throw new NativeError('MOUSE_CONTROL_PENDING', error.message, { cause: error }) - if (error.code === 'NO_PROVIDER') throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', error.message, { cause: error }) - if (error.code === 'ASK_CANCELLED') throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) - } - throw error - } - params.useSystemPointer = true - } - } -} diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index 7f0cb9ea86..54d384a688 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -34,12 +34,12 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(pressKey).toBe(COMMON_GUIDANCE.split('\n').find(line => line.startsWith('- pressKey('))) expect(GUIDANCE).toContain('W down, D down, D up, W up') const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove(')) - for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'The question interrupts the user']) expect(hover).toContain(term) + for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE']) expect(hover).toContain(term) const click = GUIDANCE.split('\n').find(line => line.startsWith('- click(')) expect(click).toContain('await app.getWindow(windowId), then call win.click(target)') expect(click).toContain('use app.invoke(id)') for (const step of ['call app.activate() first', 'outside the window', 'screenshot pixels', 'before typing or retrying']) expect(click).toContain(step) - for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'The question interrupts the user', 'mouse-down']) { + for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'mouse-down']) { expect(COMMON_GUIDANCE).not.toContain(platformDetail) } }) @@ -72,7 +72,7 @@ it('inlines discovery, restoration, screenshot, and mouse-move return records', for (const name of ['cua.getCapabilities', 'cua.listApps', 'cua.findApps', 'cua.getState', 'app.getWindows', 'app.show', 'window.show', 'getScreenshot', 'mouseMove']) { expect(lines.find(line => line.startsWith(`- ${name}(`))).toMatch(/ -> \(?\{ /) } - expect(COMMON_GUIDANCE).toContain("{ platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false }") + expect(COMMON_GUIDANCE).toContain("{ platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false }") expect(COMMON_GUIDANCE).toContain('{ id: string, displayName: string, isRunning: true, pid: number }[]') expect(COMMON_GUIDANCE).toContain('({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[]') expect(COMMON_GUIDANCE).toContain('bounds: { x: number, y: number, width: number, height: number }, isOnScreen: boolean, onCurrentSpace: boolean | null') diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index 76d3bbff12..c83c16e77e 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -3,8 +3,7 @@ import { readFile } from 'node:fs/promises' import { Ajv2020 } from 'ajv/dist/2020.js' import { describe, expect, it } from 'vitest' import { renderNativeProtocol } from '../native/generate-protocol.ts' -import { Request } from '@deepseek-ai/dsh-cua/protocol' -import { MacRequest } from '../src/mouse-control.ts' +import { Request, SystemPointerRequest } from '@deepseek-ai/dsh-cua/protocol' describe('native protocol generation', () => { it('keeps native JSON Schema and Swift admission tables current', async () => { @@ -29,13 +28,13 @@ describe('native protocol generation', () => { expect(validate({ id: 'shortcut', ...request })).toBe(false) }) - it('admits the pointer option only in the macOS model schema and only as a boolean', () => { + it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => { const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 } const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } } expect(Request.safeParse(request).success).toBe(false) - expect(MacRequest.safeParse(request).success).toBe(true) - expect(MacRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) - expect(MacRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) + expect(SystemPointerRequest.safeParse(request).success).toBe(true) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) }) it.each([ diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 33f87e5866..343bbbdb33 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -17,8 +17,8 @@ import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import Hmr from '@deepseek-ai/dsh-hmr' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' +import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' import { CuaConfig, installCua } from '@deepseek-ai/dsh-cua' -import { CuaSessionPermissions } from '@deepseek-ai/dsh-cua/session-permissions' import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import FileSystem from '@deepseek-ai/dsh-fs-local' @@ -289,7 +289,7 @@ it('merges startup capabilities into runtime native fields', async () => { native.request.mockResolvedValueOnce(runtime) const result = await f.execute('getCapabilities') expect(result.isError).toBe(false) - expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false, + expect(result.value).toEqual({ ...runtime, platform: 'macos', systemPointer: true, appLevelAccessibility: true, keyboard: ['app'], browser: false, coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', @@ -982,7 +982,7 @@ async function hoverFixture(requireApproval = true) { await f.ctx.plugin(NativeProvider, { ...config, requireSystemPointerApproval: false }) } const questions = await f.ctx.plugin(UserQuestions) - const questionId = 'cua-session-permission:system-pointer' + const questionId = 'cua-mouse-control' const approved = { answers: [{ id: questionId, selected: ['Allow for this session'] }] } const ask = vi.fn(async () => approved) f.ctx.on('user-questions/request', async ({ questions }) => questions[0]!.id === questionId ? ask() @@ -1032,7 +1032,7 @@ it('keeps refusals and grants separate and forgets a closed session on reopen', const { agent } = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('APP_ACCESS_REQUIRED') await f.select(agent) - f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-session-permission:system-pointer', selected: ['Deny'] }] }) + f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Deny'] }] }) native.request.mockClear() for (let i = 0; i < 2; i++) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_DENIED') expect(native.request).not.toHaveBeenCalled() @@ -1120,39 +1120,35 @@ it.each([true, false])('rejects a delegated pointer move before asking or dispat expect(native.request).not.toHaveBeenCalled() }) -it('keeps provider-specific pointer flags and questions off providers without mouse control', async () => { +it.each([true, false])('uses the declared systemPointer capability for a non-macOS provider (%s)', async (systemPointer) => { const f = await setup() await f.provider.dispose() await f.ctx.plugin({ name: 'fixture-window-provider', inject: NativeProvider.inject, apply(ctx: Context) { installCua(ctx, CuaConfig(config), { - helperPath: config.helperPath, createChannel: native.connect, - guidance: '', permissionParameters: () => ({}), - capabilities: { platform: 'fixture', methods: ['getApp', 'mouseMove'], appLevelAccessibility: true, - keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, + helperPath: config.helperPath, createChannel: native.connect, guidance: COMMON_GUIDANCE, + permissionParameters: () => ({}), + capabilities: { platform: 'fixture', methods: ['getApp', 'getCapabilities', 'mouseMove'], systemPointer, + appLevelAccessibility: true, keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, }) } }) + native.request.mockResolvedValueOnce({ systemPointer: !systemPointer }) + expect((await f.execute('getCapabilities')).value).toMatchObject({ platform: 'fixture', systemPointer }) + const sections = (await f.ctx.systemPrompt.assemble({ scope: f.handle.agent })).sections + const guidance = sections.find(section => section.name === 'computer-use:dsh-cua')!.text + expect(guidance.includes('useSystemPointer')).toBe(systemPointer) + expect(guidance.includes('The question interrupts the user')).toBe(systemPointer) + expect(JSON.stringify(f.ctx.tools.get('cua', f.handle.agent)!.parameters).includes('useSystemPointer')).toBe(systemPointer) await selectApplication(f) - expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('useSystemPointer') - const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } - expect((await f.execute('mouseMove', target)).isError).toBe(false) - expect(native.request).toHaveBeenLastCalledWith('mouseMove', target, expect.any(AbortSignal), expect.any(String)) -}) - -it('separates a provider’s permission decisions and presents its question for each capability', async () => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const permissions = new CuaSessionPermissions(f.ctx) - const signal = new AbortController().signal - const ask = vi.spyOn(f.ctx.userQuestions, 'ask').mockImplementation(async ({ questions }) => { - const q = questions[0]! - return { answers: [{ id: q.id, selected: [q.question === 'Read status?' ? 'Allow for this session' : 'Deny'] }] } + native.request.mockClear() + const answer = vi.spyOn(f.ctx.userQuestions, 'ask').mockResolvedValue({ + answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }], }) - const read = { question: { en: 'Read status?' }, detail: { en: 'Only for this session' } } - const write = { question: { en: 'Change status?' }, detail: { en: 'Sets the current status' } } - const agent = f.handle.agent - await expect(permissions.request(agent, 'read-status', read, signal)).resolves.toBe(true) - await expect(permissions.request(agent, 'write-status', write, signal)).resolves.toBe(false) - await expect(permissions.request(agent, 'write-status', write, signal)).resolves.toBe(false) - await expect(permissions.request(agent, 'read-status', read, signal)).resolves.toBe(true) - expect(ask.mock.calls.map(([request]) => request.questions[0]!.detail)).toEqual([read.detail.en, write.detail.en]) + const physical = await f.execute('mouseMove', physicalHover) + expect(physical.isError).toBe(!systemPointer) + expect(answer).toHaveBeenCalledTimes(systemPointer ? 1 : 0) + if (systemPointer) expect(native.request).toHaveBeenLastCalledWith('mouseMove', physicalHover, expect.any(AbortSignal), expect.any(String)) + else expect(native.request).not.toHaveBeenCalled() + expect((await f.execute('mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', systemPointer ? { ...hoverTarget, useSystemPointer: false } : hoverTarget, + expect.any(AbortSignal), expect.any(String)) }) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 851909295c..40d8ac79c6 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -21,8 +21,8 @@ en: d92d5948a7941e8d zh: d43491ae6521b87e /deepseek-ai-dsh-cua/understand-the-implementation: - en: edb0b08ad9ee9a73 - zh: da868fc1061516e4 + en: f7d47911abb5e2d8 + zh: 227645c9df979455 /deepseek-ai-dsh-cua/further-exploration: en: c174fb5a00ea3681 zh: 039340042676881d diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 5eea482387..1ee0a555d1 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -68,7 +68,7 @@ The Host marker has no configurable fields; platform providers own native config ## Understand the implementation -Import `CuaSessionPermissions` from `@deepseek-ai/dsh-cua/session-permissions`. Its `request(agent, permission, prompt, signal)` method asks for one named permission using the provider’s localized question and detail. A live Session caches each permission separately. Providers declare additional tool fields with their request schema and can prepare a move before dispatch. +Providers declare `systemPointer: true` to support explicit physical pointer movement. CUA exposes the option in the tool schema and model instructions only for those providers. CUA owns the approval card, live-session decision and delegated-task restriction. A provider receives the per-call pointer mode after CUA checks application access and any required consent. Permission responses identify their platform and report only the state that platform can observe. The shared page renders the controls those observations support. Before platform identification, it shows neutral observation and input rows without system actions. Native providers use the shared build lock and compiler cleanup. Standalone executables are retained by digest. The macOS provider reads its signed application receipt after compilation completes under the same lock. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index 2db56fd1b2..b8592b36eb 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -68,7 +68,7 @@ Computer Use 详情页的权限卡片、组件列表和来源信息使用 l3 边 ## 理解实现 -提供方从 `@deepseek-ai/dsh-cua/session-permissions` 导入 `CuaSessionPermissions`,通过 `request(agent, permission, prompt, signal)` 申请指定权限,并传入本地化的问题和说明。每个会话分别记录各项权限的决定。提供方通过请求 schema 声明额外字段,可在派发前处理悬停请求。 +提供方通过 `systemPointer: true` 声明支持真实鼠标移动。CUA 据此在工具 schema 和模型提示词中加入该选项,负责显示授权卡、记录当前会话的决定并限制子代理。CUA 检查应用访问权限和本次移动所需的授权后,将悬停方式传给提供方。 权限响应标识所属平台,并且只报告该平台能够观察到的状态。共享页面显示这些观察结果支持的控件。平台尚未确认时显示通用观察和输入项,不提供系统操作。原生提供方共用编译锁和编译进程清理逻辑。独立可执行文件按内容摘要保存。macOS 提供方在锁内等待编译完成,再读取签名应用的构建回执。 diff --git a/packages/experimental/cua/package.json b/packages/experimental/cua/package.json index 5d5c6bc0fb..440f321935 100644 --- a/packages/experimental/cua/package.json +++ b/packages/experimental/cua/package.json @@ -53,10 +53,6 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./session-permissions": { - "types": "./lib/types/session-permissions.d.ts", - "default": "./lib/session-permissions.js" - }, "./config": { "types": "./lib/types/config.d.ts", "default": "./lib/config.js" @@ -82,7 +78,6 @@ "lib/api.js", "lib/bridge.js", "lib/native-client.js", - "lib/session-permissions.js", "lib/protocol.js", "lib/config.js", "lib/native-schema.js", diff --git a/packages/experimental/cua/src/client/ApplicationApproval.tsx b/packages/experimental/cua/src/client/ApplicationApproval.tsx index c55f3ead50..af577d3c2a 100644 --- a/packages/experimental/cua/src/client/ApplicationApproval.tsx +++ b/packages/experimental/cua/src/client/ApplicationApproval.tsx @@ -5,14 +5,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' import type {} from '@deepseek-ai/dsh-client-ui-approval/client' /** - * Select application access and provider Session permission questions; other interactions keep their renderer. + * Select application access and mouse-control questions; other interactions keep their renderer. * @param pending The session's currently elected interaction. * @returns Its Computer Use decision, or null for another interaction. */ export function selectApplicationApproval(pending: ComposerChainProps['pendingInteraction']) { if (pending?.kind !== 'question' || pending.questions.length !== 1) return null const item = pending.questions[0] as (typeof pending.questions)[number] - if ((item.id !== 'cua-app-access' && !item.id.startsWith('cua-session-permission:')) || item.intent?.kind !== 'approval') return null + if ((item.id !== 'cua-app-access' && item.id !== 'cua-mouse-control') || item.intent?.kind !== 'approval') return null return { pending, item, intent: item.intent } } diff --git a/packages/experimental/cua/src/config.ts b/packages/experimental/cua/src/config.ts index a570d4a93b..4e60c1e363 100644 --- a/packages/experimental/cua/src/config.ts +++ b/packages/experimental/cua/src/config.ts @@ -3,6 +3,7 @@ import Schema from '@deepseek-ai/schemastery' /** Shared configuration fields extended by native platform providers. */ export const CuaConfigFields = { + requireSystemPointerApproval: Schema.boolean().default(true), agentPreset: Schema.union([Schema.string().min(1), Schema.const(false)]).default('ptc'), allowedApps: Schema.array(Schema.object({ id: Schema.string().pattern(/^[a-f0-9]{64}$/).required(), diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index d8d7d46660..5e3f5cfc95 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -1,4 +1,6 @@ /** Shared model instructions for native application and window operations. */ +const hoverSignature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean })' + /** Async method catalog with consent and observation rules, grouped by observation, mouse, and keyboard. */ export const COMMON_GUIDANCE = `Use cua to observe and operate native applications. Always use cua.getCapabilities() to confirm whether a specific API is available on the current platform; its reported capabilities take precedence over availability statements in these instructions. All methods are async; observation options default to { emit: true }, and emit: false suppresses sink output only, leaving returned values unchanged. Actions report dispatch evidence, not verified completion. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI actions and ask the user to unlock the desktop session; null or unknown means verify a usable unlocked desktop session first. cua.getApp requests user consent; if access is denied or revoked, stop using that application until the user explicitly permits another request, and never bypass a refusal or switch tools to avoid it. Never replay uncertain input without a fresh observation. @@ -7,7 +9,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -32,7 +34,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. +- ${hoverSignature} -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -46,3 +48,15 @@ Available at app or window level depending on the platform. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.` + +/** + * Describe the system-pointer option for a provider that declares the capability. + * @param guidance Provider instructions including the shared operation catalog. + * @returns The capability-specific signature and permission instructions. + */ +export function withSystemPointerGuidance(guidance: string): string { + const signature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean })' + return `${guidance.replace(hoverSignature, signature)} + +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.` +} diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 731ae38157..43ca13308e 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -13,11 +13,13 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { z } from 'zod' import { NativeClient, NativeError, type NativeClientConfig, type NativeChannelFactory } from './native-client.ts' +import { MouseControl } from './mouse-control.ts' +import { withSystemPointerGuidance } from './guidance.ts' import { CuaPermissionStore } from './permission-store.ts' import { ApplicationAuthorizationResult, installCuaAccess } from './access.ts' import type { CuaApplicationGrant, CuaApplicationId, CuaSystemPermission } from './access-types.ts' import type { CuaAppHandle, CuaOperation, CuaStartupCapabilities } from './types.ts' -import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' +import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, SystemPointerRequest, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' import { CuaConfigFields } from './config.ts' import { CuaCoordinates } from './coordinates.ts' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' @@ -34,6 +36,8 @@ export type { CuaStartupCapabilities } from './types.ts' /** Native executable and per-operation resource bounds. */ export interface CuaConfig { + /** Ask once per live Session before an explicitly requested system-pointer move. */ + requireSystemPointerApproval: boolean /** Register tools for Agents using this preset; defaults to ptc. False enables global registration. */ agentPreset: string | false /** Legacy grants imported once into profile-scoped storage; later edits are ignored. */ @@ -126,10 +130,6 @@ export interface CuaBackend { createChannel: NativeChannelFactory guidance: string capabilities: CuaStartupCapabilities - /** Model request schema including this provider's operation options. Defaults to the shared schema. */ - requestSchema?: z.ZodType - /** Resolve provider options on the parsed copy. An optional interactive step runs before authorized dispatch. */ - prepareMouseMove?: (agent: Agent, params: Record) => ((signal: AbortSignal) => Promise) | undefined /** Dispatch a directional key through the provider's execution-scoped input owner, after application authorization. */ sendKeyEvent?: (client: NativeClient, params: Record, execution: Readonly) => Promise @@ -151,7 +151,11 @@ export interface CuaBackend { */ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend): void { const { helperPath } = backend - const guidance = `${backend.guidance}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.` + const systemPointer = backend.capabilities.systemPointer === true + const mouseControl = systemPointer ? new MouseControl(ctx, config.requireSystemPointerApproval) : undefined + const requestSchema = systemPointer ? SystemPointerRequest : Request + const instructions = systemPointer ? withSystemPointerGuidance(backend.guidance) : backend.guidance + const guidance = `${instructions}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.` const native: NativeClientConfig = { helperPath, cwd: process.cwd(), timeoutMs: config.timeoutMs, axTimeoutMs: config.axTimeoutMs, connectTimeoutMs: config.connectTimeoutMs, screenshotTimeoutMs: config.screenshotTimeoutMs, @@ -163,7 +167,6 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) - const requestSchema = backend.requestSchema ?? Request const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -212,13 +215,14 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) for (const result of [...await permissions, ...results, ...releases]) if (result.status === 'rejected') errors.push(result.reason) if (errors.length > 0) throw new AggregateError(errors, 'dsh-cua helper cleanup failed') } - const callNative = async (request: CuaOperation, execution: Readonly) => { + const callNative = async (request: CuaOperation, execution: Readonly, useSystemPointer?: boolean) => { const { method, params: parsedParams } = request const params = execution.coordinates.parameters(request) const agent = execution.agent const signal = execution.signal signal.throwIfAborted() const client = execution.nativeClient + if (useSystemPointer !== undefined) params.useSystemPointer = useSystemPointer if (method === 'sendKeyEvent' && backend.sendKeyEvent) return backend.sendKeyEvent(client, params, execution) const nativeParams = method === 'findApps' ? { ...params, maxAppMatches: config.maxAppMatches } @@ -282,7 +286,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const capabilities = backend.capabilities return { ...runtime, - platform: capabilities.platform, + platform: capabilities.platform, systemPointer, methods: [...capabilities.methods], appLevelAccessibility: capabilities.appLevelAccessibility, keyboard: [...capabilities.keyboard], @@ -340,16 +344,18 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) owner.coordinates = new CuaCoordinates() } const nativeClient = client - const prepare = request.method === 'mouseMove' ? backend.prepareMouseMove?.(agent, request.params) : undefined - if (prepare) owner.coordinates.parameters(request) + const route = request.method === 'mouseMove' + ? mouseControl?.resolve(agent, 'useSystemPointer' in request.params && request.params.useSystemPointer === true) + : undefined + if (route?.prepare) owner.coordinates.parameters(request) const dispatch = async (signal: AbortSignal) => { const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates } - const result = await callNative(request, scoped) + const result = await callNative(request, scoped, route?.useSystemPointer) signal.throwIfAborted() return result } return 'appHandle' in request.params - ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, prepare) + ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, route?.prepare) : dispatch(signal) })() pending.set(operation, agent) diff --git a/packages/experimental/cua-macos/src/mouse-control-locales.ts b/packages/experimental/cua/src/mouse-control-locales.ts similarity index 100% rename from packages/experimental/cua-macos/src/mouse-control-locales.ts rename to packages/experimental/cua/src/mouse-control-locales.ts diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts new file mode 100644 index 0000000000..866defca76 --- /dev/null +++ b/packages/experimental/cua/src/mouse-control.ts @@ -0,0 +1,63 @@ +/** Session approval for explicit physical pointer moves, shared by capable CUA providers. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' +import { NativeError } from './native-client.ts' +import { applicationAccessDisplay } from './locales.ts' +import { mouseControlPrompt } from './mouse-control-locales.ts' + +/** Access is scoped to the live Session and never grants application access. */ +export class MouseControl { + private readonly decisions = new WeakMap() + + constructor(private readonly ctx: Context, private readonly requireApproval: boolean) {} + + /** + * Select the per-call route and any approval required before dispatch. + * @param agent Agent owning the live Session. + * @param requested Whether the caller explicitly requested system-pointer movement. + * @returns Native pointer mode and an optional preparation that must succeed before input. + */ + resolve(agent: Agent, requested: boolean): { useSystemPointer: boolean; prepare?: (signal: AbortSignal) => Promise } { + if (!requested) return { useSystemPointer: false } + if (agent.session.header.origin === 'subagent') { + throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") + } + if (!this.requireApproval || this.decisions.get(agent) === true) return { useSystemPointer: true } + return { useSystemPointer: true, prepare: signal => this.approve(agent, signal) } + } + + private async approve(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + const decision = this.decisions.get(agent) + if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'Wait for the outstanding mouse control request') + let granted = decision + if (granted === undefined) { + const questions = this.ctx.get('userQuestions') + if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') + const id = 'cua-mouse-control' + const display = { ...mouseControlPrompt, approve: applicationAccessDisplay.approve } + const approve = display.approve.en + const reject = 'Deny' + this.decisions.set(agent, 'asking') + try { + const result = await questions.ask({ agent, signal, questions: [{ + id, question: display.question.en, detail: display.detail.en, options: [{ label: approve }, { label: reject }], + intent: { kind: 'approval', approve, reject, display }, + }] }) + signal.throwIfAborted() + const [answer] = result.answers + granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() + && answer.selected.length === 1 && answer.selected[0] === approve + this.decisions.set(agent, granted) + } catch (error) { + this.decisions.delete(agent) + if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { + throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) + } + throw error + } + } + if (!granted) throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.') + } +} diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 1fd9d6cd41..7684bb4aca 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -39,48 +39,57 @@ export const MouseMoveTargets = [ z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(), ] as const -/** Model-submittable operation parameters; Host resource fields and private continuations are separate. */ -export const Request = z.discriminatedUnion('method', [ - operation('getCapabilities', {}), operation('listApps', {}), - operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), - operation('getApp', { selector: z.string().min(1) }), - operation('activate', app), - operation('show', { ...app, windowId: id.optional() }), - operation('listWindows', app), - operation('getState', { ...app, windowId: id.optional(), - includeMenus: z.boolean().optional().describe('Expand menu contents; omitted or false shows menu-bar entries only.'), - details: z.boolean().optional().describe('Include identifiers, global native bounds and complete action names.'), - }), - operation('listActions', snapshot), - operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }), - operation('invoke', snapshot), - z.object({ method: z.literal('mouseMove'), params: z.union(MouseMoveTargets) }).strict(), - z.object({ method: z.literal('click'), params: z.union([ - z.object({ ...snapshot, ...click }).strict(), - z.object({ ...absoluteCoordinate, ...click }).strict(), - z.object({ ...relativeCoordinate, ...click }).strict(), - ]) }).strict(), - operation('setValue', { ...snapshot, value: jsonValue }), - operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(), - selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), - operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), - operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), - operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }), - operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), - z.object({ method: z.literal('scroll'), params: z.union([ - z.object({ ...absoluteCoordinate, - deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), - z.object({ ...relativeCoordinate, - deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), - ]) }).strict(), - z.object({ method: z.literal('drag'), params: z.union([ - z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: z.literal(false).optional(), - fromX: point, fromY: point, toX: point, toY: point }).strict(), - z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: relativeCoordinate.relative, - fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), - ]) }).strict(), - operation('screenshot', window), -]) +function requestSchema(hover: T) { + return z.discriminatedUnion('method', [ + operation('getCapabilities', {}), operation('listApps', {}), + operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), + operation('getApp', { selector: z.string().min(1) }), + operation('activate', app), + operation('show', { ...app, windowId: id.optional() }), + operation('listWindows', app), + operation('getState', { ...app, windowId: id.optional(), + includeMenus: z.boolean().optional().describe('Expand menu contents; omitted or false shows menu-bar entries only.'), + details: z.boolean().optional().describe('Include identifiers, global native bounds and complete action names.'), + }), + operation('listActions', snapshot), + operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }), + operation('invoke', snapshot), + z.object({ method: z.literal('mouseMove'), params: hover }).strict(), + z.object({ method: z.literal('click'), params: z.union([ + z.object({ ...snapshot, ...click }).strict(), + z.object({ ...absoluteCoordinate, ...click }).strict(), + z.object({ ...relativeCoordinate, ...click }).strict(), + ]) }).strict(), + operation('setValue', { ...snapshot, value: jsonValue }), + operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(), + selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), + operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), + operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), + operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }), + operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), + z.object({ method: z.literal('scroll'), params: z.union([ + z.object({ ...absoluteCoordinate, + deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), + z.object({ ...relativeCoordinate, + deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), + ]) }).strict(), + z.object({ method: z.literal('drag'), params: z.union([ + z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: z.literal(false).optional(), + fromX: point, fromY: point, toX: point, toY: point }).strict(), + z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: relativeCoordinate.relative, + fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), + ]) }).strict(), + operation('screenshot', window), + ]) +} + +/** Portable model operations, including auxiliary hover without a system-pointer option. */ +export const Request = requestSchema(z.union(MouseMoveTargets)) + +/** Model operations for providers that can move the user's system pointer. */ +export const SystemPointerRequest = requestSchema(z.union(MouseMoveTargets.map(target => target.extend({ + useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.'), +})))) /** Resource parameters supplied by the Host, never accepted from model tool arguments. */ export const NativeResourceParameters = { @@ -139,6 +148,8 @@ export const CapabilitiesResult = z.object({ platform: z.string(), permissions: z.object({ accessibility: z.boolean(), screenRecording: z.boolean() }), /** Whether the provider supports application-level accessibility observations. */ appLevelAccessibility: z.boolean(), + /** Whether mouseMove accepts an explicit request to move the system pointer. */ + systemPointer: z.boolean().default(false), /** Scopes that support keyboard input. */ keyboard: z.array(z.enum(['app', 'window'])), sessionLocked: z.boolean().nullable(), diff --git a/packages/experimental/cua/src/session-permissions.ts b/packages/experimental/cua/src/session-permissions.ts deleted file mode 100644 index f21a61b685..0000000000 --- a/packages/experimental/cua/src/session-permissions.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Provider-owned permissions remembered separately for each live Session and requested capability. */ -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import { applicationAccessDisplay } from './locales.ts' - -type Localized = { en: string; [locale: string]: string } - -/** A provider owns the lifetime and names of the permissions it requests. */ -export class CuaSessionPermissions { - private readonly decisions = new WeakMap>() - - constructor(private readonly ctx: Context) {} - - /** - * Remember the user's choice only for this permission in this live Session. - * @param agent Live Session owner. - * @param permission Provider-defined permission name shared by calls requesting the same access. - * @param prompt Localized question and scope displayed before the user decides. - * @param signal Calling operation lifetime. Aborting or dismissing the question leaves the permission undecided. - * @returns True only when the user selects session approval. - */ - async request(agent: Agent, permission: string, prompt: { question: Localized; detail: Localized }, - signal: AbortSignal): Promise { - signal.throwIfAborted() - const decisions = this.decisions.get(agent) ?? new Map() - const decision = decisions.get(permission) - if (decision === 'asking') throw new UserQuestionError('Wait for the outstanding permission request', 'APPROVAL_PENDING') - if (decision !== undefined) return decision - const questions = this.ctx.get('userQuestions') - if (!questions) throw new UserQuestionError('No human answerer is available for the permission request', 'NO_PROVIDER') - this.decisions.set(agent, decisions) - decisions.set(permission, 'asking') - const id = `cua-session-permission:${permission}` - const display = { ...prompt, approve: applicationAccessDisplay.approve } - const approve = display.approve.en - const reject = 'Deny' - try { - const result = await questions.ask({ agent, signal, questions: [{ - id, question: prompt.question.en, detail: prompt.detail.en, options: [{ label: approve }, { label: reject }], - intent: { kind: 'approval', approve, reject, display }, - }] }) - signal.throwIfAborted() - const [answer] = result.answers - const granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() - && answer.selected.length === 1 && answer.selected[0] === approve - decisions.set(permission, granted) - return granted - } catch (error) { - decisions.delete(permission) - throw error - } - } -} diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index b84663f9e2..98c7c7a1a2 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -2,7 +2,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { z } from 'zod' -import type { ActionResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, WindowsResult } from './protocol.ts' +import type { ActionResult, CapabilitiesResult, FoundAppsResult, HoverResult, SystemPointerRequest, ScreenshotResult, ShowResult, WindowsResult } from './protocol.ts' /** Helper-issued application selection; public app objects hold only this opaque handle. */ export type CuaAppHandle = Branded<'CuaAppHandle'> @@ -11,7 +11,7 @@ export type CuaSnapshotId = Branded<'CuaSnapshotId'> /** Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot. */ export type CuaElementId = Branded<'CuaElementId'> /** One operation sent through an authorized and logged host dispatcher. */ -export type CuaOperation = z.output +export type CuaOperation = z.output type CuaParameters = Extract['params'] /** Dispatch exactly once; a failed action must not be retried implicitly. */ export type CuaDispatch = (operation: CuaOperation) => Promise @@ -49,7 +49,7 @@ export type CuaCapabilities = z.output export type CuaStartupCapabilities = Pick< CuaCapabilities, 'platform' | 'methods' | 'appLevelAccessibility' | 'keyboard' | 'browser' | 'coordinateSpace' -> +> & { systemPointer?: boolean } /** App discovery result accepted by getApp. */ export type CuaAppInfo = Pick, 'id' | 'displayName' | 'isRunning' | 'pid'> /** An installed application match; running matches include a process identifier. */ @@ -196,10 +196,12 @@ export interface CuaWindow extends CuaSurface { * Returning and subsequent observations do not restore the previous pointer position. * Other input and application changes can interrupt the interaction; observe before continuing. * @param target - current element id or screenshot-local [x,y]. - * @param options - coordinate mode; applies only to [x,y]. + * @param options - Coordinate mode for points. Only providers reporting systemPointer accept useSystemPointer. + * True requests physical movement for this call only. * @returns dispatch evidence and the selected route; observe the resulting UI after any UI delay. */ - mouseMove(target: CuaElementId | readonly [number, number], options?: CuaCoordinateOptions): Promise + mouseMove(target: CuaElementId | readonly [number, number], + options?: CuaCoordinateOptions & { useSystemPointer?: boolean }): Promise /** * Click a current element id or screenshot-local point. * Cancellation or timeout releases the held button; received input cannot be undone. The operation deadline includes holdMs. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index 73dbf044cc..9b9a58c64c 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -13,7 +13,7 @@ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG const bounds = { x: -640, y: 100, width: 640, height: 480 } const selected = { appHandle: 'app-17', pid: 17, name: 'Editor', bundleId: 'test.editor', running: true } const delivered = { dispatched: true } -const capabilities: CuaCapabilities = { platform: 'macos', sessionLocked: false, permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility: true, keyboard: ['app'], methods: ['getState', 'click'], coordinateSpace: 'screenshot-pixels', browser: false } +const capabilities: CuaCapabilities = { platform: 'macos', systemPointer: true, sessionLocked: false, permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility: true, keyboard: ['app'], methods: ['getState', 'click'], coordinateSpace: 'screenshot-pixels', browser: false } function windowInfo(windowId: number) { return { windowId, appName: 'Editor', title: `Document ${windowId}`, bounds, isOnScreen: true, onCurrentSpace: null } diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx index 48f4cadafd..13e79b8faa 100644 --- a/packages/experimental/cua/tests/application-approval.client.spec.tsx +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -91,7 +91,7 @@ it('shows the mouse interruption and expiry with only session approval and refus detail: { en: 'Until restart', zh: '重启后失效' }, approve: { en: 'Allow for this session', zh: '本会话允许' }, } - const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-session-permission:example', question: display.question.en, + const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en, detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }]) const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) render( brandString(id) const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' const capabilities: CuaCapabilities = { - platform: 'macos', permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: true, + platform: 'macos', systemPointer: true, permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: true, keyboard: ['app'], sessionLocked: false, methods: ['getCapabilities', 'getApp', 'getState', 'screenshot'], coordinateSpace: 'screenshot-pixels', browser: false, } diff --git a/packages/experimental/cua/tests/fixtures/exports.mjs b/packages/experimental/cua/tests/fixtures/exports.mjs index 91b55b76b8..19dbd3973e 100644 --- a/packages/experimental/cua/tests/fixtures/exports.mjs +++ b/packages/experimental/cua/tests/fixtures/exports.mjs @@ -12,8 +12,6 @@ const guard = registerHooks({ }) const { Context } = await import('@deepseek-ai/cordis') const host = await import('@deepseek-ai/dsh-cua') -const { CuaSessionPermissions } = await import('@deepseek-ai/dsh-cua/session-permissions') -assert.equal(typeof CuaSessionPermissions.prototype.request, 'function') const { createCuaApi } = await import('@deepseek-ai/dsh-cua/api') const { CuaConfigFields } = await import('@deepseek-ai/dsh-cua/config') assert.equal(typeof CuaConfigFields.connectTimeoutMs.default, 'function') diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index 50fb329c07..1842ac1f78 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -179,7 +179,7 @@ it.each([true, false])('requires the reported application-level accessibility ca platform: 'macos', permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility, keyboard: ['app'], sessionLocked: false, methods: [], coordinateSpace: 'screenshot-pixels', browser: false, } - expect(CapabilitiesResult.parse(capabilities)).toEqual(capabilities) + expect(CapabilitiesResult.parse(capabilities)).toEqual({ ...capabilities, systemPointer: false }) const { appLevelAccessibility: _omitted, ...missing } = capabilities expect(CapabilitiesResult.safeParse(missing).success).toBe(false) }) @@ -195,7 +195,7 @@ it.each([ platform: 'windows', permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: false, keyboard, sessionLocked: false, methods: [], coordinateSpace: 'screenshot-pixels', browser: false, } - expect(CapabilitiesResult.parse(capabilities)).toEqual(capabilities) + expect(CapabilitiesResult.parse(capabilities)).toEqual({ ...capabilities, systemPointer: false }) }) it.each([ diff --git a/packages/experimental/cua/tsdown.config.ts b/packages/experimental/cua/tsdown.config.ts index 92c9eca9a8..29526fddd6 100644 --- a/packages/experimental/cua/tsdown.config.ts +++ b/packages/experimental/cua/tsdown.config.ts @@ -3,7 +3,6 @@ import { clientBundle } from '../../client/tsdown.client.ts' const runtime: UserConfig[] = Object.entries({ 'remote-types': 'lib/types/remote-types.js', api: 'lib/types/api.js', bridge: 'lib/types/bridge.js', - 'session-permissions': 'lib/types/session-permissions.js', 'native-client': 'lib/types/native-client.js', protocol: 'lib/types/protocol.js', config: 'lib/types/config.js', 'native-schema': 'lib/types/native-schema.js', 'native-build': 'lib/types/native-build.js', guidance: 'lib/types/guidance.js', }).map(([name, path]) => ({ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 8e75b08fbd..f759eca7ec 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -188,7 +188,7 @@ const packageFileExtras: Readonly> = { // The embedded OAR implementation retains its upstream license. '@deepseek-ai/dsh-experimental-subagent-oar': ['LICENSE-OAR'], // CUA publishes its client API and attributed native source for local macOS builds. - '@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/session-permissions.js', 'lib/protocol.js', 'lib/config.js', 'lib/native-schema.js', 'lib/native-build.js', 'lib/guidance.js'], + '@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js', 'lib/config.js', 'lib/native-schema.js', 'lib/native-build.js', 'lib/guidance.js'], '@deepseek-ai/dsh-experimental-cua-macos': ['native/bin', 'cua-helper.json', 'native/Package.swift', 'native/Info.plist', 'native/DSHComputerUse.icns', 'native/build_app.py', 'native/Sources', 'native/tests/swift', 'native/tests/test_swift.py', 'native/LICENSE.cua-driver', 'native/LICENSE.computer-use-icon', 'native/upstream.json', 'native/README.md', 'native/README.zh.md', 'native/protocol.schema.json'], '@deepseek-ai/dsh-webhook-github': ['examples/github-review/cordis.yml', 'examples/github-review/github-ready-review-rule.mjs'], // Liveshare publishes its built Relay gateway entry and the shared runtime diff --git a/tsconfig.base.json b/tsconfig.base.json index 58c5ce6e40..e8ca4f8e44 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -113,7 +113,6 @@ "@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": ["./packages/experimental/computer-use-cua-driver-mcp/src/index.ts"], "@deepseek-ai/dsh-experimental-cua-macos": ["./packages/experimental/cua-macos/src/index.ts"], "@deepseek-ai/dsh-cua/config": ["./packages/experimental/cua/src/config.ts"], - "@deepseek-ai/dsh-cua/session-permissions": ["./packages/experimental/cua/src/session-permissions.ts"], "@deepseek-ai/dsh-cua/native-schema": ["./packages/experimental/cua/src/native-schema.ts"], "@deepseek-ai/dsh-cua/native-build": ["./packages/experimental/cua/src/native-build.ts"], "@deepseek-ai/dsh-cua/guidance": ["./packages/experimental/cua/src/guidance.ts"], From cbba0c66a970ee88484dd0f3603a41f28acc52c9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 18:20:34 +0800 Subject: [PATCH 128/149] test(cua): verify pointer capabilities and portable option forwarding --- packages/experimental/cua-macos/README.md | 2 +- packages/experimental/cua-macos/README.zh.md | 2 +- packages/experimental/cua-macos/src/guidance.ts | 2 +- .../cua-macos/tests/protocol-generation.spec.ts | 11 +---------- .../cua-macos/tests/provider-lifecycle.spec.ts | 2 +- packages/experimental/cua/src/guidance.ts | 2 +- packages/experimental/cua/src/protocol.ts | 2 +- packages/experimental/cua/tests/api.spec.ts | 4 ++-- packages/experimental/cua/tests/protocol.spec.ts | 11 ++++++++++- snapshots/session/cua-native/session.v4.jsonl | 2 +- .../session/cua-native/system-prompt.expected.md | 6 ++++-- snapshots/session/cua-ptc/system-prompt.expected.md | 6 ++++-- .../ptc-script-retained/system-prompt.expected.md | 4 ++-- 13 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 7b5df96219..e20c639e4c 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -228,7 +228,7 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. -Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token effect diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 8db5bee329..83177e6047 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -228,7 +228,7 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. -Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token 影响 diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 0428941edb..68243fdaab 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -10,7 +10,7 @@ const methodDetails = new Map([ ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) -/** Complete model instructions for this provider. */ +/** Native operation instructions; the shared Host adds guidance for declared capabilities. */ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index c83c16e77e..36c6dd94c9 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises' import { Ajv2020 } from 'ajv/dist/2020.js' import { describe, expect, it } from 'vitest' import { renderNativeProtocol } from '../native/generate-protocol.ts' -import { Request, SystemPointerRequest } from '@deepseek-ai/dsh-cua/protocol' +import { Request } from '@deepseek-ai/dsh-cua/protocol' describe('native protocol generation', () => { it('keeps native JSON Schema and Swift admission tables current', async () => { @@ -28,15 +28,6 @@ describe('native protocol generation', () => { expect(validate({ id: 'shortcut', ...request })).toBe(false) }) - it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => { - const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 } - const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } } - expect(Request.safeParse(request).success).toBe(false) - expect(SystemPointerRequest.safeParse(request).success).toBe(true) - expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) - expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) - }) - it.each([ { method: 'getState', params: { appHandle: 'app', maxElements: 1 } }, { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 343bbbdb33..5e7b5893f6 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -1135,7 +1135,7 @@ it.each([true, false])('uses the declared systemPointer capability for a non-mac expect((await f.execute('getCapabilities')).value).toMatchObject({ platform: 'fixture', systemPointer }) const sections = (await f.ctx.systemPrompt.assemble({ scope: f.handle.agent })).sections const guidance = sections.find(section => section.name === 'computer-use:dsh-cua')!.text - expect(guidance.includes('useSystemPointer')).toBe(systemPointer) + expect(guidance.includes('relative?: boolean, useSystemPointer?: boolean')).toBe(systemPointer) expect(guidance.includes('The question interrupts the user')).toBe(systemPointer) expect(JSON.stringify(f.ctx.tools.get('cua', f.handle.agent)!.parameters).includes('useSystemPointer')).toBe(systemPointer) await selectApplication(f) diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 5e3f5cfc95..49caa08bf2 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -58,5 +58,5 @@ export function withSystemPointerGuidance(guidance: string): string { const signature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean })' return `${guidance.replace(hoverSignature, signature)} -Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.` +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.` } diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 7684bb4aca..3bedc404d1 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -83,7 +83,7 @@ function requestSchema(hover: T) { ]) } -/** Portable model operations, including auxiliary hover without a system-pointer option. */ +/** Portable model operations, including standard hover without a system-pointer option. */ export const Request = requestSchema(z.union(MouseMoveTargets)) /** Model operations for providers that can move the user's system pointer. */ diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index 9b9a58c64c..f71b958368 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -901,10 +901,10 @@ it('forwards provider options while retaining the selected target and coordinate appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', elementId: '0', trackingMode: 'precise', } }) await win.getScreenshot({ emit: false }) - const pixelOptions = { relative: false, trackingMode: 'precise', x: 222 } + const pixelOptions = { relative: false, useSystemPointer: true, trackingMode: 'precise', x: 222 } await win.mouseMove([30, 40], pixelOptions) expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: { - appHandle: 'app-17', snapshotId: 'snapshot-2', windowId: 23, x: 30, y: 40, relative: false, trackingMode: 'precise', + appHandle: 'app-17', snapshotId: 'snapshot-2', windowId: 23, x: 30, y: 40, relative: false, useSystemPointer: true, trackingMode: 'precise', } }) const relativeOptions = { relative: true, trackingMode: 'coarse' } await win.mouseMove([250, 750], relativeOptions) diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index 1842ac1f78..f259df5fcd 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -1,7 +1,7 @@ /** Model-authored operation JSON cannot supply host configuration or bypass retained identities. */ import { expect, it } from 'vitest' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import { CapabilitiesResult, Request, ScreenshotResult } from '../src/protocol.ts' +import { CapabilitiesResult, Request, ScreenshotResult, SystemPointerRequest } from '../src/protocol.ts' const rejectedRequests: Array<{ reason: string; request: JsonValue }> = [ { reason: 'raw process ID instead of a selected app handle', request: { method: 'typeText', params: { pid: 17, text: 'hello' } } }, @@ -217,3 +217,12 @@ it('requires keyboard scopes', () => { } expect(CapabilitiesResult.safeParse(capabilities).success).toBe(false) }) + +it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => { + const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 } + const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } } + expect(Request.safeParse(request).success).toBe(false) + expect(SystemPointerRequest.safeParse(request).success).toBe(true) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) +}) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 7482438890..db5e234368 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889225,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889225,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"systemPointer\":true,\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889794,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889794,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index a663884957..9589c20d8e 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -42,7 +42,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -67,7 +67,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -83,4 +83,6 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. + Maximum click holdMs: 5000 ms. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 9b9d570d03..6888ad0481 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -44,7 +44,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -85,6 +85,8 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. + Maximum click holdMs: 5000 ms. ## Writing code for run_code diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index e2fb062dc6..c670f443b9 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -44,7 +44,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From d6208abfcaff5526a1c623ff4d54ed9e4bb73a59 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:21:08 +0800 Subject: [PATCH 129/149] docs(forge-github): state how review reads bound the first page and which pages re-check the head A first review thread page whose output alone passes maxOutputBytes fails the read as too-large through gh's output limit, rather than ending it as truncated as the README and the ReviewLimits documentation said; only a later page that would pass the limit truncates the read. Thread pages after the first re-check the head, while a thread's comment pages do not. A test pins the first-page failure. --- packages/experimental/forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- packages/experimental/forge-github/README.zh.md | 2 +- packages/experimental/forge-github/src/threads.ts | 10 +++++++--- .../experimental/forge-github/tests/threads.spec.ts | 8 +++++++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 6d3ca2d441..a12b42da43 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: dc1fc7eb436482c8 - zh: 968aa86136fdd840 + en: e01228c710824a6c + zh: d59f5f14f7e8150f /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: f3d0a577b0a4db0a zh: 0c15ac7979909117 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 0d5f8ea6b6..437f23b2ec 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -50,7 +50,7 @@ A pull request is `mergeReady` when it is open, not a draft, GitHub reports its Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. -`readReview` reads review threads with one GraphQL query that names `headRefOid` and the signed-in viewer, and returns `stale` when the head is not the requested commit. It reads threads in pages of up to 100 until it has read `maxReviewThreads` of them, each page after the first naming the head again, so a push during the read returns `stale`; it reads every comment of each thread, and the commits and `diffHunk` of a thread's first comment once. All pages together, measured as JSON, must fit `maxOutputBytes`: the page that would pass it is dropped with the threads after it, and the result is marked `truncated`, as when more than `maxReviewThreads` threads exist. A thread on lines gets a `current` position only when GitHub reports it not outdated, with a `line`, and its first comment's `commit` is the head: a comment written on an earlier commit after a force-push keeps a `line` that numbers that commit, and GitHub can mark a thread outdated while keeping its `line`. `original` comes from `originalLine` and `originalStartLine` at the first comment's `originalCommit`; `subjectType: FILE` threads have neither. A range whose start is on the other side of the diff keeps its last line. Threads without comments are left out. REST's `position` does not tell outdated threads apart and is not read. +`readReview` reads review threads with one GraphQL query that names `headRefOid` and the signed-in viewer, and returns `stale` when the head is not the requested commit. It reads threads in pages of up to 100 until it has read `maxReviewThreads` of them, each thread page after the first naming the head again, so a push during the read returns `stale`; it reads every comment of each thread through comment pages, which do not name the head, and the commits and `diffHunk` of a thread's first comment once. All pages together, measured as JSON, must fit `maxOutputBytes`: a later page that would pass it is dropped with the threads after it, and the result is marked `truncated`, as when more than `maxReviewThreads` threads exist; a first page whose output alone passes it fails the read as `too-large`. A thread on lines gets a `current` position only when GitHub reports it not outdated, with a `line`, and its first comment's `commit` is the head: a comment written on an earlier commit after a force-push keeps a `line` that numbers that commit, and GitHub can mark a thread outdated while keeping its `line`. `original` comes from `originalLine` and `originalStartLine` at the first comment's `originalCommit`; `subjectType: FILE` threads have neither. A range whose start is on the other side of the diff keeps its last line. Threads without comments are left out. REST's `position` does not tell outdated threads apart and is not read. ----- diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 99896c73d8..2f94f93d75 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -50,7 +50,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 -`readReview` 用一次 GraphQL 查询读取评审讨论,同时读出 `headRefOid` 和当前登录的用户;head 不是所请求的提交时返回 `stale`。讨论按每页最多 100 个分页读取,直到读满 `maxReviewThreads` 个;第一页之后的每一页都会再次读取 head,因此读取期间有新的推送时返回 `stale`。每个讨论的评论都会全部读出,讨论第一条评论的提交和 `diffHunk` 只读一次。所有页面按 JSON 计合计不得超过 `maxOutputBytes`:会超出上限的那一页及其后的讨论被丢弃,结果标记为 `truncated`,与讨论数超过 `maxReviewThreads` 时相同。行级讨论只有在 GitHub 报告它未过期、带有 `line`,且第一条评论的 `commit` 就是 head 时,才得到 `current` 位置:force-push 之后写在旧提交上的评论保留的 `line` 是那个旧提交的行号,GitHub 也可能在标记讨论过期的同时保留它的 `line`。`original` 取自第一条评论 `originalCommit` 上的 `originalLine` 和 `originalStartLine`;`subjectType: FILE` 的讨论两者都没有。起始行在 diff 另一侧的范围只保留最后一行。没有评论的讨论会被略去。REST 的 `position` 无法区分已过期的讨论,因此不读取。 +`readReview` 用一次 GraphQL 查询读取评审讨论,同时读出 `headRefOid` 和当前登录的用户;head 不是所请求的提交时返回 `stale`。讨论按每页最多 100 个分页读取,直到读满 `maxReviewThreads` 个;第一页之后的每一页讨论都会再次读取 head,因此读取期间有新的推送时返回 `stale`。每个讨论的评论都会经评论分页全部读出,评论分页不读取 head;讨论第一条评论的提交和 `diffHunk` 只读一次。所有页面按 JSON 计合计不得超过 `maxOutputBytes`:之后的某一页会超出上限时,该页及其后的讨论被丢弃,结果标记为 `truncated`,与讨论数超过 `maxReviewThreads` 时相同;第一页的输出本身超过上限时,读取以 `too-large` 失败。行级讨论只有在 GitHub 报告它未过期、带有 `line`,且第一条评论的 `commit` 就是 head 时,才得到 `current` 位置:force-push 之后写在旧提交上的评论保留的 `line` 是那个旧提交的行号,GitHub 也可能在标记讨论过期的同时保留它的 `line`。`original` 取自第一条评论 `originalCommit` 上的 `originalLine` 和 `originalStartLine`;`subjectType: FILE` 的讨论两者都没有。起始行在 diff 另一侧的范围只保留最后一行。没有评论的讨论会被略去。REST 的 `position` 无法区分已过期的讨论,因此不读取。 ----- diff --git a/packages/experimental/forge-github/src/threads.ts b/packages/experimental/forge-github/src/threads.ts index f23507e374..5717f47a0c 100644 --- a/packages/experimental/forge-github/src/threads.ts +++ b/packages/experimental/forge-github/src/threads.ts @@ -45,7 +45,10 @@ export const THREAD_COMMENTS_QUERY = `query($id: ID!, $cursor: String!) { export interface ReviewLimits { /** Most threads read. */ readonly maxReviewThreads: number - /** Most JSON bytes of all pages together; a page that would pass it ends the read as truncated. */ + /** + * Most JSON bytes of all pages together: a later page that would pass it ends the read as truncated, and a first + * page whose output alone passes it fails the read as `too-large`. + */ readonly maxOutputBytes: number } @@ -97,8 +100,9 @@ type CommentNode = z.infer /** * Read the review threads of a pull request while its head is `headSha`. The first page also names the head and the * signed-in user; every following thread page names the head again, so a head that moves during the read returns - * `stale`. Threads are read up to `maxReviewThreads`, each with all of its comments. A page that would bring the - * pages read, measured as JSON, past `maxOutputBytes` is dropped with the threads after it, and the result is marked truncated. + * `stale`, while a thread's comment pages do not. Threads are read up to `maxReviewThreads`, each with all of its + * comments. A later page that would bring the pages read, measured as JSON, past `maxOutputBytes` is dropped with the + * threads after it, and the result is marked truncated; `read` fails a first page whose output alone passes the limit. * @param read - runs one GraphQL request. * @param ref - pull request, with an owner and name already checked for inlining. * @param headSha - head commit the threads must be read at. diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts index 7962e90280..42c49f389a 100644 --- a/packages/experimental/forge-github/tests/threads.spec.ts +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GhOutput, GhRunner } from '../src/gh.ts' +import { GhOutputLimitError, type GhOutput, type GhRunner } from '../src/gh.ts' import { GitHubProvider } from '../src/provider.ts' import type { GitHubLimits } from '../src/provider.ts' import { REVIEW_QUERY, REVIEW_THREADS_QUERY, THREAD_COMMENTS_QUERY } from '../src/threads.ts' @@ -206,6 +206,12 @@ describe('GitHub provider review threads', () => { .resolves.toMatchObject({ threads: [{ id: 't1' }], truncated: true }) }) + it('fails a read whose first page alone passes maxOutputBytes as too-large', async () => { + const run: GhRunner = async () => { throw new GhOutputLimitError('gh output exceeds maxOutputBytes') } + + await expect(provider(run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'too-large', message: 'gh output exceeds maxOutputBytes' }) + }) + it.each([ ['a thread page without a cursor', firstPage({ pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] }), {}, {}, 'GitHub review thread pagination did not advance'], From fc92cdf7eff08a5a546bb22ee92a200dc72e804b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:04:44 +0800 Subject: [PATCH 130/149] fix(chat): keep multi-reasoning rows on the process-row rhythm Several reasoning blocks of one Assistant message rendered 16px apart inside the response body, while process rows of different steps sit 4px apart. A signature-only reasoning block renders no row but left its empty wrapper in the flex body, adding a full block gap. Styles only: inside a step-group reasoning part, whose body holds only reasoning rows, the block gap is 4px, and an empty reasoning wrapper cancels its gap like a hidden one. A keyless web e2e pins the rendered gaps. --- apps/web/tests/reasoning-spacing.e2e.ts | 119 ++++++++++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../client/chat/AssistantMarkdown.module.css | 22 +++- tsconfig.host.json | 1 + 7 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/reasoning-spacing.e2e.ts diff --git a/apps/web/tests/reasoning-spacing.e2e.ts b/apps/web/tests/reasoning-spacing.e2e.ts new file mode 100644 index 0000000000..aa3b3935a4 --- /dev/null +++ b/apps/web/tests/reasoning-spacing.e2e.ts @@ -0,0 +1,119 @@ +/** Reasoning rows of one Assistant message keep the 4px process-row gap, and display-empty reasoning adds none. */ +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +type Block = readonly ['reasoning' | 'signature' | 'text' | 'bash', string] + +/** Turn 1: reasoning around a signature-only block. Turn 2: signature-only blocks before and after reasoning. */ +const TURNS: readonly (readonly (readonly Block[])[])[] = [ + [[['reasoning', 'a'], ['signature', 's1'], ['reasoning', 'b'], ['bash', 'k1']], [['text', 'Answer one']]], + [ + [['reasoning', 'c'], ['bash', 'k2']], + [['signature', 's2'], ['signature', 's3'], ['reasoning', 'd'], ['bash', 'k3']], + [['reasoning', 'e'], ['signature', 's4'], ['signature', 's5'], ['bash', 'k4']], + [['text', 'Answer two']], + ], +] + +function bashArguments(label: string): string { + return JSON.stringify({ command: `echo ${label}`, description: `Run ${label}` }) +} + +function content([kind, label]: Block): Record { + switch (kind) { + case 'reasoning': return { type: 'reasoning', text: `Thinking ${label}\n\nDetails for ${label}.` } + case 'signature': return { type: 'reasoning', text: `${label}` } + case 'text': return { type: 'text', text: label } + case 'bash': return { + type: 'tool-call', id: `call_${label}`, name: 'bash', arguments: bashArguments(label), + } + } +} + +/** One closed Session whose Assistant messages carry the block layouts above. */ +function sessionLog(): string { + const header = { + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', + isSeeded: false, delegationDepth: 0, + } + const events: Record[] = [] + let message = 0 + const id = (): string => `{{message:${String(++message)}}}` + TURNS.forEach((steps, index) => { + const turn = index + 1 + events.push({ type: 'turn/start', data: { turn } }) + steps.forEach((blocks, stepIndex) => { + const step = stepIndex + 1 + events.push({ type: 'step/start', data: { turn, step } }) + if (step === 1) { + events.push({ + type: 'user/message', surfaceOp: 'append', + data: { content: [{ type: 'text', text: `Turn ${String(turn)}` }], source: { kind: 'user' }, role: 'user', id: id() }, + }) + } + events.push({ + type: 'assistant/message', surfaceOp: 'append', + data: { + turn, step, stream: [], + message: { + role: 'assistant', content: blocks.map(content), id: id(), + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }, + }, + }) + for (const [kind, label] of blocks) { + if (kind !== 'bash') continue + const callId = `call_${label}` + events.push({ type: 'tool/call', data: { turn, step, callId, name: 'bash', arguments: bashArguments(label) } }) + events.push({ + type: 'tool/result', surfaceOp: 'append', sourceEventSeqs: [events.length - 1], + data: { + turn, step, meta: { cwd: '{{cwd}}' }, + message: { + role: 'tool', source: { kind: 'tool', callId }, toolCallId: callId, + content: [{ type: 'text', text: `${label}\n` }], isError: false, id: id(), + }, + }, + }) + } + events.push({ type: 'step/end', data: { turn, step } }) + }) + events.push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + }) + return [header, ...events].map(line => JSON.stringify(line)).join('\n') +} + +it('keeps 4px between reasoning and tool rows around display-empty reasoning blocks', async () => { + const scaffold = await launchWebScaffold({}) + onTestFinished(() => scaffold.close()) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: 'verbose' }) + await seedSession(scaffold, sessionLog(), 'reasoning-spacing') + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText('Answer two', { exact: true }).waitFor() + + // Rows in reading order; signature-only reasoning renders no row of its own. + const rows = await page.evaluate(() => [...document.querySelectorAll('[data-variant="think"], [data-chat-call-id]')] + .filter(row => row.getBoundingClientRect().height > 0) + .map((row) => { + const { top, bottom } = row.getBoundingClientRect() + const name = row.matches('[data-variant="think"]') + ? /Thinking (\w+)/.exec(row.textContent ?? '')?.[1] + : row.getAttribute('data-chat-call-id')?.replace('call_', '') + return { name: name ?? '?', top, bottom } + }) + .sort((left, right) => left.top - right.top)) + expect(rows.map(row => row.name)).toEqual(['a', 'b', 'k1', 'c', 'k2', 'd', 'k3', 'e', 'k4']) + const gaps = Object.fromEntries(rows.slice(1).map((row, index) => + [`${rows[index]!.name}→${row.name}`, Math.round(row.top - rows[index]!.bottom)])) + expect(gaps).toMatchObject({ 'a→b': 4, 'b→k1': 4, 'c→k2': 4, 'k2→d': 4, 'd→k3': 4, 'k3→e': 4, 'e→k4': 4 }) + expect(console.pageErrors).toEqual([]) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9e6e59f142..a70870d5ec 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -89,6 +89,7 @@ "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/reasoning-preview.e2e.ts", + "tests/reasoning-spacing.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/sessionless-header.e2e.ts", "tests/document-preview.e2e.ts", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 0ea68825c1..6ce0a71af9 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,8 +33,8 @@ en: 7cc6692b879aa500 zh: e924733c88b526c9 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: e2b02942a55fb669 - zh: a38bbee0cc69ae4e + en: f6d8bbae13a1a8f8 + zh: 96c8f400c215e33d /deepseek-ai-dsh-client-ui-chat/scroll-ownership: en: 1b12aa6c505dde3b zh: 88e1654dd63816d7 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 672da9097d..1a1869e4bd 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -127,7 +127,7 @@ Live tool deltas share reasoning's frame-batched publication; durable calls and The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. The earlier-history control directly precedes the flow anchor; their sibling relationship supplies the leading gap. CSS variables do not belong in the Group Definition. -Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Ordinary flow rows sit 4px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. +Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Ordinary flow rows sit 4px apart, including adjacent reasoning rows of one Assistant message. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 29a1951662..8f2648fd2d 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -127,7 +127,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。更早历史控件紧邻流锚点之前,两者的兄弟关系决定首行间距。CSS 变量不属于 Group Definition。 -运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。普通流程行之间相隔 4px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 +运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。普通流程行之间相隔 4px,同一条 Assistant 消息里相邻的推理行也是如此;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index 990aa985f6..6441ce20c6 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -15,9 +15,17 @@ } .body { + --dsh-assistant-block-gap: 16px; display: flex; flex-direction: column; - gap: 16px; + gap: var(--dsh-assistant-block-gap); +} + +/* A step-group reasoning part (its Chat seat carries data-chat-group-part) + holds only reasoning rows, which are process rows: they sit 4px apart, like + rows of different steps. */ +:global([data-chat-group-part="reasoning"]) .body { + --dsh-assistant-block-gap: 4px; } /* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span @@ -50,11 +58,13 @@ z-index: 1; } -/* hidden="until-found" keeps a zero-height reasoning box in flex layout. - Cancel the one gap that box would otherwise leave before the visible reply; - visible Assistant blocks retain the ordinary 16px rhythm. */ -.body > [data-turn-process-inline][hidden] { - margin-bottom: -16px; +/* hidden="until-found" keeps a zero-height reasoning box in flex layout, and + so does the class-less wrapper of a reasoning block whose row renders + nothing (signature-only reasoning has no display text). Cancel the one gap + each box would otherwise leave. */ +.body > [data-turn-process-inline][hidden], +.body > :empty:not([class]) { + margin-bottom: calc(-1 * var(--dsh-assistant-block-gap)); } /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed diff --git a/tsconfig.host.json b/tsconfig.host.json index 9480842554..21ad8213c5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -79,6 +79,7 @@ "apps/web/tests/trajectory-virtualization.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", "apps/web/tests/reasoning-preview.e2e.ts", + "apps/web/tests/reasoning-spacing.e2e.ts", "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/sessionless-header.e2e.ts", "apps/web/tests/document-preview.e2e.ts", From a62156c1edf89713ab9c336d775021a3abbad217 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 8 Nov 2026 22:58:56 +0800 Subject: [PATCH 131/149] test(forge): pin the commit menu clock The rows name each commit's age, so the fixed 2026-10-09 fixture began reading '1 mo ago' once the wall clock crossed 30 days and the ' d ago' assertion failed on every lane. Pin Date to the suite's 2026-10-09T12:00:00Z now and date the fixture two days earlier, matching the formatAgo cases. --- .../tests/review-controls.client.spec.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx index c1ffebcbe4..d310ce00ab 100644 --- a/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/review-controls.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Review controls of the changes view: the lens in both forms, the test file switch, viewed progress, and the commit picker. */ import { act, cleanup, fireEvent, render, within } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { ForgeCommitList } from '@deepseek-ai/dsh-experimental-forge/types' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -15,7 +15,7 @@ const sha = (n: number): string => String(n).repeat(40) const COMMITS: ForgeCommitList = { headSha: sha(3), truncated: false, - commits: [1, 2, 3].map(n => ({ sha: sha(n), parent: n === 1 ? null : sha(n - 1), title: `commit ${n}`, author: 'octo', authoredAt: '2026-10-09T00:00:00Z' })), + commits: [1, 2, 3].map(n => ({ sha: sha(n), parent: n === 1 ? null : sha(n - 1), title: `commit ${n}`, author: 'octo', authoredAt: '2026-10-07T12:00:00Z' })), } afterEach(() => { @@ -121,6 +121,10 @@ describe('HideTestsSwitch and ViewedProgress', () => { }) describe('CommitMenu', () => { + // The rows name each commit's age; pin Date so the label does not follow the wall clock. + beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'], now: Date.parse('2026-10-09T12:00:00Z') }) }) + afterEach(() => { vi.useRealTimers() }) + function mount(props: Partial = {}) { const list = props.list ?? vi.fn(async () => ({ ok: true, value: COMMITS })) const choose = vi.fn() @@ -151,7 +155,7 @@ describe('CommitMenu', () => { expect(m.list).toHaveBeenCalledTimes(1) const rows = m.rows() expect(rows[0]).toBe('All commits3 commits') - expect(rows.slice(1).map((row, index) => row.startsWith(`${sha(index + 1).slice(0, 7)}commit ${index + 1}`) && row.endsWith(' d ago'))).toEqual([true, true, true]) + expect(rows.slice(1)).toEqual([1, 2, 3].map(n => `${sha(n).slice(0, 7)}commit ${n}${t('time.days', { n: 2 })}`)) expect(within(document.body).getByText(en['commits.shiftHint'])).toBeDefined() expect(within(document.body).getByRole('menuitem', { name: /^All commits/ }).getAttribute('aria-current')).toBe('page') fireEvent.click(m.row(2)) From bafc363d780705ec674abb95eb93917f7d9dbb23 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 9 Nov 2026 10:59:09 +0800 Subject: [PATCH 132/149] fix(worktree): reclaim archived checkouts in large repositories Archive cleanup read whole Git listings into the 64 KiB maxOutputBytes buffer, so every clean checkout in a repository with more than about a thousand tracked files settled as failed. - Drop the skip-worktree/assume-unchanged and gitlink listings; removal follows what `git status` reports, as peer agent products do. - Stream `git status` and stop at its first entry, and stream the worktree listing, so neither is bounded by maxOutputBytes; restoring a worktree shares the streamed lookup. - Retain a checkout whose administrative directory holds submodule repositories instead of letting Git refuse the removal. Refs #8151 --- docs/config-catalog.md | 7 +- docs/config-catalog.zh.md | 7 +- .../archive-worktrees/guide.i18n.yaml | 8 +- .../v0.2.2-rc.1/archive-worktrees/guide.md | 4 +- .../v0.2.2-rc.1/archive-worktrees/guide.zh.md | 4 +- .../experimental/worktree/README.i18n.yaml | 4 +- packages/experimental/worktree/README.md | 2 +- packages/experimental/worktree/README.zh.md | 2 +- packages/experimental/worktree/src/index.ts | 32 +++-- packages/experimental/worktree/src/process.ts | 123 ++++++++++++++---- packages/experimental/worktree/src/removal.ts | 81 +++++++----- .../worktree/tests/archive.spec.ts | 38 +++++- .../worktree/tests/removal.spec.ts | 54 ++++++-- .../worktree/tests/worktree.spec.ts | 16 +++ 14 files changed, 290 insertions(+), 92 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d6284aee3c..678d7387d2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1561,7 +1561,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/experimental/worktree/src/index.ts:35`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:36`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, subprocess resource limits, and the archive cleanup switch. */ @@ -1580,7 +1580,10 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status` and the worktree listing, so it bounds only their stderr. + */ maxOutputBytes?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 70e75f8142..b1707d7c32 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1563,7 +1563,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/experimental/worktree/src/index.ts:35`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:36`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, subprocess resource limits, and the archive cleanup switch. */ @@ -1582,7 +1582,10 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status` and the worktree listing, so it bounds only their stderr. + */ maxOutputBytes?: number } ``` diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml index b563eb7323..c17fd006c0 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml @@ -9,8 +9,8 @@ en: bc309764ab7b8b98 zh: 20fdfbce76706025 /worktree-cleanup-on-archive/change: - en: 05f27e166f2dbb40 - zh: a320ac6252fd2e38 + en: c16e3db7ee76eee8 + zh: a1db1e346d5af41a /worktree-cleanup-on-archive/migration: - en: c9b2fca77d02e07f - zh: a4228d7f22fb7e35 + en: 05f29b760cb99ae9 + zh: 33f181ac2a582bdf diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md index 77912a2e71..9232c6ecdd 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md @@ -9,13 +9,13 @@ English | [中文](guide.zh.md) ## Change -Archiving a Session previously retained its checkout. The worktree service now records each checkout it creates as a `plugin:worktree/created` record and, when the Session that created one is archived, removes it with a non-force `git worktree remove` when no unarchived Session refers to it, its creator's policy is not read-only, and Git finds no tracked changes, untracked files, hidden index flags, submodules, locks, or detached HEAD. Ignored files such as `node_modules` do not retain it. Branches and commits remain. The service's `retainOnArchive` setting, off by default, keeps every checkout instead. Checkouts created before this release have no such event and are never swept, nor are Sessions archived earlier. See the [worktree service](../../../../packages/experimental/worktree/README.md). +Archiving a Session previously retained its checkout. The worktree service now records each checkout it creates as a `plugin:worktree/created` record and, when the Session that created one is archived, removes it with a non-force `git worktree remove` when no unarchived Session refers to it, its creator's policy is not read-only, and Git finds no tracked changes, untracked files, submodule repositories, locks, or detached HEAD. Ignored files such as `node_modules` and edits to skip-worktree or assume-unchanged files, which `git status` hides, do not retain it; Git discards them. Branches and commits remain. The service's `retainOnArchive` setting, off by default, keeps every checkout instead. Checkouts created before this release have no such event and are never swept, nor are Sessions archived earlier. See the [worktree service](../../../../packages/experimental/worktree/README.md). The Forge bundle mounts one `worktree` service for both the panel and the `create_worktree` tool. The `forge-worktree` row and the `optional-forge-panel` group that isolated a second service are gone; the panel is the top-level `forge-panel` row. ## Migration -1. If a checkout must remain available outside DSH, keep its Session unarchived, lock it with `git worktree lock ` before archiving, or turn on **Keep worktrees when archiving** on the Forge bundle's worktree row of the Plugins page (`retainOnArchive`), which keeps every checkout. Ignored files are not kept. +1. If a checkout must remain available outside DSH, keep its Session unarchived, lock it with `git worktree lock ` before archiving, or turn on **Keep worktrees when archiving** on the Forge bundle's worktree row of the Plugins page (`retainOnArchive`), which keeps every checkout. Ignored files are not kept, nor are edits to skip-worktree or assume-unchanged files: commit those edits, or clear the flags with `git update-index --no-skip-worktree` or `--no-assume-unchanged`, before archiving. 2. To resume an archived Session whose checkout was removed, unarchive it and use **Restore worktree**. The retained branch is checked out at the recorded path. 3. In a profile's `cordis.patch.yml`, move any `forge-worktree` override to the `worktree` row and any `optional-forge-panel` override to the `forge-panel` row; a `disabled: true` on `forge-panel` turns off the panel alone. 4. Confirm the branch and commits are present with `git branch --list` and that **Restore worktree** makes the recorded checkout available. Session logs require no migration. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md index 5edf966298..0d32dded1c 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md @@ -9,13 +9,13 @@ description: "归档 Session 可以删除其干净的工作树并保留分支, ## 变更 -归档 Session 以前会保留检出目录。工作树服务现在把它创建的每个检出目录记录为 `plugin:worktree/created` Session 事件;当创建它的 Session 被归档时,若没有未归档的 Session 引用该目录、创建者的策略不是只读,且 Git 未发现跟踪文件改动、未跟踪文件、隐藏的索引标记、子模块、锁或分离 HEAD,就用不带 force 的 `git worktree remove` 删除它。`node_modules` 之类被忽略的文件不会保留目录。分支和提交仍会保留。服务的 `retainOnArchive` 设置默认关闭,开启后改为保留所有检出目录。本版本之前创建的检出目录没有该事件,不会被扫描;更早归档的 Session 也不会。参见[工作树服务](../../../../packages/experimental/worktree/README.zh.md)。 +归档 Session 以前会保留检出目录。工作树服务现在把它创建的每个检出目录记录为 `plugin:worktree/created` Session 事件;当创建它的 Session 被归档时,若没有未归档的 Session 引用该目录、创建者的策略不是只读,且 Git 未发现跟踪文件改动、未跟踪文件、子模块仓库、锁或分离 HEAD,就用不带 force 的 `git worktree remove` 删除它。`node_modules` 之类被忽略的文件,以及 `git status` 不报告的 skip-worktree 或 assume-unchanged 文件改动,都不会保留目录,Git 会丢弃它们。分支和提交仍会保留。服务的 `retainOnArchive` 设置默认关闭,开启后改为保留所有检出目录。本版本之前创建的检出目录没有该事件,不会被扫描;更早归档的 Session 也不会。参见[工作树服务](../../../../packages/experimental/worktree/README.zh.md)。 Forge bundle 为面板和 `create_worktree` 工具挂载同一个 `worktree` 服务。隔离第二个服务的 `forge-worktree` 行与 `optional-forge-panel` 组已移除;面板是顶层的 `forge-panel` 行。 ## 迁移 -1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock ` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留。 +1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock ` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留,skip-worktree 或 assume-unchanged 文件的改动也不会:归档前请提交这些改动,或用 `git update-index --no-skip-worktree` 或 `--no-assume-unchanged` 清除标记。 2. 要继续使用已归档且检出目录已删除的 Session,请取消归档并使用**恢复工作树**。保留的分支会在记录的路径重新检出。 3. 在 profile 的 `cordis.patch.yml` 中,把针对 `forge-worktree` 的覆盖迁到 `worktree` 行,把针对 `optional-forge-panel` 的覆盖迁到 `forge-panel` 行;对 `forge-panel` 设置 `disabled: true` 可以只关闭面板。 4. 用 `git branch --list` 确认分支与提交仍然存在,并确认**恢复工作树**使记录的检出目录重新可用。Session 日志无需迁移。 diff --git a/packages/experimental/worktree/README.i18n.yaml b/packages/experimental/worktree/README.i18n.yaml index c964c25be1..788a8dfda7 100644 --- a/packages/experimental/worktree/README.i18n.yaml +++ b/packages/experimental/worktree/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-worktree/use-this-package: - en: c788af94d52dd001 - zh: 200580d1a97048eb + en: 56af10241dc270ee + zh: 2519395ab9641235 /deepseek-ai-dsh-experimental-worktree/understand-the-implementation: en: 6971a7ca9073bef1 zh: b977c28a75c7f963 diff --git a/packages/experimental/worktree/README.md b/packages/experimental/worktree/README.md index 756ea6803f..4c394a6d2a 100644 --- a/packages/experimental/worktree/README.md +++ b/packages/experimental/worktree/README.md @@ -36,7 +36,7 @@ Mount the service alongside `workingDirectory`, `fs`, `subprocess`, `sandbox`, ` `ctx.worktrees.restore(agent, { path, branch, baseCommit, repositoryRoot }, signal?, onPhase?)` checks a removed worktree out again at its recorded directory. It validates the recorded repository root, full commit object name, branch, and canonical target inside the configured pool before mutation. It removes only the missing target's unlocked registration with `git worktree remove`; other worktrees and locked registrations remain unchanged. It then checks out the branch when it still exists, or creates the branch again at `baseCommit` when it does not, and reports which happened as `branchRecreated`. An existing directory is an error. The calling Session's working directory does not change, and changes that were never committed are not recovered. Checkout disables filters as creation does. `onPhase` receives `prepare` before validation, target-registration cleanup, and branch creation, and `checkout` before the files are written; it must not throw. -Every creation appends a `plugin:worktree/created` record to the Session log after the directory change, so the `worktree` projection lists the Session's checkouts with `owned` false for one inside a fork-inherited prefix. When the Workspace archives a Session, the service registers one `scheduled` cleanup for the first checkout that Session created itself and returns; the inspection runs in the background. While `retainOnArchive` is on, archiving registers nothing and every checkout stays; the field is volatile and read at each archive, and the [Forge panel](../client-ui-forge/README.md) edits it as a switch on the bundle's worktree row of the Plugins page. The checkout is retained while any unarchived Session refers to it, by a recorded creation or by a working directory inside it (a Session beneath an archived subagent ancestor counts as archived; cold Sessions are read from the projection cache or their log), and while the creator's sandbox policy is read-only. An eligible checkout is removed with a non-force `git worktree remove` under the creator's policy after a second use check inside the path lock, which also holds `ensure` and `set` into that path through `working-directory/access`; the branch and its commits remain. Tracked changes, untracked files, hidden index flags, submodules, locks, detached HEAD, a mismatched branch, and targets outside the configured pool retain the checkout; ignored files do not, as Git itself discards them. Processes are not consulted: a process still writing there makes Git refuse, and an idle one fails its next command. Unarchiving cancels an unadmitted removal and waits for an admitted one. `cleanupOf(session, revision)` reads the attempt, or waits for a named revision to settle, as `scheduled`, `retained`, `removed`, or `failed`; a failure leaves the Session archived and logs a warning. Checkouts created before this event existed, and Sessions archived earlier, are not swept. +Every creation appends a `plugin:worktree/created` record to the Session log after the directory change, so the `worktree` projection lists the Session's checkouts with `owned` false for one inside a fork-inherited prefix. When the Workspace archives a Session, the service registers one `scheduled` cleanup for the first checkout that Session created itself and returns; the inspection runs in the background. While `retainOnArchive` is on, archiving registers nothing and every checkout stays; the field is volatile and read at each archive, and the [Forge panel](../client-ui-forge/README.md) edits it as a switch on the bundle's worktree row of the Plugins page. The checkout is retained while any unarchived Session refers to it, by a recorded creation or by a working directory inside it (a Session beneath an archived subagent ancestor counts as archived; cold Sessions are read from the projection cache or their log), and while the creator's sandbox policy is read-only. An eligible checkout is removed with a non-force `git worktree remove` under the creator's policy after a second use check inside the path lock, which also holds `ensure` and `set` into that path through `working-directory/access`; the branch and its commits remain. Tracked changes, untracked files, submodule repositories (Git refuses to remove those), locks, detached HEAD, a mismatched branch, and targets outside the configured pool retain the checkout. Ignored files and edits to skip-worktree or assume-unchanged files do not: `git status` reports neither, and Git discards them with the checkout. Inspection reads `git status` only up to its first entry and streams the worktree listing, so neither is bounded by `maxOutputBytes`. Processes are not consulted: a process still writing there makes Git refuse, and an idle one fails its next command. Unarchiving cancels an unadmitted removal and waits for an admitted one. `cleanupOf(session, revision)` reads the attempt, or waits for a named revision to settle, as `scheduled`, `retained`, `removed`, or `failed`; a failure leaves the Session archived and logs a warning. Checkouts created before this event existed, and Sessions archived earlier, are not swept. The default checkout directory is `/.agents/worktrees/`. The returned `repositoryRoot` is the top-level directory of the caller’s current checkout; when it is a linked checkout, the pool is nested inside it. Omitted names join the `dsh-` prefix, the first eight hexadecimal digits of the UUID in the Session id, a hyphen, and four random hexadecimal digits, such as `dsh-6b1f02c4-9a3e`; a Session id without a UUID gets eight random hexadecimal digits after the prefix. `generatedWorktreeName` exports the same rule for callers that name a worktree before creating it. The service writes a `.gitignore` containing `*` and a trailing newline only when its allocation creates the pool; existing files and ignore rules are preserved. Each Git and Node command runs under its own `timeoutMs` deadline, 5 minutes by default, so checking out a large repository on a slow filesystem can finish; cancellation still stops the running command. Relative `directory`, generated `namePrefix`, executable choices, process limits, and `retainOnArchive` are configurable in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-worktree). diff --git a/packages/experimental/worktree/README.zh.md b/packages/experimental/worktree/README.zh.md index fa3624a49c..262ef39480 100644 --- a/packages/experimental/worktree/README.zh.md +++ b/packages/experimental/worktree/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `ctx.worktrees.restore(agent, { path, branch, baseCommit, repositoryRoot }, signal?, onPhase?)` 在记录的目录重新检出被删除的 worktree。它在修改前校验记录中的仓库根目录、完整提交对象名、分支和配置池内的规范目标路径。它只通过 `git worktree remove` 移除缺失目标的未锁定登记;其他 worktree 与锁定登记保持不变。随后在分支仍存在时检出该分支,否则在 `baseCommit` 重新创建同名分支,并通过 `branchRecreated` 报告是哪种情况。目录已存在时会报错。调用方 Session 的工作目录不变,从未提交的修改无法恢复。检出与创建时一样禁用过滤器。`onPhase` 在检查目录、清理登记与创建分支之前收到 `prepare`,在写入文件之前收到 `checkout`;它不能抛出异常。 -每次创建都会在目录切换之后向 Session 日志追加一条 `plugin:worktree/created` 事件,因此 `worktree` 投影列出该 Session 的检出目录,位于 fork 继承前缀内的条目 `owned` 为 false。Workspace 归档某个 Session 时,服务为该 Session 自己创建的第一个检出目录登记一条 `scheduled` 清理并立即返回;检查在后台进行。`retainOnArchive` 开启时,归档不登记任何清理,所有检出目录都保留;该字段是 volatile 字段,每次归档时读取,[Forge 面板](../client-ui-forge/README.zh.md)在插件页该套件的 worktree 行上以开关编辑它。只要任一未归档 Session 仍引用该目录——通过记录的创建,或通过位于其中的工作目录(祖先子代理已归档的 Session 视为已归档;冷 Session 从投影缓存或其日志读取)——或创建者的沙箱策略为只读,目录就保留。符合条件的目录在路径锁内再做一次使用检查后,用创建者的策略执行不带 force 的 `git worktree remove`;路径锁同时通过 `working-directory/access` 阻塞进入该路径的 `ensure` 与 `set`;分支及其提交保留。跟踪文件改动、未跟踪文件、隐藏的索引标记、子模块、锁、分离 HEAD、分支不匹配以及位于配置池之外的目标都会保留目录;被忽略的文件不会,因为 Git 自身会丢弃它们。不检查进程:仍在写入的进程会让 Git 拒绝删除,空闲进程的下一条命令会失败。取消归档会取消尚未获准的删除,并等待已获准的删除结束。`cleanupOf(session, revision)` 读取本次尝试,或等待指定修订号结束,状态为 `scheduled`、`retained`、`removed` 或 `failed`;失败时 Session 仍保持归档并记录警告。此事件存在之前创建的检出目录,以及更早归档的 Session,不会被扫描。 +每次创建都会在目录切换之后向 Session 日志追加一条 `plugin:worktree/created` 事件,因此 `worktree` 投影列出该 Session 的检出目录,位于 fork 继承前缀内的条目 `owned` 为 false。Workspace 归档某个 Session 时,服务为该 Session 自己创建的第一个检出目录登记一条 `scheduled` 清理并立即返回;检查在后台进行。`retainOnArchive` 开启时,归档不登记任何清理,所有检出目录都保留;该字段是 volatile 字段,每次归档时读取,[Forge 面板](../client-ui-forge/README.zh.md)在插件页该套件的 worktree 行上以开关编辑它。只要任一未归档 Session 仍引用该目录——通过记录的创建,或通过位于其中的工作目录(祖先子代理已归档的 Session 视为已归档;冷 Session 从投影缓存或其日志读取)——或创建者的沙箱策略为只读,目录就保留。符合条件的目录在路径锁内再做一次使用检查后,用创建者的策略执行不带 force 的 `git worktree remove`;路径锁同时通过 `working-directory/access` 阻塞进入该路径的 `ensure` 与 `set`;分支及其提交保留。跟踪文件改动、未跟踪文件、子模块仓库(Git 拒绝删除这类目录)、锁、分离 HEAD、分支不匹配以及位于配置池之外的目标都会保留目录。被忽略的文件以及对 skip-worktree 或 assume-unchanged 文件的改动不会:`git status` 不报告这两类内容,Git 删除目录时一并丢弃它们。检查只读取 `git status` 的第一条记录,并以流式读取工作树列表,因此两者都不受 `maxOutputBytes` 限制。不检查进程:仍在写入的进程会让 Git 拒绝删除,空闲进程的下一条命令会失败。取消归档会取消尚未获准的删除,并等待已获准的删除结束。`cleanupOf(session, revision)` 读取本次尝试,或等待指定修订号结束,状态为 `scheduled`、`retained`、`removed` 或 `failed`;失败时 Session 仍保持归档并记录警告。此事件存在之前创建的检出目录,以及更早归档的 Session,不会被扫描。 默认检出目录是 `/.agents/worktrees/`。返回的 `repositoryRoot` 是调用方当前检出目录的顶层目录;如果它是链接检出目录,工作树父目录就嵌套在其中。省略名称时,名称由前缀 `dsh-`、Session id 中 UUID 的前 8 位十六进制数、一个连字符和 4 位随机十六进制数组成,例如 `dsh-6b1f02c4-9a3e`;Session id 不含 UUID 时,前缀后接 8 位随机十六进制数。`generatedWorktreeName` 导出同一规则,供在创建前确定名称的调用方使用。仅当服务的分配操作创建了父目录时,服务才会写入内容为 `*` 加换行符的 `.gitignore`;已有文件与忽略规则保持不变。每条 Git 和 Node 命令各自受 `timeoutMs` 时限约束,默认 5 分钟,使大仓库在较慢的文件系统上也能完成检出;取消仍会终止正在运行的命令。相对路径 `directory`、生成名称使用的 `namePrefix`、可执行程序、进程限制与 `retainOnArchive` 可在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-worktree)中查阅。 diff --git a/packages/experimental/worktree/src/index.ts b/packages/experimental/worktree/src/index.ts index 3c74e3f73c..683798a5d4 100644 --- a/packages/experimental/worktree/src/index.ts +++ b/packages/experimental/worktree/src/index.ts @@ -15,11 +15,12 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { WorktreeReclamation } from './archive.ts' import { PREPARE_DIRECTORY } from './directory.ts' import { generatedWorktreeName } from './name.ts' -import { runCommand } from './process.ts' +import { runCommand, scanCommand } from './process.ts' import type { ProcessConfig } from './process.ts' import { CREATED_RECORD, worktreeProjection } from './projection.ts' import type { WorktreeProjectionState } from './projection.ts' import { checkoutIdentity, registration, removeCheckout } from './removal.ts' +import type { GitScan } from './removal.ts' import type { CreatedWorktree, CreateWorktreePhase, CreateWorktreeRequest, RestoredWorktree, RestoreWorktreeRequest, WorktreeCleanup, } from './types.ts' @@ -47,7 +48,10 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status` and the worktree listing, so it bounds only their stderr. + */ maxOutputBytes?: number } @@ -220,8 +224,9 @@ export class WorktreeService extends Service { ): Promise { const control = AbortSignal.any([this.lifetime.signal, signal]) control.throwIfAborted() - const run = this.gitRunner(this.ctx.sandboxPolicy.resolve({ session }), control) - const operation = removeCheckout(this.ctx, this.config.directory, request, run, control, admit) + const policy = this.ctx.sandboxPolicy.resolve({ session }) + const git = { read: this.gitRunner(policy, control), scan: this.gitScanner(policy, control) } + const operation = removeCheckout(this.ctx, this.config.directory, request, git, control, admit) this.pending.add(operation) try { return await operation } finally { this.pending.delete(operation) } } @@ -296,7 +301,8 @@ export class WorktreeService extends Service { if (identity === null) throw new Error('worktree restoration must use a canonical directory inside the configured pool of the source checkout root') await run(repositoryRoot, ['check-ref-format', `refs/heads/${branch}`]) await run(repositoryRoot, ['rev-parse', '--verify', '--end-of-options', `${baseCommit}^{commit}`]) - const fields = await registration(this.ctx, { path, branch, baseCommit, repositoryRoot }, identity.target, run, signal) + const scan = this.gitScanner(policy, signal) + const fields = await registration(this.ctx, { path, branch, baseCommit, repositoryRoot }, identity.target, scan, signal) if (fields !== undefined) { if (fields.some(field => field === 'locked' || field.startsWith('locked '))) throw new Error(`worktree is locked: ${path}`) if (await this.ctx.fs.lstat(path, undefined, signal) !== undefined) throw new Error(`worktree path already exists: ${path}`) @@ -320,15 +326,23 @@ export class WorktreeService extends Service { } } - /** Confine Git commands to the caller's sandbox policy, with hooks and the filesystem monitor disabled. */ + /** Confine Git commands to the caller's sandbox policy, collecting bounded stdout. */ private gitRunner(policy: SandboxExecutionPolicy, signal: AbortSignal): GitRun { return (directory, args, allowedExitCodes, env) => runCommand( - this.ctx, this.config, policy, directory, - [this.config.gitCommand, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args], - signal, allowedExitCodes, env, + this.ctx, this.config, policy, directory, this.gitArgv(args), signal, allowedExitCodes, env, ) } + /** Confine Git commands to the caller's sandbox policy, streaming stdout instead of collecting it. */ + private gitScanner(policy: SandboxExecutionPolicy, signal: AbortSignal): GitScan { + return (directory, args, onField) => scanCommand(this.ctx, this.config, policy, directory, this.gitArgv(args), signal, onField) + } + + /** The configured Git executable with hooks and the filesystem monitor disabled, followed by `args`. */ + private gitArgv(args: string[]): [string, ...string[]] { + return [this.config.gitCommand, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args] + } + /** Allocate the checkout directory exclusively inside the pool, creating a self-ignoring pool when it is new. */ private async prepareDirectory( policy: SandboxExecutionPolicy, diff --git a/packages/experimental/worktree/src/process.ts b/packages/experimental/worktree/src/process.ts index 74c8fa1c1b..94846759c1 100644 --- a/packages/experimental/worktree/src/process.ts +++ b/packages/experimental/worktree/src/process.ts @@ -1,8 +1,11 @@ /** Bounded, cancellable commands under the calling Session's existing file policy. */ +import type { Readable } from 'node:stream' import type { Context } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' -import type { SubprocessCollectedOutputs } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessCollect, SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputReader, +} from '@deepseek-ai/dsh-subprocess' import { deadline } from '@deepseek-ai/dsh-timeout' /** Resolved process resource limits owned by the worktree service. */ @@ -46,39 +49,109 @@ export async function runCommand( env: NodeJS.ProcessEnv = {}, ): Promise<{ stdout: string; exitCode: number }> { using timeout = deadline(signal, config.timeoutMs, 'WORKTREE_TIMEOUT') - timeout.signal.throwIfAborted() - const program = await ctx.subprocess.resolveExecutable(argv[0], undefined, timeout.signal) - const command = [program, ...argv.slice(1)] - const wrapped = policy.mode === 'danger-full-access' - ? command - : (await ctx.sandbox.confine(command, { ...policy, mode: policy.mode }, timeout.signal)).argv - timeout.signal.throwIfAborted() - const child = ctx.subprocess.spawn({ - argv: wrapped, - cwd, - env: { ...GIT_ENV, GIT_TERMINAL_PROMPT: '0', GIT_LFS_SKIP_SMUDGE: '1', GIT_NO_LAZY_FETCH: '1', ...env }, - signal: timeout.signal, - graceMs: config.graceMs, - stdio: { - stdin: 'ignore', - stdout: { maxBytes: config.maxOutputBytes }, - stderr: { maxBytes: config.maxOutputBytes }, - }, - }) + const child = await spawnCommand(ctx, config, policy, cwd, argv, timeout.signal, env, { maxBytes: config.maxOutputBytes }) try { const outcome = await child.done timeout.signal.throwIfAborted() // The spawn spec above collects both streams. const captured = child.collected as Required + const exitCode = allowedExitCode(outcome, captured.stderr, allowedExitCodes) const stdout = captured.stdout.readFrom(0) - const stderr = captured.stderr.readFrom(0) - if (outcome.exitCode === null || !allowedExitCodes.includes(outcome.exitCode)) { - throw new Error(`worktree command failed (${outcome.signal ?? outcome.exitCode}): ${stderr.text.trim()}`) - } if (stdout.lossy) throw new Error('worktree command output exceeds maxOutputBytes') - return { stdout: stdout.text, exitCode: outcome.exitCode } + return { stdout: stdout.text, exitCode } } finally { child.terminate() await child.waitForExit() } } + +/** + * Run an argv as {@link runCommand} does, but pass each NUL-terminated stdout + * field to `onField` as it arrives instead of retaining the output, so the size + * of stdout is unbounded and `maxOutputBytes` limits only stderr. An + * unterminated remainder is ignored. A false return from `onField` terminates + * the command, whose exit status is then not checked; otherwise only status 0 succeeds. + * @param ctx - subprocess and sandbox providers in the same execution world. + * @param config - complete process resource limits. + * @param policy - unchanged calling Session policy. + * @param cwd - canonical process working directory. + * @param argv - executable followed by literal arguments. + * @param signal - operation cancellation. + * @param onField - synchronous consumer of each field in order; false ends the command. + * @returns true when `onField` ended the command, false when it exited successfully first. + */ +export async function scanCommand( + ctx: Context, + config: ProcessConfig, + policy: SandboxExecutionPolicy, + cwd: string, + argv: readonly [string, ...string[]], + signal: AbortSignal, + onField: (field: string) => boolean, +): Promise { + using timeout = deadline(signal, config.timeoutMs, 'WORKTREE_TIMEOUT') + const child = await spawnCommand(ctx, config, policy, cwd, argv, timeout.signal, {}, 'pipe') + try { + // The spawn spec above pipes stdout and collects stderr. + const stdout = child.stdout as Readable + stdout.setEncoding('utf8') + let pending = '' + let stopped = false + for await (const chunk of stdout as AsyncIterable) { + const fields = (pending + chunk).split('\0') + pending = fields.splice(-1).join('') + if (!fields.every(field => onField(field))) { + stopped = true + break + } + } + if (stopped) child.terminate() + const outcome = await child.done + timeout.signal.throwIfAborted() + if (!stopped) allowedExitCode(outcome, child.collected.stderr as SubprocessOutputReader, [0]) + return stopped + } finally { + child.terminate() + await Promise.allSettled([child.done]) + await child.waitForExit() + } +} + +/** Resolve, confine, and start one command whose stderr is collected within `maxOutputBytes`. */ +async function spawnCommand( + ctx: Context, + config: ProcessConfig, + policy: SandboxExecutionPolicy, + cwd: string, + argv: readonly [string, ...string[]], + signal: AbortSignal, + env: NodeJS.ProcessEnv, + stdout: 'pipe' | SubprocessCollect, +): Promise { + signal.throwIfAborted() + const program = await ctx.subprocess.resolveExecutable(argv[0], undefined, signal) + const command = [program, ...argv.slice(1)] + const wrapped = policy.mode === 'danger-full-access' + ? command + : (await ctx.sandbox.confine(command, { ...policy, mode: policy.mode }, signal)).argv + signal.throwIfAborted() + return ctx.subprocess.spawn({ + argv: wrapped, + cwd, + env: { ...GIT_ENV, GIT_TERMINAL_PROMPT: '0', GIT_LFS_SKIP_SMUDGE: '1', GIT_NO_LAZY_FETCH: '1', ...env }, + signal, + graceMs: config.graceMs, + stdio: { stdin: 'ignore', stdout, stderr: { maxBytes: config.maxOutputBytes } }, + }) +} + +/** + * The exit code of a command that exited with an allowed status. + * @throws when the command was signalled or exited with another status, quoting its stderr. + */ +function allowedExitCode(outcome: SubprocessOutcome, stderr: SubprocessOutputReader, allowedExitCodes: readonly number[]): number { + if (outcome.exitCode === null || !allowedExitCodes.includes(outcome.exitCode)) { + throw new Error(`worktree command failed (${outcome.signal ?? outcome.exitCode}): ${stderr.readFrom(0).text.trim()}`) + } + return outcome.exitCode +} diff --git a/packages/experimental/worktree/src/removal.ts b/packages/experimental/worktree/src/removal.ts index f9e4082bf7..bcec543e6c 100644 --- a/packages/experimental/worktree/src/removal.ts +++ b/packages/experimental/worktree/src/removal.ts @@ -3,9 +3,24 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-fs' import type { RestoreWorktreeRequest } from './types.ts' -/** Runs one Git command in the owner's confinement and reports its output. */ +/** Runs one Git command in the owner's confinement and reports its complete bounded output. */ export type GitRead = (directory: string, args: string[]) => Promise<{ stdout: string; exitCode: number }> +/** + * Runs one Git command in the owner's confinement and passes each + * NUL-terminated stdout field to `onField` without retaining the output; + * false from `onField` ends the command. Resolves true when `onField` ended it. + */ +export type GitScan = (directory: string, args: string[], onField: (field: string) => boolean) => Promise + +/** The confined Git runners inspection and removal use. */ +export interface GitCommands { + /** Commands whose output stays small, read whole. */ + readonly read: GitRead + /** Commands whose output grows with the repository or its worktrees, read as it arrives. */ + readonly scan: GitScan +} + /** Resolved canonical locations of a recorded checkout that validation confirmed. */ export interface CheckoutIdentity { readonly root: Awaited> @@ -40,22 +55,30 @@ export async function checkoutIdentity( /** * Find the registration of `target` among the source repository's worktrees. + * The listing grows with the number of worktrees and is scanned as it arrives. * @param ctx - execution-world filesystem provider. * @param request - saved checkout identity. * @param target - resolved checkout directory. - * @param run - bounded Git runner with the owner's policy. + * @param scan - streaming Git runner with the owner's policy. * @param signal - cancellation of the lookups. * @returns the porcelain fields of the matching entry, or undefined when none is registered. */ export async function registration( - ctx: Context, request: RestoreWorktreeRequest, target: CheckoutIdentity['target'], run: GitRead, signal: AbortSignal, + ctx: Context, request: RestoreWorktreeRequest, target: CheckoutIdentity['target'], scan: GitScan, signal: AbortSignal, ): Promise { - const registered = await run(request.repositoryRoot, ['worktree', 'list', '--porcelain', '-z']) - for (const entry of registered.stdout.split('\0\0').map(entry => entry.split('\0'))) { - const registeredPath = entry.find(field => field.startsWith('worktree '))?.slice('worktree '.length) - if (registeredPath === undefined) continue - const recorded = await ctx.fs.resolve(registeredPath, { cwd: request.repositoryRoot, signal }) - if (recorded.targetKey === target.targetKey) return entry + const entries: { path: string; fields: string[] }[] = [] + // Each entry of the -z porcelain listing starts with its worktree line and ends with an empty field. + let fields: string[] = [] + await scan(request.repositoryRoot, ['worktree', 'list', '--porcelain', '-z'], (field) => { + if (field.startsWith('worktree ')) { + fields = [field] + entries.push({ path: field.slice('worktree '.length), fields }) + } else if (field.length > 0) fields.push(field) + return true + }) + for (const entry of entries) { + const recorded = await ctx.fs.resolve(entry.path, { cwd: request.repositoryRoot, signal }) + if (recorded.targetKey === target.targetKey) return entry.fields } return undefined } @@ -67,51 +90,51 @@ export async function registration( * @param ctx - execution-world filesystem provider. * @param directory - configured relative worktree pool. * @param request - saved checkout identity. - * @param run - bounded Git runner with the owner's policy. + * @param git - confined Git runners with the owner's policy. * @param signal - cancellation before or during removal. * @param admit - synchronous final admission after inspection; false retains the checkout. * @returns true when removed; false when inspection or admission retains it; Git failures reject. */ export async function removeCheckout( - ctx: Context, directory: string, request: RestoreWorktreeRequest, run: GitRead, signal: AbortSignal, admit: () => boolean, + ctx: Context, directory: string, request: RestoreWorktreeRequest, git: GitCommands, signal: AbortSignal, admit: () => boolean, ): Promise { - if (!await inspectRemoval(ctx, directory, request, run, signal)) return false + if (!await inspectRemoval(ctx, directory, request, git, signal)) return false signal.throwIfAborted() if (!admit()) return false - await run(request.repositoryRoot, ['worktree', 'remove', '--', request.path]) + await git.read(request.repositoryRoot, ['worktree', 'remove', '--', request.path]) return true } /** - * Verify checkout identity and account for every file that Git's non-force - * removal would refuse: tracked changes, untracked files, hidden index flags, - * and submodules. Ignored files are not counted; `git worktree remove` discards them. + * Verify checkout identity and that `git status` reports no tracked changes or + * untracked files; its output is read only up to the first entry. Ignored files + * are not counted, nor are edits to skip-worktree or assume-unchanged files, + * which `git status` hides; `git worktree remove` discards both. A checkout + * whose administrative directory holds submodule repositories is ineligible, + * as Git refuses to remove it. * @param ctx - execution-world filesystem provider. * @param directory - configured relative worktree pool. * @param request - saved checkout identity. - * @param run - bounded Git runner with the owner's policy. + * @param git - confined Git runners with the owner's policy. * @param signal - cancellation of inspection. * @returns false for an ineligible checkout; inspection failures reject. */ export async function inspectRemoval( - ctx: Context, directory: string, request: RestoreWorktreeRequest, run: GitRead, signal: AbortSignal, + ctx: Context, directory: string, request: RestoreWorktreeRequest, git: GitCommands, signal: AbortSignal, ): Promise { const { path, branch } = request - const identity = await checkoutIdentity(ctx, directory, request, run, signal) + const identity = await checkoutIdentity(ctx, directory, request, git.read, signal) if (identity === null) return false if ((await ctx.fs.lstat(path, undefined, signal))?.type !== 'directory') return false - const fields = await registration(ctx, request, identity.target, run, signal) + const fields = await registration(ctx, request, identity.target, git.scan, signal) // The primary registration is excluded even when the configured pool contains it. if (fields === undefined || !fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field === 'detached' || field.startsWith('locked') || field.startsWith('prunable'))) return false - const head = await run(path, ['symbolic-ref', 'HEAD']) + const head = await git.read(path, ['symbolic-ref', 'HEAD']) if (head.stdout.trim() !== `refs/heads/${branch}`) return false - const status = await run(path, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none']) - if (status.stdout.length !== 0) return false - // Git status deliberately hides skip-worktree and assume-unchanged files. - const flags = await run(path, ['ls-files', '-v', '-z']) - if (flags.stdout.split('\0').some(entry => entry.length > 0 && (entry[0] === 'S' || /^[a-z]/.test(entry)))) return false - const index = await run(path, ['ls-files', '--stage', '-z']) - if (index.stdout.split('\0').some(entry => entry.startsWith('160000 '))) return false - return true + const changed = await git.scan(path, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'], () => false) + if (changed) return false + // Git refuses removal whenever this directory exists, populated or not. + const modules = await git.read(path, ['rev-parse', '--git-path', 'modules']) + return await ctx.fs.lstat(modules.stdout.replace(/\r?\n$/, ''), { cwd: path }, signal) === undefined } diff --git a/packages/experimental/worktree/tests/archive.spec.ts b/packages/experimental/worktree/tests/archive.spec.ts index e2b5c8d47a..d8d18c91ed 100644 --- a/packages/experimental/worktree/tests/archive.spec.ts +++ b/packages/experimental/worktree/tests/archive.spec.ts @@ -63,7 +63,7 @@ function agentFor(ctx: Context, session: Session): Agent { return agent } -async function fixture(options: { cache?: boolean; retainOnArchive?: boolean } = {}) { +async function fixture(options: { cache?: boolean; retainOnArchive?: boolean; maxOutputBytes?: number } = {}) { const temporary = await realpath(await mkdtemp(join(tmpdir(), 'dsh-worktree-archive-'))) cleanups.push(() => rm(temporary, { recursive: true, force: true })) const root = await repository(temporary) @@ -87,7 +87,8 @@ async function fixture(options: { cache?: boolean; retainOnArchive?: boolean } = ctx.provide('storageDomain', domain) if (options.cache === true) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 1_000, writeIntervalMs: 60_000 }) await ctx.plugin(WorkspaceRegistry) - const worktrees = ctx.plugin(WorktreeService, options.retainOnArchive === undefined ? {} : { retainOnArchive: options.retainOnArchive }) + const { cache: _cache, ...config } = options + const worktrees = ctx.plugin(WorktreeService, config) await worktrees const session = ctx.sessions.create(SessionId('archive-owner'), { meta: { cwd: root } }) const agent = agentFor(ctx, session) @@ -137,6 +138,39 @@ describe('archive-triggered worktree cleanup', () => { expect(f.ctx.workspaceRegistry.archivedSessionIds).toContain(f.session.id) }) + it('removes a clean checkout whose file listing exceeds maxOutputBytes', async () => { + const f = await fixture({ maxOutputBytes: 1_024 }) + for (let index = 0; index < 64; index++) { + await writeFile(join(f.worktree.path, `file-${String(index).padStart(3, '0')}-${'x'.repeat(24)}.txt`), `${index}\n`) + } + await git(f.worktree.path, 'add', '.') + await git(f.worktree.path, 'commit', '-m', 'many files') + expect((await git(f.worktree.path, 'ls-files', '-z')).length).toBeGreaterThan(1_024) + await f.ctx.workspaceRegistry.archiveSession(f.session.id) + expect(await settled(f)).toMatchObject({ status: 'removed' }) + await expect(access(f.worktree.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('retains a checkout whose status output exceeds maxOutputBytes instead of failing', async () => { + const f = await fixture({ maxOutputBytes: 1_024 }) + for (let index = 0; index < 64; index++) { + await writeFile(join(f.worktree.path, `untracked-${String(index).padStart(3, '0')}-${'x'.repeat(24)}.txt`), `${index}\n`) + } + expect((await git(f.worktree.path, 'status', '--porcelain', '-z', '--untracked-files=all')).length).toBeGreaterThan(1_024) + await f.ctx.workspaceRegistry.archiveSession(f.session.id) + expect(await settled(f)).toMatchObject({ status: 'retained' }) + await access(join(f.worktree.path, 'untracked-000-xxxxxxxxxxxxxxxxxxxxxxxx.txt')) + }) + + it('removes a clean checkout when the worktree listing exceeds maxOutputBytes', async () => { + const f = await fixture({ maxOutputBytes: 1_024 }) + for (let index = 0; index < 8; index++) await git(f.root, 'worktree', 'add', '--detach', join(f.temporary, `extra-${index}`)) + expect((await git(f.root, 'worktree', 'list', '--porcelain', '-z')).length).toBeGreaterThan(1_024) + await f.ctx.workspaceRegistry.archiveSession(f.session.id) + expect(await settled(f)).toMatchObject({ status: 'removed' }) + await expect(access(f.worktree.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('returns from admission before the inspection runs', async () => { const f = await fixture() const release = Promise.withResolvers() diff --git a/packages/experimental/worktree/tests/removal.spec.ts b/packages/experimental/worktree/tests/removal.spec.ts index d4afb66f85..27be81eca6 100644 --- a/packages/experimental/worktree/tests/removal.spec.ts +++ b/packages/experimental/worktree/tests/removal.spec.ts @@ -4,16 +4,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { inspectRemoval, removeCheckout } from '../src/removal.ts' -import type { GitRead } from '../src/removal.ts' +import type { GitCommands, GitRead, GitScan } from '../src/removal.ts' import type { RestoreWorktreeRequest } from '../src/types.ts' import { git, harness, repository, testAgent } from './harness.ts' /** A Git runner over the fixture's unconfined git, as the service's danger-full-access runner behaves. */ const run: GitRead = async (directory, args) => ({ stdout: await git(directory, ...args), exitCode: 0 }) +/** Pass each NUL-terminated field of `text` to `onField` as the streaming runner does; true when it stopped early. */ +function scanText(text: string, onField: (field: string) => boolean): boolean { + return !text.split('\0').slice(0, -1).every(field => onField(field)) +} + +/** A streaming runner over the same git; it reads the output whole, then passes its fields in order. */ +const scan: GitScan = async (directory, args, onField) => scanText(await git(directory, ...args), onField) + +const commands: GitCommands = { read: run, scan } + /** The inspection-and-removal step archive cleanup runs, with the pool the harness configures. */ function remove(ctx: Context, _agent: Agent, request: RestoreWorktreeRequest, admit: () => boolean = () => true): Promise { - return removeCheckout(ctx, '.agents/worktrees', request, run, new AbortController().signal, admit) + return removeCheckout(ctx, '.agents/worktrees', request, commands, new AbortController().signal, admit) } const disposers: (() => Promise)[] = [] @@ -48,21 +58,42 @@ describe('safe worktree removal', () => { expect(await git(worktree.path, 'rev-parse', 'HEAD')).toBe(head) }) - it.each(['modified', 'staged', 'untracked', 'assume-unchanged', 'skip-worktree'] as const)( + it.each(['modified', 'staged', 'untracked'] as const)( 'retains %s files', async (kind) => { const { ctx, agent, worktree } = await fixture() - const tracked = join(worktree.path, 'tracked.txt') if (kind === 'untracked') await writeFile(join(worktree.path, 'new.txt'), 'keep') else { - await writeFile(tracked, 'keep\n') + await writeFile(join(worktree.path, 'tracked.txt'), 'keep\n') if (kind === 'staged') await git(worktree.path, 'add', 'tracked.txt') - if (kind === 'assume-unchanged' || kind === 'skip-worktree') await git(worktree.path, 'update-index', `--${kind}`, 'tracked.txt') } expect(await remove(ctx, agent, worktree)).toBe(false) await access(worktree.path) }, ) + it.each(['assume-unchanged', 'skip-worktree'] as const)( + 'removes a checkout whose only edits are to %s files, which git status hides', async (flag) => { + const { ctx, agent, worktree } = await fixture() + await writeFile(join(worktree.path, 'tracked.txt'), 'hidden edit\n') + await git(worktree.path, 'update-index', `--${flag}`, 'tracked.txt') + expect(await git(worktree.path, 'status', '--porcelain')).toBe('') + expect(await remove(ctx, agent, worktree)).toBe(true) + await expect(access(worktree.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) + + it('retains a checkout holding a submodule repository, which Git refuses to remove', async () => { + const { ctx, agent, worktree, root } = await fixture() + const source = await repository() + disposers.push(() => rm(source, { recursive: true, force: true })) + await git(worktree.path, '-c', 'protocol.file.allow=always', 'submodule', 'add', source, 'nested') + await git(worktree.path, 'commit', '-m', 'submodule') + expect(await git(worktree.path, 'status', '--porcelain')).toBe('') + expect(await remove(ctx, agent, worktree)).toBe(false) + await access(join(worktree.path, 'nested', 'tracked.txt')) + await expect(git(root, 'worktree', 'remove', worktree.path)).rejects.toThrow('working trees containing submodules cannot be moved or removed') + }) + it('removes a checkout that holds only ignored files, as Git does without force', async () => { const { ctx, agent, worktree } = await fixture() await git(worktree.path, 'config', 'core.excludesFile', join(worktree.repositoryRoot, 'exclude')) @@ -94,17 +125,18 @@ describe('safe worktree removal', () => { }, ) - it.each(['repository identity', 'registration', 'checked-out branch', 'submodule'] as const)( + it.each(['repository identity', 'registration', 'checked-out branch'] as const)( 'retains a checkout when the %s changes during inspection', async (changed) => { const { ctx, worktree, root } = await fixture() - const run = vi.fn(async (directory: string, args: string[]) => { + const read = vi.fn(async (directory: string, args: string[]) => { if (changed === 'repository identity' && args[0] === 'rev-parse') return { stdout: worktree.path, exitCode: 0 } - if (changed === 'registration' && args[0] === 'worktree') return { stdout: `worktree ${root}\0branch refs/heads/main\0\0\0`, exitCode: 0 } if (changed === 'checked-out branch' && args[0] === 'symbolic-ref') return { stdout: 'refs/heads/other', exitCode: 0 } - if (changed === 'submodule' && args.includes('--stage')) return { stdout: `160000 ${worktree.baseCommit} 0\tsubmodule\0`, exitCode: 0 } return { stdout: await git(directory, ...args), exitCode: 0 } }) - expect(await inspectRemoval(ctx, '.agents/worktrees', worktree, run, new AbortController().signal)).toBe(false) + const listed: GitScan = async (directory, args, onField) => changed === 'registration' && args[0] === 'worktree' + ? scanText(`worktree ${root}\0branch refs/heads/main\0\0`, onField) + : await scan(directory, args, onField) + expect(await inspectRemoval(ctx, '.agents/worktrees', worktree, { read, scan: listed }, new AbortController().signal)).toBe(false) await access(worktree.path) }, ) diff --git a/packages/experimental/worktree/tests/worktree.spec.ts b/packages/experimental/worktree/tests/worktree.spec.ts index c1beb17c4a..33c9b84f21 100644 --- a/packages/experimental/worktree/tests/worktree.spec.ts +++ b/packages/experimental/worktree/tests/worktree.spec.ts @@ -414,6 +414,22 @@ describe('restoring removed worktrees', () => { expect(ctx.workingDirectory.get(agent.session)).toBe(root) }) + it('finds the stale registration when the worktree listing exceeds maxOutputBytes', async () => { + const root = await repo() + ctx = await harness(root, { maxOutputBytes: 1_024 }) + const agent = testAgent(ctx, root) + const created = await ctx.worktrees.create(agent, { name: 'topic' }) + await ctx.workingDirectory.set(agent, root) + for (let index = 0; index < 8; index++) await git(root, 'worktree', 'add', '--detach', join(root, '.agents/worktrees', `extra-${index}`)) + expect((await git(root, 'worktree', 'list', '--porcelain', '-z')).length).toBeGreaterThan(1_024) + await rm(created.path, { recursive: true, force: true }) + + expect(await ctx.worktrees.restore(agent, created)).toEqual({ path: created.path, branchRecreated: false }) + + expect(await git(created.path, 'symbolic-ref', '--short', 'HEAD')).toBe('topic') + expect(await registered(root)).toContain(gitPath(created.path)) + }) + it('creates a deleted branch again at its base commit, without the commits it had gained', async () => { const root = await repo() ctx = await harness(root) From b02529e520756f2fe7f0a81677d210568b12c9c8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 9 Nov 2026 11:21:00 +0800 Subject: [PATCH 133/149] feat(forge): write review threads and the pending review through the provider ForgeProvider gains an optional writeReview for replies, resolution, comment edits and deletions, and the viewer's pending review, which review reads now report with the viewer's permissions. The GitHub provider checks the head and the pending review in one query, then sends one GraphQL mutation on stdin and never retries it; a reply at once is refused while a pending review exists, since GitHub adds it to that review. The forge panel Remote changeRequestReviewWrite drops the kept threads after every write, because resolving a thread does not move a pull request's updatedAt. --- .../experimental/client-ui-forge/src/index.ts | 92 ++++++- .../client-ui-forge/src/review-cache.ts | 43 ++- .../tests/review-cache.host.spec.ts | 37 ++- .../client-ui-forge/tests/review.host.spec.ts | 107 +++++++- .../experimental/forge-github/package.json | 1 + .../experimental/forge-github/src/provider.ts | 15 +- .../forge-github/src/review-writes.ts | 209 +++++++++++++++ .../experimental/forge-github/src/threads.ts | 61 ++++- .../forge-github/tests/review-writes.spec.ts | 253 ++++++++++++++++++ .../forge-github/tests/threads.spec.ts | 63 ++++- .../experimental/forge-github/tsconfig.json | 3 + packages/experimental/forge/src/index.ts | 31 ++- packages/experimental/forge/src/types.ts | 68 ++++- .../experimental/forge/tests/registry.spec.ts | 21 +- .../experimental/forge/tests/watch.spec.ts | 32 ++- pnpm-lock.yaml | 3 + 16 files changed, 987 insertions(+), 52 deletions(-) create mode 100644 packages/experimental/forge-github/src/review-writes.ts create mode 100644 packages/experimental/forge-github/tests/review-writes.spec.ts diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 6a1baaa71d..aa6e09c9d8 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-api-remote-access' import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { - ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewData, ForgeReviewRequest, - ForgeReviewResult, MergeMethod, + ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeLineComment, ForgeReviewData, + ForgeReviewRequest, ForgeReviewResult, ForgeReviewWrite, ForgeReviewWriteResult, MergeMethod, } from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' import type { WorktreeCleanup } from '@deepseek-ai/dsh-experimental-worktree/types' @@ -466,8 +466,8 @@ export class ForgePanelController extends TypertRemoteService { /** * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read * at the same head commit and update time are reused from memory, for any Session, until they are - * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved - * since, which do not change a GitHub pull request's update time. + * `reviewRefreshMs` old, `maxCachedReviewBytes` evicts them, or a review write through this panel drops them; a read + * again after that age shows threads resolved elsewhere since, which do not change a GitHub pull request's update time. * @param session - Session the change request is bound to. * @param url - binding URL of the change request. * @param headSha - head commit of the displayed diff. @@ -493,14 +493,16 @@ export class ForgePanelController extends TypertRemoteService { const readAt = Date.now() const kept = this.reviews.get(key, version, readAt) if (kept !== undefined) return kept + const token = this.reviews.token() const review = await this.home.forge.readReview(ref, headSha, signal) if ('code' in review) throw reviewError(review) - this.reviews.set(key, version, review, readAt) + this.reviews.set(key, version, review, readAt, token) return review } /** - * Publish a line comment or completed review for a visible Session binding. + * Publish a line comment or completed review for a visible Session binding. The threads kept for the change request + * are dropped once the host answers, so the next read shows the comments. * @param session - Session retaining the change request. * @param url - visible binding URL. * @param request - reviewed head, ranges, plain text, and explicit review decision. @@ -511,13 +513,43 @@ export class ForgePanelController extends TypertRemoteService { async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise { signal.throwIfAborted() const { ref } = this.bound(session, url) - if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(request.headSha)) throw new Error('Invalid review commit') + if (!isCommit(request.headSha)) throw new Error('Invalid review commit') const comments = request.kind === 'comment' ? [request.comment] : request.comments - if (comments.some(comment => comment.path === '' || comment.body.trim() === '' - || !Number.isSafeInteger(comment.startLine) || comment.startLine < 1 - || !Number.isSafeInteger(comment.endLine) || comment.endLine < comment.startLine)) throw new Error('Invalid review comment') + if (!comments.every(validComment)) throw new Error('Invalid review comment') if (request.kind === 'review' && request.event !== 'approve' && request.body.trim() === '' && comments.length === 0) throw new Error('Empty review') - return await this.home.forge.submitReview(ref, request, signal) + try { + return await this.home.forge.submitReview(ref, request, signal) + } finally { + this.reviews.drop(changeRequestKey(ref)) + } + } + + /** + * Write to the review threads or the pending review of a change request of a Session while its head is still the + * head the user saw. The threads kept for the change request are dropped once the host answers, so the next read + * shows the write, which a resolve does not show through a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param write - the write and the head commit of the threads the user saw. + * @param signal - request cancellation. + * @returns whether the host took the write; `stale` once the head moved, which polls the change request again, and + * `unknown` when the outcome must be checked on the host before the write is tried again. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; an error + * for a write without text, a head that is not a commit object name, or an invalid line range. + */ + @Remote + async changeRequestReviewWrite( + session: Session, url: string, write: ForgeReviewWrite, signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + const { ref } = this.bound(session, url) + if (!isCommit(write.headSha)) throw new Error('Invalid review commit') + if (!validWrite(write)) throw new Error('Invalid review write') + try { + return await this.home.forge.writeReview(ref, write, signal) + } finally { + this.reviews.drop(changeRequestKey(ref)) + } } /** @@ -851,6 +883,44 @@ export class ForgePanelController extends TypertRemoteService { } } +/** + * Whether a value names a full commit object name. + * @param value - the value from the browser. + * @returns whether it is 40 or 64 hexadecimal digits. + */ +function isCommit(value: string): boolean { + return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value) +} + +/** + * Whether a line comment from the browser names a path, consecutive positive lines, and text. + * @param comment - the comment. + * @returns whether a provider may write it. + */ +function validComment(comment: ForgeLineComment): boolean { + return comment.path !== '' && comment.body.trim() !== '' && Number.isSafeInteger(comment.startLine) && comment.startLine >= 1 + && Number.isSafeInteger(comment.endLine) && comment.endLine >= comment.startLine +} + +/** + * Whether a review write from the browser names its target and carries text where the write needs it. + * @param write - the write. + * @returns whether a provider may perform it. + */ +function validWrite(write: ForgeReviewWrite): boolean { + switch (write.kind) { + case 'reply': return write.thread !== '' && write.body.trim() !== '' + case 'resolve': return write.thread !== '' + case 'edit-comment': return write.comment !== '' && write.body.trim() !== '' + case 'delete-comment': return write.comment !== '' + case 'add-pending': return validComment(write.comment) + case 'submit-pending': return write.review !== '' && (write.event !== 'request-changes' || write.body.trim() !== '') + case 'discard-pending': return write.review !== '' + /* v8 ignore next -- closed ForgeReviewWrite kind union exhaustiveness guard */ + default: return assertNever(write, 'ForgeReviewWrite.kind') + } +} + /** * Report why the code host did not provide a diff or file. * @param failure - the provider's failure. diff --git a/packages/experimental/client-ui-forge/src/review-cache.ts b/packages/experimental/client-ui-forge/src/review-cache.ts index 6a8d9f0d1e..fa8c54f53a 100644 --- a/packages/experimental/client-ui-forge/src/review-cache.ts +++ b/packages/experimental/client-ui-forge/src/review-cache.ts @@ -1,6 +1,6 @@ /** * Review threads of change requests kept in memory, so the changes view reads them from the code host again only - * after the change request's head or update time changes or the kept threads reach their age limit. + * after the change request's head or update time changes, the kept threads reach their age limit, or a review write drops them. */ import { Buffer } from 'node:buffer' @@ -18,11 +18,14 @@ interface Entry { /** * One read of review threads per change request, bounded by age and by the total UTF-8 size of the kept reads as - * JSON; the least recently read change request leaves first. + * JSON; the least recently read change request leaves first. A read that started before a drop of any change request + * is not kept, so threads read before a write never replace the drop. */ export class ReviewCache { private readonly entries = new Map() private bytes = 0 + /** Number of drops so far. */ + private drops = 0 /** * @param maxBytes - most UTF-8 bytes kept together; a larger read is not kept. @@ -46,20 +49,26 @@ export class ReviewCache { return entry.review } + /** + * Name the cache's state when a read starts, for {@link ReviewCache.set}. + * @returns the token of the read. + */ + token(): number { + return this.drops + } + /** * Keep a read in place of the change request's earlier one, then drop the least recently read change requests - * until the rest fit within `maxBytes`. + * until the rest fit within `maxBytes`. A read whose token a drop outdated is not kept. * @param key - the change request. * @param version - head commit and update time the threads were read at. * @param review - the threads. * @param readAt - epoch milliseconds when the read started. + * @param token - what {@link ReviewCache.token} returned when the read started. */ - set(key: ChangeRequestKey, version: string, review: ForgeReviewData, readAt: number): void { - const previous = this.entries.get(key) - if (previous !== undefined) { - this.entries.delete(key) - this.bytes -= previous.bytes - } + set(key: ChangeRequestKey, version: string, review: ForgeReviewData, readAt: number, token: number): void { + if (token !== this.drops) return + this.forget(key) const bytes = Buffer.byteLength(JSON.stringify(review)) if (bytes > this.maxBytes) return this.entries.set(key, { version, review, readAt, bytes }) @@ -70,4 +79,20 @@ export class ReviewCache { this.bytes -= entry.bytes } } + + /** + * Forget the threads kept for a change request, and every read in flight, after a write changed them. + * @param key - the change request. + */ + drop(key: ChangeRequestKey): void { + this.drops += 1 + this.forget(key) + } + + private forget(key: ChangeRequestKey): void { + const previous = this.entries.get(key) + if (previous === undefined) return + this.entries.delete(key) + this.bytes -= previous.bytes + } } diff --git a/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts index de498b8bbf..119f69dffd 100644 --- a/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts @@ -8,7 +8,7 @@ const b = 'fake:b#2' as ChangeRequestKey const c = 'fake:c#3' as ChangeRequestKey function review(viewer: string): ForgeReviewData { - return { headSha: 'a'.repeat(40), viewer, threads: [], truncated: false } + return { headSha: 'a'.repeat(40), viewer, threads: [], truncated: false, pendingReview: null, viewerCanReview: false } } const size = Buffer.byteLength(JSON.stringify(review('x'))) @@ -16,7 +16,7 @@ const size = Buffer.byteLength(JSON.stringify(review('x'))) describe('ReviewCache', () => { it('returns threads kept at the same version until they reach the age limit', () => { const cache = new ReviewCache(size * 4, 100) - cache.set(a, 'v1', review('x'), 1_000) + cache.set(a, 'v1', review('x'), 1_000, cache.token()) expect(cache.get(a, 'v1', 1_099)).toEqual(review('x')) expect(cache.get(a, 'v2', 1_050)).toBeUndefined() @@ -26,26 +26,45 @@ describe('ReviewCache', () => { it('replaces a change request\'s earlier read and drops the least recently read reads past the byte limit', () => { const cache = new ReviewCache(size * 2, 100) - cache.set(a, 'v1', review('x'), 0) - cache.set(a, 'v2', review('y'), 0) - cache.set(b, 'v1', review('z'), 0) + cache.set(a, 'v1', review('x'), 0, cache.token()) + cache.set(a, 'v2', review('y'), 0, cache.token()) + cache.set(b, 'v1', review('z'), 0, cache.token()) expect(cache.get(a, 'v1', 0)).toBeUndefined() expect(cache.get(a, 'v2', 0)).toEqual(review('y')) - cache.set(c, 'v1', review('w'), 0) + cache.set(c, 'v1', review('w'), 0, cache.token()) expect(cache.get(b, 'v1', 0)).toBeUndefined() expect(cache.get(a, 'v2', 0)).toEqual(review('y')) expect(cache.get(c, 'v1', 0)).toEqual(review('w')) }) + it('forgets a dropped change request and keeps no read that started before any drop', () => { + const cache = new ReviewCache(size * 4, 100) + const early = cache.token() + cache.set(a, 'v1', review('x'), 0, early) + cache.set(b, 'v1', review('y'), 0, early) + cache.drop(a) + cache.drop(c) + + expect(cache.get(a, 'v1', 0)).toBeUndefined() + expect(cache.get(b, 'v1', 0)).toEqual(review('y')) + cache.set(a, 'v1', review('z'), 0, early) + expect(cache.get(a, 'v1', 0)).toBeUndefined() + cache.set(a, 'v1', review('z'), 0, cache.token()) + expect(cache.get(a, 'v1', 0)).toEqual(review('z')) + cache.set(a, 'v2', review('much longer than the others'), 0, cache.token()) + cache.set(c, 'v1', review('w'), 0, cache.token()) + expect(cache.get(c, 'v1', 0)).toEqual(review('w')) + }) + it('keeps no read larger than the byte limit and forgets the read it replaced', () => { const cache = new ReviewCache(size, 100) - cache.set(a, 'v1', review('x'), 0) - cache.set(a, 'v2', review('longer'), 0) + cache.set(a, 'v1', review('x'), 0, cache.token()) + cache.set(a, 'v2', review('longer'), 0, cache.token()) expect(cache.get(a, 'v1', 0)).toBeUndefined() expect(cache.get(a, 'v2', 0)).toBeUndefined() - cache.set(b, 'v1', review('x'), 0) + cache.set(b, 'v1', review('x'), 0, cache.token()) expect(cache.get(b, 'v1', 0)).toEqual(review('x')) }) }) diff --git a/packages/experimental/client-ui-forge/tests/review.host.spec.ts b/packages/experimental/client-ui-forge/tests/review.host.spec.ts index 6869d3acb6..bf34f81384 100644 --- a/packages/experimental/client-ui-forge/tests/review.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review.host.spec.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ChangeRequestRef, ForgeFailure, ForgeReviewData, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge' +import type { + ChangeRequestRef, ForgeCommentId, ForgeFailure, ForgeReviewData, ForgeReviewId, ForgeReviewWrite, ForgeReviewWriteResult, ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge' import { SessionId } from '@deepseek-ai/dsh-session' import ForgePanelController from '../src/index.ts' import type { ForgePanelState } from '../src/index.ts' @@ -29,10 +31,11 @@ async function opened(config: Parameters[1] = {}): Promise<{ opene function review(headSha: string, path = 'src/a.ts'): ForgeReviewData { return { - headSha, viewer: 'octo', truncated: false, + headSha, viewer: 'octo', truncated: false, pendingReview: null, viewerCanReview: true, threads: [{ id: 'thread-1' as ForgeThreadId, path, side: 'R', subject: 'line', resolved: false, diffHunk: '@@ -1 +1 @@\n-old\n+new', current: { commit: headSha, startLine: 1, endLine: 1 }, original: { commit: headSha, startLine: 1, endLine: 1 }, comments: [], + viewerCanReply: true, viewerCanResolve: true, }], } } @@ -124,6 +127,106 @@ describe('forgePanel change request reviews', () => { expect(readReview).toHaveBeenCalledTimes(codes.length) }) + it('writes through the provider, then drops the kept threads so the next read goes to the host', async () => { + const { opened: panel, follower } = await opened() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + const writeReview = vi.fn(async (): Promise => ({ ok: true })) + Object.assign(panel.provider, { readReview, writeReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + const write: ForgeReviewWrite = { kind: 'resolve', headSha: HEAD, thread: 'thread-1' as ForgeThreadId, resolved: true } + + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await expect(panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, pullUrl(7), write, signal)).resolves.toEqual({ ok: true }) + expect(writeReview).toHaveBeenCalledWith(expect.objectContaining({ number: 7 }), write, expect.any(AbortSignal)) + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(2) + + writeReview.mockRejectedValueOnce(new Error('gh crashed')) + await expect(panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, pullUrl(7), write, signal)).rejects.toThrow('gh crashed') + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(3) + }) + + it('keeps no threads read before a write that settled during the read', async () => { + const { opened: panel, follower } = await opened() + const reading = Promise.withResolvers() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + readReview.mockReturnValueOnce(reading.promise) + Object.assign(panel.provider, { readReview, writeReview: async (): Promise => ({ ok: true }) }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + const first = panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await vi.waitFor(() => { expect(readReview).toHaveBeenCalledTimes(1) }) + await panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, pullUrl(7), + { kind: 'delete-comment', headSha: HEAD, comment: 'comment-1' as ForgeCommentId }, signal) + reading.resolve(review(HEAD, 'src/before.ts')) + await expect(first).resolves.toMatchObject({ threads: [{ path: 'src/before.ts' }] }) + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)) + .resolves.toMatchObject({ threads: [{ path: 'src/a.ts' }] }) + expect(readReview).toHaveBeenCalledTimes(2) + }) + + it('reports a provider without review writes as unsupported and refuses an unbound change request and invalid writes', async () => { + const { opened: panel, follower } = await opened() + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + const write = (url: string, value: ForgeReviewWrite): Promise => + panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, url, value, signal) + const thread = 'thread-1' as ForgeThreadId + const comment = 'comment-1' as ForgeCommentId + const reviewId = 'review-1' as ForgeReviewId + const line = { path: 'src/a.ts', side: 'R' as const, startLine: 2, endLine: 3, body: 'Check' } + + await expect(write(pullUrl(7), { kind: 'resolve', headSha: HEAD, thread, resolved: false })) + .resolves.toEqual({ ok: false, code: 'unsupported', message: 'This provider does not support review writes' }) + await expect(write(pullUrl(9), { kind: 'resolve', headSha: HEAD, thread, resolved: false })).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) + await expect(write(pullUrl(7), { kind: 'resolve', headSha: 'main', thread, resolved: false })).rejects.toThrow('Invalid review commit') + const invalid: ForgeReviewWrite[] = [ + { kind: 'reply', headSha: HEAD, thread, body: ' ', pending: false }, + { kind: 'reply', headSha: HEAD, thread: '' as ForgeThreadId, body: 'Done', pending: false }, + { kind: 'resolve', headSha: HEAD, thread: '' as ForgeThreadId, resolved: true }, + { kind: 'edit-comment', headSha: HEAD, comment, body: '' }, + { kind: 'edit-comment', headSha: HEAD, comment: '' as ForgeCommentId, body: 'Text' }, + { kind: 'delete-comment', headSha: HEAD, comment: '' as ForgeCommentId }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, path: '' } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, body: '\n' } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, startLine: 0 } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, startLine: 1.5 } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, endLine: 1 } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, endLine: Number.POSITIVE_INFINITY } }, + { kind: 'submit-pending', headSha: HEAD, review: '' as ForgeReviewId, event: 'comment', body: '' }, + { kind: 'submit-pending', headSha: HEAD, review: reviewId, event: 'request-changes', body: ' ' }, + { kind: 'discard-pending', headSha: HEAD, review: '' as ForgeReviewId }, + ] + for (const value of invalid) await expect(write(pullUrl(7), value)).rejects.toThrow('Invalid review write') + const valid: ForgeReviewWrite[] = [ + { kind: 'reply', headSha: HEAD, thread, body: 'Done', pending: true }, + { kind: 'edit-comment', headSha: HEAD, comment, body: 'Text' }, + { kind: 'delete-comment', headSha: HEAD, comment }, + { kind: 'add-pending', headSha: HEAD, comment: line }, + { kind: 'submit-pending', headSha: HEAD, review: reviewId, event: 'approve', body: '' }, + { kind: 'submit-pending', headSha: HEAD, review: reviewId, event: 'request-changes', body: 'Fix it' }, + { kind: 'discard-pending', headSha: HEAD, review: reviewId }, + ] + for (const value of valid) await expect(write(pullUrl(7), value)).resolves.toMatchObject({ ok: false, code: 'unsupported' }) + }) + + it('drops the kept threads after publishing a comment so the next read shows it', async () => { + const { opened: panel, follower } = await opened() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + Object.assign(panel.provider, { readReview, submitReview: async () => ({ ok: true as const, url: `${pullUrl(7)}#discussion_r1` }) }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await panel.ctx.forgePanel.submitReview(panel.session, pullUrl(7), + { kind: 'comment', headSha: HEAD, comment: { path: 'src/a.ts', side: 'R', startLine: 1, endLine: 1, body: 'Nit' } }, signal) + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(2) + }) + it.each([ [{ reviewRefreshMs: 0 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], [{ reviewRefreshMs: 2_147_483_648 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], diff --git a/packages/experimental/forge-github/package.json b/packages/experimental/forge-github/package.json index d0270615b4..9d9fcc6391 100644 --- a/packages/experimental/forge-github/package.json +++ b/packages/experimental/forge-github/package.json @@ -34,6 +34,7 @@ "dependencies": { "@deepseek-ai/schemastery": "workspace:~", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "execa": "^10.0.1", "zod": "4.6.6" }, diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index cf6905149e..806ef46d7b 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -3,7 +3,8 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeProvider, - ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewWrite, ForgeReviewWriteResult, + MergeRequest, } from '@deepseek-ai/dsh-experimental-forge' import { listCommits } from './commits.ts' import type { GraphqlReader } from './commits.ts' @@ -17,6 +18,7 @@ import { import type { PageResult, SnapshotPages } from './graphql.ts' import { checkedRepository, isCommit, parsePullRequestUrl, parseRemoteUrl } from './urls.ts' import { submitGitHubReview } from './review.ts' +import { writeGitHubReview } from './review-writes.ts' import { readReviewThreads } from './threads.ts' const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i @@ -65,6 +67,17 @@ export class GitHubProvider implements ForgeProvider { return await readReviewThreads(this.graphql, ref, headSha, this.limits, signal) } + /** + * Write to the review threads or the pending review of a pull request through GraphQL mutations after a check of its head. + * @see writeGitHubReview + */ + async writeReview(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) return { ok: false, code: 'failed', message: failure.message } + if (!isCommit(write.headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(write.headSha)}`) + return await writeGitHubReview(this.graphql, this.gh, ref, write, signal) + } + /** * @param gh - runner that executes `gh` on the host. * @param hosts - host names this provider serves, such as `github.com` and Enterprise hosts. diff --git a/packages/experimental/forge-github/src/review-writes.ts b/packages/experimental/forge-github/src/review-writes.ts new file mode 100644 index 0000000000..d2c6835512 --- /dev/null +++ b/packages/experimental/forge-github/src/review-writes.ts @@ -0,0 +1,209 @@ +/** GitHub writes to review threads and the pending review through GraphQL mutations, each after a check of the head. */ + +import type { + ChangeRequestRef, ForgeLineComment, ForgeReviewId, ForgeReviewWrite, ForgeReviewWriteResult, +} from '@deepseek-ai/dsh-experimental-forge' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { z } from 'zod' +import type { GraphqlReader } from './commits.ts' +import { GhUnavailableError, type GhOutput, type GhRunner } from './gh.ts' +import { checkedRepository } from './urls.ts' + +/** The pull request, its head, and the signed-in user's pending review; variables `owner` and `name` are strings, `number` an integer. */ +export const WRITE_CHECK_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { id headRefOid reviews(states: PENDING, first: 1) { nodes { id viewerDidAuthor } } } + } +}` + +/** Start a pending review at a commit, with the line threads of `threads`. */ +export const ADD_REVIEW_MUTATION = `mutation($pull: ID!, $commit: GitObjectID!, $threads: [DraftPullRequestReviewThread]) { + addPullRequestReview(input: { pullRequestId: $pull, commitOID: $commit, threads: $threads }) { pullRequestReview { id } } +}` + +/** Add one line thread to a pending review. */ +export const ADD_THREAD_MUTATION = `mutation($review: ID!, $path: String!, $body: String!, $line: Int!, $side: DiffSide!, $startLine: Int, $startSide: DiffSide) { + addPullRequestReviewThread(input: { + pullRequestReviewId: $review, path: $path, body: $body, line: $line, side: $side, startLine: $startLine, startSide: $startSide + }) { thread { id } } +}` + +/** Reply to a thread, inside the pending review `review` when it is set. */ +export const REPLY_MUTATION = `mutation($thread: ID!, $body: String!, $review: ID) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body, pullRequestReviewId: $review }) { comment { id } } +}` + +/** Mark a thread resolved. */ +export const RESOLVE_MUTATION = 'mutation($thread: ID!) { resolveReviewThread(input: { threadId: $thread }) { thread { id } } }' + +/** Mark a thread unresolved. */ +export const UNRESOLVE_MUTATION = 'mutation($thread: ID!) { unresolveReviewThread(input: { threadId: $thread }) { thread { id } } }' + +/** Replace the text of a review comment. */ +export const EDIT_COMMENT_MUTATION = `mutation($comment: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $comment, body: $body }) { pullRequestReviewComment { id } } +}` + +/** Delete a review comment. */ +export const DELETE_COMMENT_MUTATION = 'mutation($comment: ID!) { deletePullRequestReviewComment(input: { id: $comment }) { clientMutationId } }' + +/** Submit a pending review with a decision and summary. */ +export const SUBMIT_REVIEW_MUTATION = `mutation($review: ID!, $event: PullRequestReviewEvent!, $body: String) { + submitPullRequestReview(input: { pullRequestReviewId: $review, event: $event, body: $body }) { pullRequestReview { id } } +}` + +/** Delete a pending review with its comments. */ +export const DELETE_REVIEW_MUTATION = 'mutation($review: ID!) { deletePullRequestReview(input: { pullRequestReviewId: $review }) { pullRequestReview { id } } }' + +const EVENTS = { comment: 'COMMENT', approve: 'APPROVE', 'request-changes': 'REQUEST_CHANGES' } as const + +/** HTTP statuses that mean GitHub refused the request, so it changed nothing. */ +const REFUSED = /\(HTTP (?:400|401|403|404|409|422)\)/ + +const checkSchema = z.object({ data: z.object({ repository: z.object({ pullRequest: z.object({ + id: z.string(), + headRefOid: z.string(), + reviews: z.object({ nodes: z.array(z.object({ id: z.string(), viewerDidAuthor: z.boolean() })) }), +}) }) }) }) + +const graphqlErrors = z.object({ errors: z.array(z.object({ message: z.string() })).min(1) }) + +/** One mutation and the payload field that holds its result. */ +interface Mutation { + readonly query: string + readonly field: string + readonly variables: Readonly> +} + +/** A mutation's result: the payload on success, else the write's outcome. */ +type Sent = { readonly ok: true; readonly payload: unknown } | Extract + +const stringId = z.object({ id: z.string() }) + +/** + * Write to the review threads or the pending review of a pull request. One read first checks that the head is still + * `write.headSha` and finds the signed-in user's pending review; a moved head returns `stale` without writing. A pending + * reply or comment without a pending review starts one at `write.headSha`, so it takes two mutations. A reply at once is + * refused with `failed` while the user has a pending review, since GitHub would add it to that review. Mutations send + * their variables as JSON on stdin, and none is retried: a refusal GitHub reports returns `failed`, and an answer that + * does not show whether GitHub applied a mutation returns `unknown`. + * @param read - runs the check as one GraphQL request. + * @param gh - bounded, cancellable GitHub CLI runner for the mutations. + * @param ref - pull request, with an owner and name already checked for inlining. + * @param write - the write and the head commit the user saw. + * @param signal - cancellation of the check and the mutations. + * @returns whether GitHub took the write. + * @throws with `signal.reason` when the signal aborts before a mutation is sent. + */ +export async function writeGitHubReview( + read: GraphqlReader, gh: GhRunner, ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal, +): Promise { + const { host, owner, name } = checkedRepository(ref) + const checked = await read(['api', 'graphql', '--hostname', host, + '-f', `query=${WRITE_CHECK_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`], signal) + if (!checked.ok) return { ok: false, code: 'failed', message: checked.failure.message } + const parsed = checkSchema.safeParse(checked.body) + if (!parsed.success) return { ok: false, code: 'failed', message: 'unexpected GitHub response to the pull request check' } + const pull = parsed.data.data.repository.pullRequest + if (pull.headRefOid.toLowerCase() !== write.headSha.toLowerCase()) { + return { ok: false, code: 'stale', message: `the pull request head moved from ${write.headSha} to ${pull.headRefOid}` } + } + const pending = pull.reviews.nodes.find(review => review.viewerDidAuthor)?.id ?? null + signal.throwIfAborted() + const send = async (mutation: Mutation): Promise => await sendMutation(gh, host, mutation, signal) + const done = (sent: Sent): ForgeReviewWriteResult => sent.ok ? { ok: true } : sent + /** Find the pending review, starting one at the head without threads when the user has none. */ + const pendingReview = async (): Promise<{ readonly ok: true; readonly id: string } | Extract> => { + if (pending !== null) return { ok: true, id: pending } + const started = await send({ query: ADD_REVIEW_MUTATION, field: 'addPullRequestReview', + variables: { pull: pull.id, commit: write.headSha, threads: null } }) + if (!started.ok) return started + const review = z.object({ pullRequestReview: stringId }).safeParse(started.payload) + return review.success ? { ok: true, id: review.data.pullRequestReview.id } + : { ok: false, code: 'unknown', message: 'unexpected GitHub response to starting a pending review' } + } + switch (write.kind) { + case 'reply': { + // GitHub adds every reply of a user with a pending review to that review, even one sent without its id. + if (!write.pending && pending !== null) { + return { ok: false, code: 'failed', message: 'GitHub adds a reply to the pending review while one exists; submit or discard it first' } + } + const review = write.pending ? await pendingReview() : { ok: true as const, id: null } + if (!review.ok) return review + return done(await send({ query: REPLY_MUTATION, field: 'addPullRequestReviewThreadReply', + variables: { thread: write.thread, body: write.body, review: review.id } })) + } + case 'resolve': + return done(await send(write.resolved + ? { query: RESOLVE_MUTATION, field: 'resolveReviewThread', variables: { thread: write.thread } } + : { query: UNRESOLVE_MUTATION, field: 'unresolveReviewThread', variables: { thread: write.thread } })) + case 'edit-comment': + return done(await send({ query: EDIT_COMMENT_MUTATION, field: 'updatePullRequestReviewComment', + variables: { comment: write.comment, body: write.body } })) + case 'delete-comment': + return done(await send({ query: DELETE_COMMENT_MUTATION, field: 'deletePullRequestReviewComment', variables: { comment: write.comment } })) + case 'add-pending': + return done(await send(pending === null + ? { query: ADD_REVIEW_MUTATION, field: 'addPullRequestReview', + variables: { pull: pull.id, commit: write.headSha, threads: [draftThread(write.comment)] } } + : { query: ADD_THREAD_MUTATION, field: 'addPullRequestReviewThread', variables: { review: pending, ...draftThread(write.comment) } })) + case 'submit-pending': + if (pending !== write.review) return missingReview(write.review) + return done(await send({ query: SUBMIT_REVIEW_MUTATION, field: 'submitPullRequestReview', + variables: { review: write.review, event: EVENTS[write.event], body: write.body } })) + case 'discard-pending': + if (pending !== write.review) return missingReview(write.review) + return done(await send({ query: DELETE_REVIEW_MUTATION, field: 'deletePullRequestReview', variables: { review: write.review } })) + /* v8 ignore next -- closed ForgeReviewWrite kind union exhaustiveness guard */ + default: return assertNever(write, 'ForgeReviewWrite.kind') + } +} + +/** The fields of a line thread in a pending review, for one side of the diff. */ +function draftThread(comment: ForgeLineComment): Readonly> { + const side = comment.side === 'L' ? 'LEFT' : 'RIGHT' + return { path: comment.path, body: comment.body, line: comment.endLine, side, + ...comment.startLine === comment.endLine ? { startLine: null, startSide: null } : { startLine: comment.startLine, startSide: side } } +} + +function missingReview(review: ForgeReviewId): ForgeReviewWriteResult { + return { ok: false, code: 'failed', message: `the pending review ${review} is no longer the signed-in user's pending review` } +} + +/** + * Send one mutation with its query and variables as JSON on stdin. + * @returns the payload of `mutation.field`; `failed` when GitHub reports a refusal or `gh` cannot start, else `unknown`. + */ +async function sendMutation(gh: GhRunner, host: string, mutation: Mutation, signal: AbortSignal): Promise { + let output: GhOutput + try { + output = await gh(['api', 'graphql', '--hostname', host, '--input', '-'], signal, + JSON.stringify({ query: mutation.query, variables: mutation.variables })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, code: error instanceof GhUnavailableError ? 'failed' : 'unknown', message } + } + const body = parse(output.stdout) + // A payload means GitHub applied the mutation, even beside errors about the fields it selects. + const payload = z.object({ data: z.record(z.string(), z.unknown()) }).safeParse(body) + const value = payload.success ? payload.data.data[mutation.field] : undefined + if (value !== undefined && value !== null) return { ok: true, payload: value } + const errors = graphqlErrors.safeParse(body) + if (errors.success) return { ok: false, code: 'failed', message: errors.data.errors.map(error => error.message).join('; ') } + if (output.exitCode !== 0) { + const message = output.stderr.trim() || output.stdout.trim() || `gh exited with ${output.exitCode}` + return { ok: false, code: REFUSED.test(output.stderr) ? 'failed' : 'unknown', message } + } + return { ok: false, code: 'unknown', message: `unexpected GitHub response to ${mutation.field}` } +} + +function parse(text: string): unknown { + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + void error // gh prints diagnostics instead of JSON when a request fails before GitHub answers. + return undefined + } + return value +} diff --git a/packages/experimental/forge-github/src/threads.ts b/packages/experimental/forge-github/src/threads.ts index 5717f47a0c..6b1e6096b3 100644 --- a/packages/experimental/forge-github/src/threads.ts +++ b/packages/experimental/forge-github/src/threads.ts @@ -2,7 +2,8 @@ import { Buffer } from 'node:buffer' import type { - ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewThread, ForgeThreadId, + ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewId, ForgeReviewThread, + ForgeThreadId, } from '@deepseek-ai/dsh-experimental-forge' import { z } from 'zod' import type { GraphqlReader } from './commits.ts' @@ -11,7 +12,10 @@ import { checkedRepository, isCommit } from './urls.ts' /** Most nodes GitHub returns in one connection page. */ const PAGE_SIZE = 100 -const COMMENT_FIELDS = 'id state createdAt url body author { __typename login avatarUrl(size: 48) }' +/** Repository permissions that may comment on a locked conversation. */ +const WRITERS: ReadonlySet = new Set(['ADMIN', 'MAINTAIN', 'WRITE']) + +const COMMENT_FIELDS = 'id state createdAt url body viewerCanUpdate viewerCanDelete author { __typename login avatarUrl(size: 48) }' const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT_FIELDS} } }` @@ -20,15 +24,26 @@ const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { * first page of comments starts without a cursor. */ const THREAD_FIELDS = `id isOutdated isResolved path diffSide startDiffSide line startLine originalLine originalStartLine subjectType + viewerCanReply viewerCanResolve viewerCanUnresolve root: comments(first: 1) { nodes { diffHunk commit { oid } originalCommit { oid } } } ${COMMENT_PAGE.replace(', after: $cursor', '')}` const THREAD_PAGE = `pageInfo { hasNextPage endCursor } nodes { ${THREAD_FIELDS} }` -/** First thread page with the head and the viewer; variables `owner` and `name` are strings, `number` and `first` integers. */ +/** + * First thread page with the head, the viewer, whether the viewer may comment, and the viewer's pending review; variables + * `owner` and `name` are strings, `number` and `first` integers. + */ export const REVIEW_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!) { viewer { login } - repository(owner: $owner, name: $name) { pullRequest(number: $number) { id headRefOid reviewThreads(first: $first) { ${THREAD_PAGE} } } } + repository(owner: $owner, name: $name) { + isArchived viewerPermission + pullRequest(number: $number) { + id headRefOid locked + reviews(states: PENDING, first: 1) { nodes { id viewerDidAuthor commit { oid } comments { totalCount } } } + reviewThreads(first: $first) { ${THREAD_PAGE} } + } + } }` /** Following thread page with the head it was read at; variables `id` and `cursor` are strings, `first` an integer. */ @@ -62,6 +77,8 @@ const commentSchema = z.object({ createdAt: z.string(), url: z.string().nullable(), body: z.string(), + viewerCanUpdate: z.boolean(), + viewerCanDelete: z.boolean(), author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string().nullable() }).nullable(), }) @@ -79,15 +96,35 @@ const threadSchema = z.object({ originalLine: z.number().int().nullable(), originalStartLine: z.number().int().nullable(), subjectType: z.string(), + viewerCanReply: z.boolean(), + viewerCanResolve: z.boolean(), + viewerCanUnresolve: z.boolean(), root: z.object({ nodes: z.array(z.object({ diffHunk: z.string(), commit: commitSchema, originalCommit: commitSchema })) }), comments: commentPageSchema, }) const threadPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(threadSchema) }) +const pendingSchema = z.object({ + id: z.string(), + viewerDidAuthor: z.boolean(), + commit: commitSchema, + comments: z.object({ totalCount: z.number().int().nonnegative() }), +}) + const firstPageSchema = z.object({ data: z.object({ viewer: z.object({ login: z.string() }).nullable(), - repository: z.object({ pullRequest: z.object({ id: z.string(), headRefOid: z.string(), reviewThreads: threadPageSchema }) }), + repository: z.object({ + isArchived: z.boolean(), + viewerPermission: z.string().nullable(), + pullRequest: z.object({ + id: z.string(), + headRefOid: z.string(), + locked: z.boolean(), + reviews: z.object({ nodes: z.array(pendingSchema) }), + reviewThreads: threadPageSchema, + }), + }), }) }) const nextPageSchema = z.object({ data: z.object({ node: z.object({ headRefOid: z.string(), reviewThreads: threadPageSchema }) }) }) @@ -127,14 +164,20 @@ export async function readReviewThreads( if ('code' in first) return first const parsed = firstPageSchema.safeParse(first.body) if (!parsed.success) return unexpected - const pull = parsed.data.data.repository.pullRequest + const repository = parsed.data.data.repository + const pull = repository.pullRequest const moved = stale(pull.headRefOid) if (moved !== undefined) return moved let bytes = first.bytes let page = pull.reviewThreads const threads: ForgeReviewThread[] = [] const viewer = parsed.data.data.viewer?.login ?? null - const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated }) + const pending = pull.reviews.nodes.find(review => review.viewerDidAuthor) + const pendingReview = pending?.commit == null ? null + : { id: pending.id as ForgeReviewId, commit: pending.commit.oid, comments: pending.comments.totalCount } + // GitHub refuses every write to an archived repository, and comments on a locked conversation from anyone without write access. + const viewerCanReview = !repository.isArchived && (!pull.locked || WRITERS.has(repository.viewerPermission ?? '')) + const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated, pendingReview, viewerCanReview }) const cursors = new Set() for (;;) { for (const node of page.nodes.slice(0, limits.maxReviewThreads - threads.length)) { @@ -193,6 +236,8 @@ function toThread(node: ThreadNode, comments: readonly CommentNode[], head: stri ? anchor(root.originalCommit.oid, sameSide ? node.originalStartLine : null, node.originalLine) : null, diffHunk: root === undefined || root.diffHunk === '' ? null : root.diffHunk, resolved: node.isResolved, + viewerCanReply: node.viewerCanReply, + viewerCanResolve: node.isResolved ? node.viewerCanUnresolve : node.viewerCanResolve, comments: comments.map(toComment), } } @@ -209,5 +254,7 @@ function toComment(node: CommentNode): ForgeReviewComment { createdAt: node.createdAt, pending: node.state === 'PENDING', url: node.url, + viewerCanEdit: node.viewerCanUpdate, + viewerCanDelete: node.viewerCanDelete, } } diff --git a/packages/experimental/forge-github/tests/review-writes.spec.ts b/packages/experimental/forge-github/tests/review-writes.spec.ts new file mode 100644 index 0000000000..d620401e36 --- /dev/null +++ b/packages/experimental/forge-github/tests/review-writes.spec.ts @@ -0,0 +1,253 @@ +/** GitHub review thread and pending review writes check the head first, send JSON on stdin, and never retry. */ +import { describe, expect, it } from 'vitest' +import type { ForgeCommentId, ForgeReviewId, ForgeReviewWrite, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge' +import { GhTimeoutError, GhUnavailableError, type GhOutput, type GhRunner } from '../src/gh.ts' +import { GitHubProvider } from '../src/provider.ts' +import { + ADD_REVIEW_MUTATION, ADD_THREAD_MUTATION, DELETE_COMMENT_MUTATION, DELETE_REVIEW_MUTATION, EDIT_COMMENT_MUTATION, REPLY_MUTATION, + RESOLVE_MUTATION, SUBMIT_REVIEW_MUTATION, UNRESOLVE_MUTATION, WRITE_CHECK_QUERY, +} from '../src/review-writes.ts' +import { demo, pull } from './fixtures.ts' + +const head = 'c'.repeat(40) +const pushed = 'd'.repeat(40) +const signal = new AbortController().signal +const thread = 'thread-1' as ForgeThreadId +const comment = 'comment-1' as ForgeCommentId +const review = 'review-1' as ForgeReviewId + +function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput { + return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr } +} + +function checked(pending: string | null = null, headRefOid = head, viewerDidAuthor = true): GhOutput { + return output({ data: { repository: { pullRequest: { + id: 'pull-7', headRefOid, reviews: { nodes: pending === null ? [] : [{ id: pending, viewerDidAuthor }] }, + } } } }) +} + +/** One recorded `gh` call: its arguments and, for a mutation, the JSON it sent on stdin. */ +interface Call { + readonly args: readonly string[] + readonly input: { readonly query: string; readonly variables: Record } | undefined +} + +/** A mutation answer that throws `throws` instead of printing output. */ +interface Thrown { readonly throws: unknown } + +/** Answer the check with `check`, then each mutation in turn from `mutations`. */ +function fakeGh(check: GhOutput, ...mutations: Array): { run: GhRunner; calls: Call[] } { + const calls: Call[] = [] + const queue = [...mutations] + return { calls, run: async (args, _signal, input) => { + calls.push({ args: [...args], input: input === undefined ? undefined : JSON.parse(input) as Call['input'] }) + if (input === undefined) return check + const next = queue.shift() + if (next === undefined) throw new Error(`unexpected gh ${args.join(' ')}`) + if ('throws' in next) throw next.throws + return next + } } +} + +function provider(run: GhRunner): GitHubProvider { + return new GitHubProvider(run, ['github.com'], { maxOutputBytes: 8_388_608, maxReviewThreads: 1_000, maxCommits: 250 }) +} + +function payload(field: string, value: unknown = { id: 'x' }): GhOutput { + return output({ data: { [field]: value } }) +} + +const MUTATION_ARGS = ['api', 'graphql', '--hostname', 'github.com', '--input', '-'] + +describe('GitHub review writes', () => { + it('checks the head with the pull request as variables and replies at once without a pending review', async () => { + const gh = fakeGh(checked(), payload('addPullRequestReviewThreadReply', { comment: { id: 'reply-1' } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Fixed in `$(x)`', pending: false }, signal)) + .resolves.toEqual({ ok: true }) + expect(gh.calls).toEqual([ + { args: ['api', 'graphql', '--hostname', 'github.com', '-f', `query=${WRITE_CHECK_QUERY}`, '-f', `owner=${demo.owner}`, + '-f', `name=${demo.name}`, '-F', 'number=7'], input: undefined }, + { args: MUTATION_ARGS, input: { query: REPLY_MUTATION, variables: { thread, body: 'Fixed in `$(x)`', review: null } } }, + ]) + }) + + it('refuses a reply at once without a mutation while the viewer has a pending review, which GitHub would add it to', async () => { + const gh = fakeGh(checked('review-9')) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Now', pending: false }, signal)) + .resolves.toEqual({ ok: false, code: 'failed', + message: 'GitHub adds a reply to the pending review while one exists; submit or discard it first' }) + expect(gh.calls).toHaveLength(1) + }) + + it('replies inside the existing pending review', async () => { + const gh = fakeGh(checked('review-9'), payload('addPullRequestReviewThreadReply')) + + await provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Later', pending: true }, signal) + + expect(gh.calls[1]?.input).toEqual({ query: REPLY_MUTATION, variables: { thread, body: 'Later', review: 'review-9' } }) + }) + + it('starts a pending review at the head before a pending reply when the viewer has none', async () => { + const gh = fakeGh(checked('review-of-other', head, false), + payload('addPullRequestReview', { pullRequestReview: { id: 'review-new' } }), payload('addPullRequestReviewThreadReply')) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Later', pending: true }, signal)) + .resolves.toEqual({ ok: true }) + expect(gh.calls.slice(1).map(call => call.input)).toEqual([ + { query: ADD_REVIEW_MUTATION, variables: { pull: 'pull-7', commit: head, threads: null } }, + { query: REPLY_MUTATION, variables: { thread, body: 'Later', review: 'review-new' } }, + ]) + }) + + it('returns the failure of starting a pending review and an unexpected start without replying', async () => { + const write: ForgeReviewWrite = { kind: 'reply', headSha: head, thread, body: 'Later', pending: true } + const refused = fakeGh(checked(), output({ data: { addPullRequestReview: null }, errors: [{ message: 'Pull request is locked' }] }, 1)) + await expect(provider(refused.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'Pull request is locked' }) + expect(refused.calls).toHaveLength(2) + + const odd = fakeGh(checked(), payload('addPullRequestReview', { pullRequestReview: null })) + await expect(provider(odd.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'unknown', message: 'unexpected GitHub response to starting a pending review' }) + expect(odd.calls).toHaveLength(2) + }) + + it.each([ + [true, RESOLVE_MUTATION, 'resolveReviewThread'], + [false, UNRESOLVE_MUTATION, 'unresolveReviewThread'], + ] as const)('marks a thread resolved %s through its mutation', async (resolved, query, field) => { + const gh = fakeGh(checked(), payload(field, { thread: { id: thread } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved }, signal)).resolves.toEqual({ ok: true }) + expect(gh.calls[1]?.input).toEqual({ query, variables: { thread } }) + }) + + it('edits and deletes a comment', async () => { + const edit = fakeGh(checked(), payload('updatePullRequestReviewComment')) + await provider(edit.run).writeReview(pull(7), { kind: 'edit-comment', headSha: head, comment, body: 'Reworded' }, signal) + expect(edit.calls[1]?.input).toEqual({ query: EDIT_COMMENT_MUTATION, variables: { comment, body: 'Reworded' } }) + + const remove = fakeGh(checked(), payload('deletePullRequestReviewComment', { clientMutationId: null })) + await expect(provider(remove.run).writeReview(pull(7), { kind: 'delete-comment', headSha: head, comment }, signal)).resolves.toEqual({ ok: true }) + expect(remove.calls[1]?.input).toEqual({ query: DELETE_COMMENT_MUTATION, variables: { comment } }) + }) + + it('starts a pending review at the head holding a new comment when the viewer has none', async () => { + const gh = fakeGh(checked(), payload('addPullRequestReview', { pullRequestReview: { id: 'review-new' } })) + + await provider(gh.run).writeReview(pull(7), { kind: 'add-pending', headSha: head, + comment: { path: 'src/a.ts', side: 'L', startLine: 3, endLine: 5, body: 'Range' } }, signal) + + expect(gh.calls[1]?.input).toEqual({ query: ADD_REVIEW_MUTATION, variables: { pull: 'pull-7', commit: head, threads: [ + { path: 'src/a.ts', body: 'Range', line: 5, side: 'LEFT', startLine: 3, startSide: 'LEFT' }, + ] } }) + }) + + it('adds a new single-line comment to the existing pending review', async () => { + const gh = fakeGh(checked('review-9'), payload('addPullRequestReviewThread', { thread: { id: 'thread-new' } })) + + await provider(gh.run).writeReview(pull(7), { kind: 'add-pending', headSha: head, + comment: { path: 'src/a.ts', side: 'R', startLine: 8, endLine: 8, body: 'Line' } }, signal) + + expect(gh.calls[1]?.input).toEqual({ query: ADD_THREAD_MUTATION, variables: { + review: 'review-9', path: 'src/a.ts', body: 'Line', line: 8, side: 'RIGHT', startLine: null, startSide: null, + } }) + }) + + it.each([ + ['comment', 'COMMENT'], ['approve', 'APPROVE'], ['request-changes', 'REQUEST_CHANGES'], + ] as const)('submits the pending review as a %s review', async (event, graphqlEvent) => { + const gh = fakeGh(checked(review), payload('submitPullRequestReview', { pullRequestReview: { id: review } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'submit-pending', headSha: head, review, event, body: 'Summary' }, signal)) + .resolves.toEqual({ ok: true }) + expect(gh.calls[1]?.input).toEqual({ query: SUBMIT_REVIEW_MUTATION, variables: { review, event: graphqlEvent, body: 'Summary' } }) + }) + + it('discards the pending review', async () => { + const gh = fakeGh(checked(review), payload('deletePullRequestReview', { pullRequestReview: { id: review } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'discard-pending', headSha: head, review }, signal)).resolves.toEqual({ ok: true }) + expect(gh.calls[1]?.input).toEqual({ query: DELETE_REVIEW_MUTATION, variables: { review } }) + }) + + it.each([ + ['submit', { kind: 'submit-pending', headSha: head, review, event: 'comment', body: '' }], + ['discard', { kind: 'discard-pending', headSha: head, review }], + ] as const)('refuses to %s a pending review that is no longer the viewer\'s without a mutation', async (_label, write) => { + for (const pending of [null, 'review-other']) { + const gh = fakeGh(checked(pending)) + await expect(provider(gh.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'the pending review review-1 is no longer the signed-in user\'s pending review' }) + expect(gh.calls).toHaveLength(1) + } + }) + + it('refuses a moved head without a mutation and accepts a head in another case', async () => { + const write: ForgeReviewWrite = { kind: 'resolve', headSha: head, thread, resolved: true } + const moved = fakeGh(checked(null, pushed)) + await expect(provider(moved.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }) + expect(moved.calls).toHaveLength(1) + + const upper = fakeGh(checked(null, head.toUpperCase()), payload('resolveReviewThread')) + await expect(provider(upper.run).writeReview(pull(7), write, signal)).resolves.toEqual({ ok: true }) + }) + + it('reports a failed or unexpected check as failed without a mutation', async () => { + const write: ForgeReviewWrite = { kind: 'resolve', headSha: head, thread, resolved: true } + const down = fakeGh(output('', 1, 'gh: Server Error (HTTP 502)')) + await expect(provider(down.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'gh: Server Error (HTTP 502)' }) + + const odd = fakeGh(output({ data: { repository: { pullRequest: null } } })) + await expect(provider(odd.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'unexpected GitHub response to the pull request check' }) + expect([down.calls, odd.calls].map(calls => calls.length)).toEqual([1, 1]) + }) + + it.each([ + ['a gh that cannot start', { throws: new GhUnavailableError('gh could not start: ENOENT') }, 'failed', 'gh could not start: ENOENT'], + ['a gh that times out', { throws: new GhTimeoutError('gh api timed out after 30000ms') }, 'unknown', 'gh api timed out after 30000ms'], + ['a thrown value that is not an error', { throws: 'lost' }, 'unknown', 'lost'], + ['GraphQL errors', output({ data: { resolveReviewThread: null }, errors: [{ message: 'one' }, { message: 'two' }] }, 1), 'failed', 'one; two'], + ['an HTTP refusal', output('', 1, 'gh: Validation Failed (HTTP 422)'), 'failed', 'gh: Validation Failed (HTTP 422)'], + ['a server error', output('', 1, 'gh: Server Error (HTTP 502)'), 'unknown', 'gh: Server Error (HTTP 502)'], + ['a failure printed on stdout', output('connection reset', 1), 'unknown', 'connection reset'], + ['a failure without output', output('', 1), 'unknown', 'gh exited with 1'], + ['an answer that is not JSON', output(''), 'unknown', 'unexpected GitHub response to resolveReviewThread'], + ['an empty payload', payload('resolveReviewThread', null), 'unknown', 'unexpected GitHub response to resolveReviewThread'], + ] as const)('reports a mutation ending in %s as %s', async (_label, answer, code, message) => { + const gh = fakeGh(checked(), answer) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved: true }, signal)) + .resolves.toEqual({ ok: false, code, message }) + }) + + it('treats a payload beside errors about its fields as applied', async () => { + const gh = fakeGh(checked(), output({ data: { resolveReviewThread: { thread: null } }, errors: [{ message: 'thread is not visible' }] })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved: true }, signal)) + .resolves.toEqual({ ok: true }) + }) + + it('throws the abort reason when the signal aborts before a mutation is sent', async () => { + const controller = new AbortController() + const reason = new Error('closed') + const run: GhRunner = async () => { controller.abort(reason); return checked() } + + await expect(provider(run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved: true }, controller.signal)).rejects.toBe(reason) + }) + + it('refuses an unconfigured host without starting gh and a head that is not a commit object name', async () => { + const gh = fakeGh(checked()) + const write: ForgeReviewWrite = { kind: 'resolve', headSha: head, thread, resolved: true } + + await expect(provider(gh.run).writeReview(pull(7, { ...demo, host: 'ghe.other.example' }), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'forge-github: host "ghe.other.example" is not configured' }) + await expect(provider(gh.run).writeReview(pull(7), { ...write, headSha: 'main' }, signal)).rejects.toThrow('forge-github: invalid head commit "main"') + expect(gh.calls).toEqual([]) + }) +}) diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts index 42c49f389a..1e4fc80037 100644 --- a/packages/experimental/forge-github/tests/threads.spec.ts +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -32,6 +32,7 @@ function page(nodes: readonly unknown[], next: string | null = null): Record = {}): Record { return { id, state: 'SUBMITTED', createdAt: '2026-11-05T08:00:00Z', url: `https://github.com/octo/demo/pull/7#discussion_${id}`, body: `Body of ${id}`, + viewerCanUpdate: false, viewerCanDelete: false, author: { __typename: 'User', login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4' }, ...overrides, } @@ -42,14 +43,20 @@ function threadNode(id: string, overrides: Record = {}, root: R return { id, isOutdated: false, isResolved: false, path: 'src/a.ts', diffSide: 'RIGHT', startDiffSide: null, line: 12, startLine: null, originalLine: 12, originalStartLine: null, subjectType: 'LINE', + viewerCanReply: true, viewerCanResolve: true, viewerCanUnresolve: false, root: { nodes: [{ diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', commit: { oid: head }, originalCommit: { oid: head }, ...root }] }, comments: page([commentNode(`${id}-1`)]), ...overrides, } } -function firstPage(threads: Record, headRefOid = head, viewer: unknown = { login: 'octo' }): Record { - return { data: { viewer, repository: { pullRequest: { id: 'pull-7', headRefOid, reviewThreads: threads } } } } +function firstPage( + threads: Record, headRefOid = head, viewer: unknown = { login: 'octo' }, pull: Record = {}, + repository: Record = {}, +): Record { + return { data: { viewer, repository: { isArchived: false, viewerPermission: 'READ', ...repository, pullRequest: { + id: 'pull-7', headRefOid, locked: false, reviews: { nodes: [] }, reviewThreads: threads, ...pull, + } } } } } function nextPage(threads: Record, headRefOid = head): Record { @@ -84,16 +91,21 @@ describe('GitHub provider review threads', () => { headSha: head, viewer: 'octo', truncated: false, + pendingReview: null, + viewerCanReview: true, threads: [{ id: 't1', path: 'src/a.ts', side: 'R', subject: 'line', current: { commit: head, startLine: 12, endLine: 12 }, original: { commit: head, startLine: 12, endLine: 12 }, diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', resolved: true, + viewerCanReply: true, + viewerCanResolve: false, comments: [{ id: 't1-1', body: 'Body of t1-1', createdAt: '2026-11-05T08:00:00Z', pending: false, url: 'https://github.com/octo/demo/pull/7#discussion_t1-1', author: { login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4', bot: false }, + viewerCanEdit: false, viewerCanDelete: false, }], }], }) @@ -161,6 +173,51 @@ describe('GitHub provider review threads', () => { expect(result).toMatchObject({ threads: { length: 1 } }) }) + it('maps the permissions of the viewer on threads and comments, resolving an unresolved thread and unresolving a resolved one', async () => { + const gh = review(firstPage(page([ + threadNode('open', { viewerCanReply: false, viewerCanResolve: true, viewerCanUnresolve: false, comments: page([ + commentNode('mine', { viewerCanUpdate: true, viewerCanDelete: true }), + ]) }), + threadNode('settled', { isResolved: true, viewerCanResolve: false, viewerCanUnresolve: true }), + ]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [ + { id: 'open', viewerCanReply: false, viewerCanResolve: true, comments: [{ id: 'mine', viewerCanEdit: true, viewerCanDelete: true }] }, + { id: 'settled', viewerCanReply: true, viewerCanResolve: true }, + ] }) + }) + + it('reports the pending review the viewer authored with its commit and comment count', async () => { + const reviews = { nodes: [{ id: 'review-1', viewerDidAuthor: true, commit: { oid: older }, comments: { totalCount: 2 } }] } + const gh = review(firstPage(page([]), head, { login: 'octo' }, { reviews })) + + await expect(provider(gh.run).readReview(pull(7), head, signal)) + .resolves.toMatchObject({ pendingReview: { id: 'review-1', commit: older, comments: 2 } }) + }) + + it.each([ + ['another user authored', { id: 'review-2', viewerDidAuthor: false, commit: { oid: head }, comments: { totalCount: 1 } }], + ['names no commit', { id: 'review-3', viewerDidAuthor: true, commit: null, comments: { totalCount: 1 } }], + ] as const)('reports no pending review for one %s', async (_label, node) => { + const gh = review(firstPage(page([]), head, { login: 'octo' }, { reviews: { nodes: [node] } })) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ pendingReview: null }) + }) + + it.each([ + ['an archived repository', {}, { isArchived: true, viewerPermission: 'ADMIN' }, false], + ['a locked conversation without write access', { locked: true }, { viewerPermission: 'TRIAGE' }, false], + ['a locked conversation without a known permission', { locked: true }, { viewerPermission: null }, false], + ['a locked conversation with write access', { locked: true }, { viewerPermission: 'WRITE' }, true], + ['an open conversation with read access', {}, { viewerPermission: 'READ' }, true], + ] as const)('reports whether the viewer can review in %s', async (_label, pullFields, repository, expected) => { + const gh = review(firstPage(page([]), head, { login: 'octo' }, pullFields, repository)) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ viewerCanReview: expected }) + }) + it('reads the following thread and comment pages, asking only for the threads left under maxReviewThreads', async () => { const gh = review( firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')), @@ -198,7 +255,7 @@ describe('GitHub provider review threads', () => { const byComments = review(first, threads, comments) await expect(provider(byComments.run, { maxOutputBytes: size + 10 }).readReview(pull(7), head, signal)) - .resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true }) + .resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true, pendingReview: null, viewerCanReview: true }) const commentBytes = JSON.stringify(comments['comments-1']).length const byThreads = review(first, threads, comments) diff --git a/packages/experimental/forge-github/tsconfig.json b/packages/experimental/forge-github/tsconfig.json index fea1cc6ec1..ff2c3474bc 100644 --- a/packages/experimental/forge-github/tsconfig.json +++ b/packages/experimental/forge-github/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../util/values" } ] } diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index 88068c3aee..1373810e80 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -8,7 +8,7 @@ import { nextPollDelay } from './schedule.ts' import type { ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, ForgeCommitList, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, - MergeRequest, + ForgeReviewWrite, ForgeReviewWriteResult, MergeRequest, } from './types.ts' export { changeRequestKey } from './keys.ts' @@ -253,7 +253,7 @@ export class ForgeService extends Service { async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { const result = await this.invoke(this.registration(ref.repository.forge), signal, (provider, operation) => provider.diff(ref, headSha, operation)) - if (typeof result !== 'string') this.staleRead(ref, headSha, result) + if (typeof result !== 'string') this.staleRead(ref, headSha, result.code) return result } @@ -270,7 +270,26 @@ export class ForgeService extends Service { const result = await this.invoke(this.registration(ref.repository.forge), signal, (provider, operation) => provider.readReview?.(ref, headSha, operation) ?? Promise.resolve({ code: 'unsupported', message: 'This provider does not support review reads' } as const)) - if ('code' in result) this.staleRead(ref, headSha, result) + if ('code' in result) this.staleRead(ref, headSha, result.code) + return result + } + + /** + * Write to the review threads or the pending review of a change request while its head is still `write.headSha`, then + * poll the change request at once when it is watched. A `stale` result polls it as a stale read does. + * @param ref - change request served by a registered provider. + * @param write - the write and the head commit the user saw. + * @param signal - cancellation of the write. + * @returns whether the host took the write; `unsupported` when the provider cannot write reviews, and `unknown` when the + * outcome must be checked on the host before the write is tried again. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ + async writeReview(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise { + const result = await this.invoke(this.registration(ref.repository.forge), signal, + (provider, operation) => provider.writeReview?.(ref, write, operation) + ?? Promise.resolve({ ok: false, code: 'unsupported', message: 'This provider does not support review writes' } as const)) + if (!result.ok && result.code === 'stale') this.staleRead(ref, write.headSha, result.code) + else this.refresh(ref) return result } @@ -398,10 +417,10 @@ export class ForgeService extends Service { return this.watches.get(changeRequestKey(ref))?.latest } - /** Poll a watched change request at once after the first `stale` read at `headSha`; later ones wait for the regular poll. */ - private staleRead(ref: ChangeRequestRef, headSha: string, failure: ForgeFailure): void { + /** Poll a watched change request at once after the first `stale` read or write at `headSha`; later ones wait for the regular poll. */ + private staleRead(ref: ChangeRequestRef, headSha: string, code: ForgeFailure['code']): void { const watch = this.watches.get(changeRequestKey(ref)) - if (failure.code !== 'stale' || watch === undefined || watch.staleHead === headSha) return + if (code !== 'stale' || watch === undefined || watch.staleHead === headSha) return watch.staleHead = headSha this.refresh(ref) } diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 71bd7270e0..6ef6526059 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -160,7 +160,7 @@ export type ForgeReviewRequest = { readonly headSha: string } & ( | { readonly kind: 'comment'; readonly comment: ForgeLineComment } - | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } + | { readonly kind: 'review'; readonly event: ForgeReviewEvent; readonly body: string; readonly comments: readonly ForgeLineComment[] } ) /** Opaque host identity of one review thread. */ @@ -169,6 +169,9 @@ export type ForgeThreadId = Branded<'ForgeThreadId'> /** Opaque host identity of one review comment. */ export type ForgeCommentId = Branded<'ForgeCommentId'> +/** Opaque host identity of one review. */ +export type ForgeReviewId = Branded<'ForgeReviewId'> + /** Consecutive lines of one file at one commit. */ export interface ForgeAnchor { /** Full object name of the commit whose file the lines number. */ @@ -203,6 +206,10 @@ export interface ForgeReviewComment { readonly pending: boolean /** Web page of the comment, or null when the host reports none. */ readonly url: string | null + /** Whether the signed-in user can replace the comment's text through `writeReview`; false for a provider without it. */ + readonly viewerCanEdit: boolean + /** Whether the signed-in user can delete the comment through `writeReview`; false for a provider without it. */ + readonly viewerCanDelete: boolean } /** @@ -227,10 +234,27 @@ export interface ForgeReviewThread { readonly diffHunk: string | null /** Whether the thread is marked resolved. */ readonly resolved: boolean + /** Whether the signed-in user can reply to the thread through `writeReview`; false for a provider without it. */ + readonly viewerCanReply: boolean + /** + * Whether the signed-in user can mark the thread resolved, or unresolved when `resolved`, through `writeReview`; false + * for a provider without it. + */ + readonly viewerCanResolve: boolean /** Comments oldest first; the first starts the thread. */ readonly comments: readonly ForgeReviewComment[] } +/** The signed-in user's review of a change request that is not submitted yet, which only that user sees. */ +export interface ForgePendingReview { + /** Host identity of the review. */ + readonly id: ForgeReviewId + /** Full object name of the commit the review addresses. */ + readonly commit: string + /** Comments the review holds. */ + readonly comments: number +} + /** Review threads of a change request read at one head commit. */ export interface ForgeReviewData { /** Head commit the threads were read at; `current` positions number its files. */ @@ -241,8 +265,44 @@ export interface ForgeReviewData { readonly threads: readonly ForgeReviewThread[] /** Whether the change request has more threads than `threads` holds. */ readonly truncated: boolean + /** The signed-in user's pending review, or null without one. */ + readonly pendingReview: ForgePendingReview | null + /** + * Whether the signed-in user can add comments to a pending review and submit or discard it through `writeReview`; + * false for a provider without it. + */ + readonly viewerCanReview: boolean } +/** Decision a submitted review records. */ +export type ForgeReviewEvent = 'comment' | 'approve' | 'request-changes' + +/** + * One write to the review threads and the pending review of a change request. `pending` replies and `add-pending` + * comments join the signed-in user's pending review, which is started when the user has none. A host that adds every + * reply to an existing pending review, as GitHub does, refuses a reply with `pending: false` while one exists. + */ +export type ForgeReviewWrite = { + /** Head commit the user saw; providers check it before writing and refuse with `stale` once the head is another commit. */ + readonly headSha: string +} & ( + | { readonly kind: 'reply'; readonly thread: ForgeThreadId; readonly body: string; readonly pending: boolean } + | { readonly kind: 'resolve'; readonly thread: ForgeThreadId; readonly resolved: boolean } + | { readonly kind: 'edit-comment'; readonly comment: ForgeCommentId; readonly body: string } + /** Deleting the only comment of a thread deletes the thread. */ + | { readonly kind: 'delete-comment'; readonly comment: ForgeCommentId } + /** A comment on lines of `headSha`. */ + | { readonly kind: 'add-pending'; readonly comment: ForgeLineComment } + | { readonly kind: 'submit-pending'; readonly review: ForgeReviewId; readonly event: ForgeReviewEvent; readonly body: string } + /** Delete the pending review with every comment it holds. */ + | { readonly kind: 'discard-pending'; readonly review: ForgeReviewId } +) + +/** Result of a review write; an `unknown` outcome must be checked on the host before the write is tried again. */ +export type ForgeReviewWriteResult = + | { readonly ok: true } + | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } + /** Result of a review write; unknown outcomes must be checked on the host before another submission. */ export type ForgeReviewResult = | { readonly ok: true; readonly url: string } @@ -306,6 +366,12 @@ export interface ForgeProvider { * Omission declares that this provider cannot read reviews. */ readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** + * Write to the review threads or the pending review of a change request while its head is `write.headSha`; once the + * head is another commit, the write resolves to `stale` without writing. A write is never retried. Omission declares + * that this provider cannot write reviews, and its `readReview` then reports no viewer permission. + */ + writeReview?(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index a87103be4e..b9d24f6338 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' -import type { ForgeReviewData } from '../src/types.ts' +import type { ForgeReviewData, ForgeReviewWrite, ForgeThreadId } from '../src/types.ts' import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' let ctx: Context | undefined @@ -134,13 +134,30 @@ describe('forge registry', () => { await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)) .resolves.toEqual({ code: 'unsupported', message: 'This provider does not support review reads' }) - const review: ForgeReviewData = { headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false } + const review: ForgeReviewData = { + headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false, pendingReview: null, viewerCanReview: false, + } const readReview = vi.fn(async () => review) Object.assign(provider, { readReview }) await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)).resolves.toBe(review) expect(readReview).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), expect.any(AbortSignal)) }) + it('delegates review writes to the provider of their forge and reports a provider without them as unsupported', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + await provide(ctx, provider) + const signal = new AbortController().signal + const write: ForgeReviewWrite = { kind: 'resolve', headSha: 'a'.repeat(40), thread: 'thread-1' as ForgeThreadId, resolved: true } + + await expect(ctx.forge.writeReview(ref(3), write, signal)) + .resolves.toEqual({ ok: false, code: 'unsupported', message: 'This provider does not support review writes' }) + const writeReview = vi.fn(async () => ({ ok: true as const })) + Object.assign(provider, { writeReview }) + await expect(ctx.forge.writeReview(ref(3), write, signal)).resolves.toEqual({ ok: true }) + expect(writeReview).toHaveBeenCalledWith(ref(3), write, expect.any(AbortSignal)) + }) + it('delegates change request actions to the provider of their forge and returns its outcome', async () => { ctx = await forgeContext() const provider = new FakeProvider() diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 5903fb42fa..43dc792c07 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -1,7 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' -import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository, ForgeReviewData } from '../src/types.ts' +import type { + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository, ForgeReviewData, + ForgeReviewWrite, ForgeReviewWriteResult, ForgeThreadId, +} from '../src/types.ts' import { FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' const config = { activeIntervalMs: 1_000, idleIntervalMs: 5_000, maxBackoffMs: 6_000, fetchTimeoutMs: 500 } @@ -289,6 +292,33 @@ describe('forge watch', () => { expect(provider.fetches).toHaveLength(2) }) + it('polls at once after every review write, and after a stale one only once per head', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + const writeReview = vi.fn(async (): Promise => ({ ok: true })) + Object.assign(provider, { writeReview }) + const context = await setup(provider) + collect(context) + await vi.advanceTimersByTimeAsync(0) + const signal = new AbortController().signal + const write: ForgeReviewWrite = { kind: 'reply', headSha: 'a'.repeat(40), thread: 'thread-1' as ForgeThreadId, body: 'Done', pending: false } + + await expect(context.forge.writeReview(ref(1), write, signal)).resolves.toEqual({ ok: true }) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(2) + writeReview.mockResolvedValue({ ok: false, code: 'unknown', message: 'connection reset' }) + await context.forge.writeReview(ref(1), write, signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(3) + writeReview.mockResolvedValue({ ok: false, code: 'stale', message: 'the head moved' }) + await expect(context.forge.writeReview(ref(1), write, signal)).resolves.toEqual({ ok: false, code: 'stale', message: 'the head moved' }) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(4) + await context.forge.writeReview(ref(1), write, signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(4) + }) + it('coalesces refreshes during a fetch into one immediate subsequent fetch', async () => { const provider = new FakeProvider() const release = held(provider, target => snapshot(target)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a2b3cbd92..f6b6836d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10172,6 +10172,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery From b1aa940b0fa92b870ff91539cea9ba23480b53f5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 9 Nov 2026 11:21:15 +0800 Subject: [PATCH 134/149] feat(forge-panel): reply to, resolve, and edit review threads and submit the pending review Thread cards gain a reply field, Resolve and Unresolve, and edit and delete for the viewer's own comments. Start review and Add to review add pull request comments to the GitHub pending review, which Submit review submits or discards; while one exists, replies join it and no single comment is offered, as GitHub refuses one. On the viewer's own pull request the dialog offers only Comment. Comment drafts an earlier panel kept in the browser stay editable, can move into the pending review, and block submission while one exists. --- .../client-ui-forge/src/client/ChangesTab.tsx | 113 +++++++- .../src/client/ReviewComposer.tsx | 53 ++-- .../src/client/ReviewThread.tsx | 31 ++- .../src/client/ReviewThreads.module.css | 78 ++++++ .../src/client/ReviewThreads.tsx | 177 +++++++++++- .../src/client/SubmitReview.module.css | 3 + .../src/client/SubmitReview.tsx | 51 +++- .../client-ui-forge/src/client/locales.ts | 184 +++++++++++++ .../client-ui-forge/src/client/mount.ts | 3 +- .../src/client/review-queue.ts | 15 +- .../src/client/thread-state.ts | 6 +- .../tests/changes-tab.client.spec.tsx | 259 ++++++++++++++++-- .../tests/mount.client.spec.ts | 15 +- .../tests/review-thread-cards.client.spec.tsx | 174 +++++++++++- .../tests/review.client.spec.tsx | 190 ++++++++++--- .../tests/thread-fixtures.client.ts | 9 +- 16 files changed, 1214 insertions(+), 147 deletions(-) diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 0747a901bc..91ef3242e7 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -22,13 +22,14 @@ import { import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { SidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { assertNever } from '@deepseek-ai/dsh-util-values' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeCommitRange, ForgeDiffFile, ForgeLocalCommits, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, } from '../types.ts' import type { - ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, + ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, ForgeReviewWrite, ForgeReviewWriteResult, } from '@deepseek-ai/dsh-experimental-forge/types' import { sameReviewSource, type ReviewQueue } from './review-queue.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' @@ -49,7 +50,7 @@ import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' import { ChangeRequestGlyph } from './glyphs.tsx' import type { ForgePanelKey, NS } from './locales.ts' import { RefreshGlyph } from './RefreshGlyph.tsx' -import type { FileThreads, ThreadActions, ThreadFocus } from './ReviewThreads.tsx' +import type { FileThreads, ThreadActions, ThreadFocus, ThreadWrite } from './ReviewThreads.tsx' import { inFileOrder, type ThreadFilter } from './review-threads.ts' import { usePlacedThreads, useThreadRead, type PlacedThread } from './thread-state.ts' import { ThreadList } from './ThreadList.tsx' @@ -94,6 +95,8 @@ export interface ChangesTabInjected { readonly changeRequestText: (url: string, commit: string, path: string) => Promise> /** Read the review threads of a change request at the head of its latest status; `signal` aborts the read. */ readonly changeRequestReview: (url: string, headSha: string, signal: AbortSignal) => Promise> + /** Write to the review threads or the pending review of a change request; never retries a write. */ + readonly changeRequestReviewWrite: (url: string, write: ForgeReviewWrite) => Promise> /** Quote a whole review thread of the change request at `url` in the main input without submitting it. */ readonly addThreadToInput: (thread: ForgeReviewThread, url: string, range: string | null) => boolean /** Open a URL outside the app. */ @@ -433,7 +436,7 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { */ export function ChangesTab({ useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, localCommits, changeRequestDiff, - changeRequestText, changeRequestCommits, changeRequestReview, viewedFiles, + changeRequestText, changeRequestCommits, changeRequestReview, changeRequestReviewWrite, viewedFiles, openLink, copyPath, createChangeRequest, addReviewToInput, addThreadToInput, reviewQueue, submitReview, report, t, }: ChangesTabProps): ReactNode { const { tab } = useTabInfo() @@ -482,6 +485,40 @@ export function ChangesTab({ setReviewBusy(false) } } + // Every write that may have changed the threads reads them again; writes run one at a time, so a write that starts a + // pending review settles before the next write looks for one. + const [reviewRevision, setReviewRevision] = useState(0) + const writing = useRef>(Promise.resolve()) + const writeReview = (url: string, write: ForgeReviewWrite): Promise => { + const run = async (): Promise => { + const response = await changeRequestReviewWrite(url, write) + if (!response.ok) { + if (response.error.code === 'forgePanel/not-bound') return t('thread.writeFailed', { message: response.error.message }) + setReviewRevision(value => value + 1) + return t('thread.writeUnknown') + } + const result = response.value + if (result.ok) { + setReviewRevision(value => value + 1) + return undefined + } + switch (result.code) { + case 'unsupported': return t('thread.writeUnsupported') + case 'failed': + setReviewRevision(value => value + 1) + return t('thread.writeFailed', { message: result.message }) + case 'stale': return t('thread.writeStale') + case 'unknown': + setReviewRevision(value => value + 1) + return t('thread.writeUnknown') + /* v8 ignore next -- closed ForgeReviewWriteResult code union exhaustiveness guard */ + default: return assertNever(result.code, 'ForgeReviewWriteResult.code') + } + } + const next = writing.current.then(run, run) + writing.current = next + return next + } const choices = useStore(store => store.byTab[tab.id]) const [wanted, setWanted] = useState(null) const [treeState, setTreeState] = useState({ query: '', collapsed: {}, selected: null }) @@ -575,7 +612,7 @@ export function ChangesTab({ const prHead = entry?.snapshot?.headSha ?? null const prAuthor = entry?.snapshot?.author ?? null const threadRead = useThreadRead({ visible: tab.visible, url: source.kind === 'change' ? source.url : null, head: prHead, - updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, read: changeRequestReview }) + updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, revision: reviewRevision, read: changeRequestReview }) const review = data?.kind === 'change' && threadRead.review !== null && threadRead.review.headSha === prHead && (data.commits !== null || data.headSha === prHead) ? threadRead.review : null const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) @@ -689,16 +726,61 @@ export function ChangesTab({ return list } - // A review addresses the head commit, so it is submitted from the view of the whole change request. + // A review addresses the head commit, so it is submitted from the view of the whole change request. A pending review on + // the code host is submitted or discarded there; without one, the summary and any comment drafts kept in the browser + // are published as one review. + const pendingReview = review?.pendingReview ?? null + // Logins compare without case, as GitHub's do. + const ownChangeRequest = review?.viewer != null && prAuthor !== null && review.viewer.toLowerCase() === prAuthor.toLowerCase() + const finishWrite = async (send: () => Promise, done: ForgePanelKey): Promise => { + if (reviewRunning.current) return + reviewRunning.current = true + setReviewBusy(true) + setReviewError(null) + try { + const failure = await send() + if (failure !== undefined) { + setReviewError(failure) + return + } + report(done) + setReviewOpen(false) + } finally { + reviewRunning.current = false + setReviewBusy(false) + } + } const reviewForm = tab.visible && data !== null && reviewOpen && data.kind === 'change' && data.commits === null && { setReviewOpen(false) }} + url={data.url} headSha={data.headSha} pending={pendingReview} own={ownChangeRequest} busy={reviewBusy} error={reviewError} + close={() => { setReviewOpen(false) }} check={() => { openLink(data.url) }} reconcile={() => { queue.finish(false); setReviewError(null) }} submit={() => { const latest = queue.getSnapshot() - void publishReview(data.url, { kind: 'review', headSha: data.headSha, event: latest.event, - body: latest.body, comments: latest.comments.map(item => item.comment) }, url => queue.finish(true, url)) + if (pendingReview === null) { + void publishReview(data.url, { kind: 'review', headSha: data.headSha, event: latest.event, + body: latest.body, comments: latest.comments.map(item => item.comment) }, url => queue.finish(true, url)) + return + } + void finishWrite(async () => { + const failure = await writeReview(data.url, { kind: 'submit-pending', headSha: data.headSha, review: pendingReview.id, + event: latest.event, body: latest.body }) + if (failure === undefined && !queue.finish(true)) report('review.saveFailed') + return failure + }, 'review.submitted') + }} + discard={pendingReview === null ? undefined : () => { + void finishWrite(() => writeReview(data.url, { kind: 'discard-pending', headSha: data.headSha, review: pendingReview.id }), 'review.discarded') }} t={t} /> + /** Add a comment to the pending review on the code host at the head the comment was selected on. */ + const addPending = (selection: ReviewSelection): Promise => { + if (selection.source.kind !== 'change') return Promise.resolve(t('review.stale')) + const { path, side, startLine, endLine, body } = selection.comment + return writeReview(selection.source.url, { kind: 'add-pending', headSha: selection.source.headSha, comment: { path, side, startLine, endLine, body } }) + } + // Only the whole change request takes new comments for its pending review, once the threads say the user may review. + const pendingComments = data?.kind === 'change' && data.commits === null && review?.viewerCanReview === true ? { queue: addPending } : {} + const recordActions: ReviewActions = { source: shownSource ?? (source.kind === 'local' ? { kind: 'local', scope, base: null, baseRef: baseRefOf(localScope), commits: commitsOf(localScope) } @@ -718,6 +800,11 @@ export function ChangesTab({ readText: (commit, path) => changeRequestText(data.url, commit, path), inputFailed: () => { report('thread.inputFailed') }, focused: focus, + writes: { + write: (write: ThreadWrite) => writeReview(data.url, { ...write, headSha: review.headSha }), + reviewStarted: review.pendingReview !== null, + canReview: review.viewerCanReview, + }, } const threadsOf = (path: string): FileThreads | undefined => { const group = placed.files.get(path) @@ -869,11 +956,12 @@ export function ChangesTab({ file={file} review={{ source: reviewSourceOf(data), addToInput: addReviewToInput, - records: queue, openLink, reviewStarted: reviewState.comments.length > 0, + records: queue, openLink, reviewStarted: pendingReview !== null, onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') }, publishingBlocked: reviewState.uncertain !== null, - ...data.kind === 'local' || data.commits !== null ? {} : { - queue: selection => queue.add(selection), + ...pendingComments, + // GitHub refuses a comment published on its own while the user has a pending review. + ...data.kind === 'local' || data.commits !== null || pendingReview !== null ? {} : { publish: selection => selection.source.kind === 'change' ? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, url => queue.published(selection, url)) @@ -939,7 +1027,8 @@ export function ChangesTab({ ? { actions.based(tab.id, next) }} commitMenu={commitMenu} createChangeRequest={createChangeRequest} t={t} /> - : { setReviewOpen(true) }} t={t} />} {!reviewOpen && reviewState.error !== null && {t(reviewState.error === 'load' ? 'review.loadFailed' : 'review.saveFailed')}} {!reviewOpen && reviewState.uncertain !== null && !reviewBusy && source.kind === 'change' &&
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx index 94407dfee8..82e482c35f 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx @@ -15,7 +15,7 @@ export interface ReviewActions { readonly source: ReviewSource /** Browser records shared by the changes tab. */ readonly records?: ReviewQueue - /** Whether another comment already started a pending review. */ + /** Whether the signed-in user has a pending review on the code host that the next comment joins. */ readonly reviewStarted?: boolean /** Open the confirmed publication URL. */ readonly openLink?: (url: string) => void @@ -27,26 +27,32 @@ export interface ReviewActions { readonly onQuoted?: (selection: ReviewSelection) => void /** Publish one comment; an error string preserves the editor. */ readonly publish?: (selection: ReviewSelection) => Promise - /** Keep one comment for the final review; false preserves the editor. */ - readonly queue?: (selection: ReviewSelection) => boolean + /** Add one comment to the pending review on the code host, starting one when needed; an error string preserves the editor. */ + readonly queue?: (selection: ReviewSelection) => Promise } -function GitHubAction({ disabled, busy, started, queue, publish, t }: PropsLocale & { +/** + * Render a primary action with an optional second action in its menu. + * @param props - the main action, the optional menu action, the menu's name, and availability. + * @returns the split button. + */ +export function SplitAction({ disabled, label, run, menuLabel, menu, other }: { readonly disabled: boolean - readonly busy: boolean - readonly started: boolean - readonly queue: () => void - readonly publish: (() => void) | undefined + readonly label: string + readonly run: () => void + /** Accessible name of the menu button. */ + readonly menuLabel: string + /** Label of the action in the menu, or undefined without one. */ + readonly other: string | undefined + readonly menu: () => void }): ReactNode { const [open, setOpen] = useState(false) return
- - {publish !== undefined && { setOpen(false) }} + + {other !== undefined && { setOpen(false) }} anchor={}> - { setOpen(false); publish() }}>{t('review.publish')} + aria-label={menuLabel} onClick={() => { setOpen(value => !value) }}>}> + { setOpen(false); menu() }}>{other} }
} @@ -73,13 +79,14 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli useEffect(() => { input.current?.focus() }, []) const comment = (): ReviewSelection => ({ ...selection, comment: { ...selection.comment, body: body.current } }) const range = reviewRange(selection.comment) - const publish = async (): Promise => { - if (running.current || actions.publish === undefined) return + /** Send the comment to the code host, closing the form once it is taken and keeping its text otherwise. */ + const send = async (destination: ((selection: ReviewSelection) => Promise) | undefined): Promise => { + if (running.current || destination === undefined) return running.current = true setBusy(true) setError(null) try { - const failure = await actions.publish(comment()) + const failure = await destination(comment()) if (failure === undefined) close() else setError(failure) } catch (_error) { @@ -108,14 +115,12 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli if (actions.addToInput(selected)) { actions.onQuoted?.(selected); close() } else setError(t('review.inputFailed')) }}>{t('review.addToInput')} - {visible && actions.queue !== undefined && { - if (actions.queue?.(comment()) === true) close() - else setError(t('review.saveFailed')) - }} publish={actions.publish === undefined ? undefined : () => { void publish() }} t={t} />} + {visible && actions.queue !== undefined && { void send(actions.queue) }} menuLabel={t('review.githubActions')} + other={actions.publish === undefined ? undefined : t('review.publish')} menu={() => { void send(actions.publish) }} />} {actions.queue === undefined && actions.publish !== undefined && } + disabled={busy || actions.publishingBlocked === true || empty} onClick={() => { void send(actions.publish) }}>{t('review.publish')}}
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx index f626c2dc47..7abb3a7f67 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx @@ -1,4 +1,4 @@ -/** Inline comment records and editing of a pending review at its captured code location. */ +/** Inline comment records and the editing of comment drafts kept in the browser at their captured code location. */ import { useCallback, useState, useSyncExternalStore, type ReactNode } from 'react' import { Button, Tag } from '@deepseek-ai/dsh-client-ui-primitives' @@ -24,7 +24,8 @@ export function useFileReview(actions: ReviewActions | undefined, path: string): } /** - * Render a saved comment at its line, with pending edits kept in the browser queue. + * Render a saved comment at its line. A comment draft kept in the browser can be edited, deleted, or moved into the + * pending review on the code host. * @param props - record, edit availability, source actions, and localized copy. * @returns one compact inline comment card. */ @@ -38,11 +39,27 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr const [editing, setEditing] = useState(false) const [body, setBody] = useState(thread.selection.comment.body) const [error, setError] = useState(null) + const [moving, setMoving] = useState(false) const comment = thread.selection.comment const quoted = editing && thread.kind === 'pending' ? { ...thread.selection, comment: { ...comment, body } } : thread.selection const range = reviewRange(comment) - const status = thread.kind === 'pending' ? 'review.pending' : thread.kind === 'published' ? 'review.published' : 'review.quoted' + const status = thread.kind === 'pending' ? 'review.browserDraft' : thread.kind === 'published' ? 'review.published' : 'review.quoted' const save = (): boolean => actions.records?.editThread(thread.key, body) === true + const queue = actions.queue + /** Add the draft to the pending review on the code host, then delete it from the browser. */ + const move = async (add: NonNullable): Promise => { + setMoving(true) + setError(null) + try { + const failure = await add(thread.selection) + if (failure !== undefined) setError(failure) + else if (actions.records?.editThread(thread.key, null) !== true) setError(t('review.saveFailed')) + } catch (_error) { + setError(t('review.unknown')) + } finally { + setMoving(false) + } + } return
@@ -65,13 +82,15 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr }}>{t('review.addToInput')} {thread.kind === 'pending' && <> {editing - ? - : } - } + + {queue !== undefined && !editing && !outdated && } } {thread.kind === 'published' && actions.openLink !== undefined && }
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css index b84b5a8428..046ba7baad 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -151,6 +151,84 @@ margin: 10px -8px -4px; } +/* A reply or a comment edit uses the inline review form's field; its actions sit under it like the form's. */ +.form { margin-top: 10px; } + +.formActions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +/* The collapsed reply field, a field-shaped button that opens the reply form. */ +.replyPrompt { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 32px; + margin-top: 10px; + padding: 6px 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-dimmed); + font: inherit; + text-align: start; + cursor: text; +} + +.replyPrompt:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +.menuAnchor { display: inline-flex; flex: none; } + +/* The comment menu's icon button keeps the 20px row height of the header it ends. */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-xs); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.icon[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } + +.icon:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +@media (hover: hover) { + .icon:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } +} + +/* Confirming a deletion replaces nothing: the comment stays readable above the question. */ +.confirm { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.confirmText { flex: 1 1 auto; min-width: 0; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } + +.confirmError, +.actionError { flex-basis: 100%; margin-top: 0; } + +.actionError { padding-inline: 8px; } + /* The end of the recorded hunk, drawn like the diff rows it came from. */ .excerpt, .original { diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx index 02cf0c8fae..feedfe071b 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx @@ -1,23 +1,44 @@ /** - * Read-only review threads of a change request inside the changes view: a thread card at its lines or at the top of - * its file, a resolved thread folded into one row, and the outdated threads of a file in a section after its lines. + * Review threads of a change request inside the changes view: a thread card at its lines or at the top of its file, a + * resolved thread folded into one row, and the outdated threads of a file in a section after its lines. A card offers + * the writes the signed-in user may make: a reply, published at once or added to the pending review, resolving or + * unresolving the thread, and editing or deleting the user's own comments. */ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconRightUpOutlineRegular, MarkdownText, Tag, type MarkdownLabels, + Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconEllipsisOutlineRegular, IconRightUpOutlineRegular, MarkdownText, + Menu, Tag, Tooltip, type MarkdownLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgeReviewComment, ForgeReviewThread, ForgeReviewWrite, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { formatAgo } from './format.ts' import { anchorRange, hunkExcerpt, type ThreadPlace } from './review-threads.ts' +import { SplitAction } from './ReviewComposer.tsx' import type { NS } from './locales.ts' import css from './ReviewThreads.module.css' +import composerCss from './ReviewComposer.module.css' type Translate = PropsLocale['t'] +/** Each member of a union without its `headSha`. */ +type WithoutHead = T extends unknown ? Omit : never + +/** A write to one thread or its comments, without the head commit the changes view adds. */ +export type ThreadWrite = WithoutHead> + +/** Writes the signed-in user can make to the threads of one changes view. */ +export interface ThreadWrites { + /** Send a write; resolves to the message to show, or undefined once the code host took it. */ + readonly write: (write: ThreadWrite) => Promise + /** Whether the user has a pending review, which every reply then joins. */ + readonly reviewStarted: boolean + /** Whether the user can add a reply to a pending review, starting one when needed. */ + readonly canReview: boolean +} + /** Reads, actions, and identities every thread card of one changes view shares. */ export interface ThreadActions { /** Login of the signed-in user, or null when the host names none. */ @@ -34,6 +55,8 @@ export interface ThreadActions { readonly inputFailed: () => void /** The latest jump to a thread, which unfolds that thread and its section until the reader folds them again; null before any. */ readonly focused: ThreadFocus | null + /** Writes the user can make, or null when the code host takes none from this view. */ + readonly writes: ThreadWrites | null } /** One jump to a thread; each jump has its own revision, so a jump to the same thread unfolds it again. */ @@ -97,14 +120,77 @@ export function Avatar({ author, size = 20 }: { readonly author: ForgeReviewComm } -function Comment({ comment, actions, labels, now, t }: { +/** Run one write at a time for a card section, keeping its error until the next attempt. */ +function useWriting(t: Translate): { + readonly busy: boolean + readonly error: string | null + readonly run: (send: () => Promise) => Promise +} { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const running = useRef(false) + const run = async (send: () => Promise): Promise => { + if (running.current) return false + running.current = true + setBusy(true) + setError(null) + try { + const failure = await send() + setError(failure ?? null) + return failure === undefined + } catch (_error) { + setError(t('thread.writeUnknown')) + return false + } finally { + running.current = false + setBusy(false) + } + } + return { busy, error, run } +} + +/** A plain-text field with Cancel and the actions that send it, for a reply or a comment edit. */ +function TextForm({ label, initial, busy, error, cancel, children }: { + readonly label: string + readonly initial: string + readonly busy: boolean + readonly error: string | null + readonly cancel: { readonly label: string; readonly run: () => void } + /** The sending actions, given the current text and whether it is blank. */ + readonly children: (text: string, blank: boolean) => ReactNode +}): ReactNode { + const [text, setText] = useState(initial) + return
+