Merge pull request #4339 from deepseek-harness/fix/session-used-toast
fix: 会话被占用时提示退出其他 DSH 实例后重试
This commit is contained in:
commit
004526ab0a
35 files changed
+267
-69
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md
|
||||
2026-09-16-session-writer-held-feedback.md: 922987996c4abd32857cd0f54cec4394566cf97f
|
||||
2026-09-16-session-writer-held-feedback.zh.md: 6a1aa7993a8f36c50cb5fed0180d6523af44c177
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Session writer contention feedback
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-session-writer-held-feedback.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Session can retain its write handle while its Agent is idle. Another Host cannot resume that Session, but a generic internal-error toast gives the user no recovery guidance. The holder can also belong to the same process, so contention alone does not identify another running application.
|
||||
|
||||
## Decision
|
||||
|
||||
Session Controller reports `session/writer-held` with `{ sessionId }` when resume fails with `SessionAlreadyOwnedError` and no reusable Agent exists. Clients discriminate by code, following the [Remote failure vocabulary](../architecture/2026-08-28-ctx-remote-failure-vocabulary.md). The controller recognizes the persistence error by its Error name, matching Session Query's optional-dependency handling; loading the controller requires no persistence implementation or error-class identity.
|
||||
|
||||
Send and model-selection failures show localized guidance that another DSH instance may hold the Session and suggest quitting other instances before retrying. Model selection returns the original Remote result to both UI entries; error classification does not depend on a later read of shared directory state. The [write-lease decision](../feature/2026-08-31-cross-process-session-write-lease.md) continues to own locking and release semantics; this feedback neither takes ownership nor retries writes automatically.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A free-text reason under `session/agent-busy`.** A second string discriminator loses the code-to-details type relationship and mixes write contention with prompt admission failures.
|
||||
|
||||
**A separate directory flag or a required persistence peer.** A flag duplicates the operation's failure and can become stale after a catalog refresh. Making persistence mandatory solely to identify its error removes support for deployments without persistence.
|
||||
|
||||
## Consequences
|
||||
|
||||
The wire gains one typed failure code without changing stored Session data. Recovery guidance cannot identify which process owns the handle. Host tests cover the error name and ordinary failures with and without persistence; Client tests cover both locales and operation-local model failures. The keyless `queue-actions` Web scenario pins the writer-held toast and preserved draft through the shipped composition. `verify-optional-dependency-imports` guards module loading against optional value imports.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 会话写句柄占用反馈
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-session-writer-held-feedback.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Agent 空闲时,Session 仍可能持有写句柄。另一个 Host 无法恢复该会话,但通用内部错误 toast 没有提供恢复指引。持有者也可能属于同一个进程,因此仅凭占用不能确定是另一个正在运行的应用。
|
||||
|
||||
## Decision
|
||||
|
||||
当恢复因 `SessionAlreadyOwnedError` 失败且不存在可复用 Agent 时,Session Controller 返回 `session/writer-held`,携带 `{ sessionId }`。Client 遵循 [Remote 失败词汇](../architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md),按 code 判别。Controller 按 Error 名称识别持久化错误,与 Session Query 的可选依赖处理一致;加载 Controller 不需要持久化实现或错误类身份相同。
|
||||
|
||||
发送和模型选择失败显示本地化指引,说明可能有其他 DSH 实例占用 Session,并建议退出其他实例后重试。模型选择将原始 Remote 结果返回给两个 UI 入口;错误分类不依赖稍后读取共享目录状态。[写租约决策](../feature/2026-08-31-cross-process-session-write-lease.zh.md) 继续拥有锁定和释放语义;此反馈既不抢占写权限,也不自动重试写入。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `session/agent-busy` 下使用自由文本 reason。** 第二个字符串判别值失去 code 与 details 的类型关系,并将写占用与提示词接纳失败混在一起。
|
||||
|
||||
**单独的目录标志或必需的持久化 peer。** 标志重复记录操作失败,且可能在目录刷新后过时。仅为识别错误而强制依赖持久化,会移除对无持久化部署的支持。
|
||||
|
||||
## Consequences
|
||||
|
||||
Wire 新增一个有类型的失败码,不改变已存储的 Session 数据。恢复指引无法确定哪个进程持有句柄。Host 测试覆盖错误名称,以及有、无持久化时的普通失败;Client 测试覆盖两种语言和随模型操作返回的失败。无密钥的 `queue-actions` Web 场景通过随附组合固定写占用 toast 和保留草稿的输出。`verify-optional-dependency-imports` 防止可选依赖的值导入破坏模块加载。
|
||||
@@ -27,6 +27,7 @@ const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
|
||||
const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
|
||||
const WRITER_HELD_EXPECTED = join(SNAPSHOT_DIR, 'writer-held.expected.md')
|
||||
const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -232,6 +233,29 @@ describe('web e2e: queue row actions', () => {
|
||||
].join('\n')
|
||||
await compareOrRefreshGolden(FAILED_EXPECTED, failed, MODE)
|
||||
|
||||
await page.route('**/api/session/prompt', async (route) => {
|
||||
const envelope = route.request().postDataJSON() as { rpcId: string }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
type: 'server-response', rpcId: envelope.rpcId,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
}, { times: 1 })
|
||||
await input.press('Enter')
|
||||
const writerHeld = page.getByRole('alert').filter({ hasText: 'This session is already in use' })
|
||||
await writerHeld.waitFor()
|
||||
await expect.poll(() => input.textContent()).toBe(FAILED)
|
||||
await compareOrRefreshGolden(WRITER_HELD_EXPECTED, [
|
||||
await writerHeld.ariaSnapshot(),
|
||||
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
|
||||
].join('\n'), MODE)
|
||||
|
||||
await input.fill(TAIL)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
@@ -373,7 +397,7 @@ describe('web e2e: queue row actions', () => {
|
||||
[
|
||||
'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
|
||||
'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
|
||||
'sending.expected.md', 'failed.expected.md',
|
||||
'sending.expected.md', 'failed.expected.md', 'writer-held.expected.md',
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 397ad7a77e49ef8da8bc3a43171a494eb42917fe
|
||||
event-producer-consumer.zh.md: d5768b0842c0e821173e3d80535988f91454114f
|
||||
event-producer-consumer.md: 1ead6a7654c5bde710f28b4f5db7e99ba991de4b
|
||||
event-producer-consumer.zh.md: 4a097b0e0eeabbe993a554e9c8f20880cffd2681
|
||||
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../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:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:573`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../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:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:573`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: 0957adb0ee15587eedff2b38742616bc4c8decba
|
||||
persistence-catalog.zh.md: 45e7155b0ddf8d72ac132e0ac79ea31c28acea3c
|
||||
persistence-catalog.md: be4a1f8760032d1c8cf526c879bb6b5b67531af2
|
||||
persistence-catalog.zh.md: 91ca2f7a03662b0ff1f5e42f084d49acdb172f13
|
||||
@@ -1808,7 +1808,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
|
||||
|
||||
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -6337,7 +6337,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:375`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:376`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
|
||||
@@ -1810,7 +1810,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
|
||||
|
||||
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -6339,7 +6339,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:375`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:376`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -32113,7 +32113,7 @@
|
||||
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
|
||||
],
|
||||
"sources": [
|
||||
"packages/api/session-controller/src/types.ts:375",
|
||||
"packages/api/session-controller/src/types.ts:376",
|
||||
"packages/attachment/attachment/src/brand.ts:6",
|
||||
"packages/compaction/compaction/src/brand.ts:4",
|
||||
"packages/core/session/src/types.ts:19",
|
||||
@@ -38047,7 +38047,7 @@
|
||||
"event:agent/inbox/spliced.data.inserted[0].source[11]"
|
||||
],
|
||||
"sources": [
|
||||
"packages/api/session-controller/src/types.ts:380"
|
||||
"packages/api/session-controller/src/types.ts:381"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: 772cec4f15da7150617b847824070b27749394ae
|
||||
README.zh.md: 0f04e539d3eb389273b7b7950acf2dadcc5410bc
|
||||
README.md: a59f7dfc6e342b6eecf3aa60a7350a19e6daaf70
|
||||
README.zh.md: 986e75fdfd18a2b07fb208a480592c05b127aee7
|
||||
@@ -41,6 +41,8 @@ The user-invocable `skills/list` metadata includes the winning provider’s opti
|
||||
|
||||
Fork copies history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
|
||||
|
||||
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
|
||||
|
||||
<a id="session-media-references"></a>
|
||||
## Session media references
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|
||||
|
||||
分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
|
||||
|
||||
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
|
||||
|
||||
<a id="session-media-references"></a>
|
||||
## 会话媒体引用
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export class ApiSessionPresetConflict extends Error {
|
||||
}
|
||||
|
||||
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
|
||||
export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'gateway/internal'>
|
||||
export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'>
|
||||
|
||||
/** Result of resolving one ordinary Session identity to its live Agent. */
|
||||
export type ApiSessionAgentResult =
|
||||
@@ -215,6 +215,9 @@ export class ApiSessionAgentController {
|
||||
if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
|
||||
return { error: apiSessionSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
|
||||
return { error: new RemoteError('session/writer-held', error.message, { sessionId }) }
|
||||
}
|
||||
return {
|
||||
error: new RemoteError(
|
||||
'gateway/internal',
|
||||
|
||||
@@ -191,6 +191,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
readonly requestedCwd: string
|
||||
readonly existingCwd?: string
|
||||
}
|
||||
'session/writer-held': { readonly sessionId: SessionId }
|
||||
'session/agent-busy': { readonly reason: string }
|
||||
'session/invalid-time-zone': { readonly value: string }
|
||||
'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -237,6 +238,37 @@ describe('ApiSession Agent lookup and recovery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies a held Session writer without classifying other resume failures as contention', async () => {
|
||||
const { ctx, agents } = await harness()
|
||||
const meta = header('owned-session')
|
||||
providePersistence(ctx, {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events: [] }),
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new SessionAlreadyOwnedError(meta.id))
|
||||
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
||||
error: { code: 'session/writer-held', details: { sessionId: meta.id } },
|
||||
})
|
||||
resume.mockRejectedValue(Object.assign(new Error('another module copy'), { name: 'SessionAlreadyOwnedError' }))
|
||||
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
||||
error: { code: 'session/writer-held', details: { sessionId: meta.id } },
|
||||
})
|
||||
resume.mockRejectedValue(new Error('unrelated failure'))
|
||||
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
||||
error: { code: 'gateway/internal' },
|
||||
})
|
||||
})
|
||||
|
||||
it('retains resume diagnostics without a persistence service', async () => {
|
||||
const { ctx, agents } = await harness()
|
||||
const meta = header('memory-only-resume')
|
||||
ctx.sessions.create(meta.id, { meta })
|
||||
vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
|
||||
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
||||
error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires projected observations before activation', async () => {
|
||||
const { agents } = await harness()
|
||||
const meta = header('unprojected-observation')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: e07c2f66e55d0bfe55b869d5b395914670278be3
|
||||
README.zh.md: 81e5300e5bac3d6d54e83ead165598cb2c378a99
|
||||
README.md: 749c2d0d99ce7e5afe2f9a03b1451d62a17206e7
|
||||
README.zh.md: 98d7d9672922ca68ea4df5499f8789e8c2fde162
|
||||
@@ -64,6 +64,8 @@ Disabled Send and Stop buttons suppress their tooltips, including a Stop button
|
||||
|
||||
File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission.
|
||||
|
||||
When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry.
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## Temporary composer entries
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
|
||||
|
||||
文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
|
||||
|
||||
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## 临时 composer entry
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ export const zh = {
|
||||
'queue.remove': '删除排队消息',
|
||||
'queue.steer': '插话发送',
|
||||
'queue.steer.unavailable': '仅运行中可插话发送',
|
||||
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
|
||||
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
|
||||
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
|
||||
'queue.steerFailed': '插话发送失败,请重试。',
|
||||
@@ -309,6 +310,7 @@ export const en = {
|
||||
'queue.remove': 'Remove queued message',
|
||||
'queue.steer': 'Steer queued message',
|
||||
'queue.steer.unavailable': 'Steering is available only while the agent is running',
|
||||
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
|
||||
'queue.editFailed': 'Edit failed: this message may have already started sending.',
|
||||
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
|
||||
'queue.steerFailed': 'Steering failed. Try again.',
|
||||
|
||||
@@ -99,11 +99,15 @@ export const InputBar = memo(function InputBar({
|
||||
// an unresolved promptError deliberately re-announces it once — the failure
|
||||
// is still pending, and a transient banner is its only surface. Attachment
|
||||
// rejections show product copy keyed by the wire reason — whichever domain
|
||||
// refused them; other codes are developer-facing and keep the raw message
|
||||
// plus code.
|
||||
// refused them. Writer contention has localized recovery guidance; other
|
||||
// failures retain the diagnostic message and code.
|
||||
useEffect(() => {
|
||||
if (promptError === null) return
|
||||
const { error } = promptError
|
||||
if (error.code === 'session/writer-held') {
|
||||
showToast(t('error.sessionInUse'))
|
||||
return
|
||||
}
|
||||
showToast(error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-invalid'
|
||||
? attachmentErrorText(t, error.details.reason, imageLimits)
|
||||
: `${error.message} (${error.code})`)
|
||||
|
||||
@@ -31,7 +31,7 @@ import type {
|
||||
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
|
||||
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
|
||||
@@ -1529,6 +1529,17 @@ describe('insertText (scoped event body)', () => {
|
||||
})
|
||||
|
||||
describe('strips and variants', () => {
|
||||
it.each([zh, en])('shows localized guidance when another writer owns the Session', (dictionary) => {
|
||||
const send = bench({
|
||||
promptError: {
|
||||
op: 'send',
|
||||
error: new RemoteError('session/writer-held', 'internal writer diagnostic', { sessionId: SID }),
|
||||
},
|
||||
t: makeTranslate(dictionary, commonZh),
|
||||
})
|
||||
expect(send.view.getByRole('alert').textContent).toBe(dictionary['error.sessionInUse'])
|
||||
})
|
||||
|
||||
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
|
||||
README.md: b3c3b25c258dd1fd8088eb7d699806f6ecd2cc1a
|
||||
README.zh.md: e705c4f1a309da66099abba6cc3cd8a2e9b26b0e
|
||||
README.md: 5e16cdf18d2eccb63097c2a524bfb2d20178d15e
|
||||
README.zh.md: a99581e827c5ce66a31f6e4cbad11394e03dde4d
|
||||
@@ -35,6 +35,10 @@ Models stay grouped by provider. The composer menu shows model and effort names
|
||||
|
||||
When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable.
|
||||
|
||||
### Selection failures
|
||||
|
||||
When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -35,6 +35,10 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
|
||||
|
||||
当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
|
||||
|
||||
### 选择失败
|
||||
|
||||
当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -275,16 +275,20 @@ export function ModelSelect(
|
||||
close()
|
||||
}
|
||||
|
||||
const settleSelection = (accepted: boolean): void => {
|
||||
if (accepted) {
|
||||
const settleSelection = (result: Awaited<ReturnType<ModelSelectInjected['select']>>): void => {
|
||||
if (result === undefined) return
|
||||
if (result.ok) {
|
||||
if (rootRef.current !== null) close(true)
|
||||
return
|
||||
}
|
||||
const message = directory.getSnapshot().error
|
||||
if (message !== null) {
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
|
||||
}
|
||||
const { error } = result
|
||||
toastSeq.current += 1
|
||||
setToast({
|
||||
seq: toastSeq.current,
|
||||
text: error.code === 'session/writer-held'
|
||||
? t('error.sessionInUse')
|
||||
: t('error.action', { message: `${error.code}: ${error.message}` }),
|
||||
})
|
||||
}
|
||||
|
||||
const choose = (selection: ModelSelection): void => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ModelCatalogDirectory } from './catalog.ts'
|
||||
@@ -82,10 +82,11 @@ export class ModelDirectory {
|
||||
/**
|
||||
* Select the complete provider/model/reasoning selection. The durable
|
||||
* projection frame updates the shared current; failures surface on the store
|
||||
* and throw so each entry's own retry surface engages.
|
||||
* and return with the operation so each entry can present its own failure.
|
||||
* @param selection - provider, provider-owned model id, and optional adapter-owned effort.
|
||||
*/
|
||||
async select(selection: ModelSelection): Promise<void> {
|
||||
* @returns the selection outcome, including the original Remote failure.
|
||||
*/
|
||||
async select(selection: ModelSelection): Promise<RemoteResult<void>> {
|
||||
this.assertAvailable()
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'selecting'; s.error = null })
|
||||
@@ -98,15 +99,18 @@ export class ModelDirectory {
|
||||
: { reasoningEffort: selection.reasoningEffort },
|
||||
})
|
||||
if (this.disposed || generation !== this.generation) {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
return
|
||||
return result.ok ? { ok: true, value: undefined } : result
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = `${result.error.code}: ${result.error.message}`
|
||||
})
|
||||
return result
|
||||
}
|
||||
this.store.update((s) => { s.status = 'ready'; s.error = null })
|
||||
this.syncInputs()
|
||||
return { ok: true, value: undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,7 +164,11 @@ export function apply(ctx: ClientContext): void {
|
||||
if (selection === undefined) {
|
||||
throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group')
|
||||
}
|
||||
await directory.select(selection)
|
||||
const result = await directory.select(selection)
|
||||
if (!result.ok) {
|
||||
if (result.error.code === 'session/writer-held') throw new Error(t('error.sessionInUse'))
|
||||
throw result.error
|
||||
}
|
||||
},
|
||||
},
|
||||
}), 'ui-model-selection: /model contribution')
|
||||
@@ -187,8 +191,8 @@ export function apply(ctx: ClientContext): void {
|
||||
if (available) directory.load().catch(() => { /* surfaced on the store */ })
|
||||
},
|
||||
select: (selection: ModelSelection) => available
|
||||
? directory.select(selection).then(() => true, () => false)
|
||||
: Promise.resolve(false),
|
||||
? directory.select(selection)
|
||||
: Promise.resolve(undefined),
|
||||
}
|
||||
},
|
||||
}, ModelSelect))
|
||||
|
||||
@@ -26,6 +26,7 @@ export const zh = {
|
||||
'effort.providerDefault': 'Default',
|
||||
'status.loading': '正在刷新模型列表…',
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'empty.models': '没有可用的模型。',
|
||||
@@ -54,6 +55,7 @@ export const en = {
|
||||
'effort.providerDefault': 'Default',
|
||||
'status.loading': 'Refreshing model list…',
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'empty.models': 'No models available.',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* entry; this package only contributes the single occupant, so no SlotMap
|
||||
* merge lives here.
|
||||
*/
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
@@ -19,7 +20,7 @@ export interface ModelSelectInjected {
|
||||
/**
|
||||
* Select a complete provider/model/reasoning selection.
|
||||
* @param selection - model selection and optional adapter-owned effort.
|
||||
* @returns whether the host accepted the selection.
|
||||
* @returns the Host outcome, or undefined when this Session cannot select a model.
|
||||
*/
|
||||
select: (selection: ModelSelection) => Promise<boolean>
|
||||
select: (selection: ModelSelection) => Promise<RemoteResult<void> | undefined>
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { createScope } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
@@ -74,6 +74,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
|
||||
// Whether the Host reports an adapter for the current route; the composer
|
||||
// block follows this, never catalog membership.
|
||||
let routable = true
|
||||
let selectionFailure: RemoteError<'session/writer-held'> | undefined
|
||||
const sessionRemote = {
|
||||
modelCatalog: () => {
|
||||
calls.models += 1
|
||||
@@ -89,6 +90,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
|
||||
},
|
||||
selectModel: (payload: { sessionId: SessionId; provider: string; model: string; reasoningEffort?: string }) => {
|
||||
calls.select += 1
|
||||
if (selectionFailure !== undefined) return Promise.resolve({ ok: false as const, error: selectionFailure })
|
||||
selected = {
|
||||
provider: payload.provider,
|
||||
model: payload.model,
|
||||
@@ -173,6 +175,9 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
|
||||
},
|
||||
seat: () => seats.get('conversation.input.model')!,
|
||||
hostCurrent: () => selected,
|
||||
rejectSelection: () => {
|
||||
selectionFailure = new RemoteError('session/writer-held', 'writer held', { sessionId: sid('owned') })
|
||||
},
|
||||
setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
|
||||
setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) },
|
||||
address: (id: SessionId) => { addressed.add(id) },
|
||||
@@ -184,6 +189,18 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
|
||||
const projection = (id: string) => ({ sessionId: sid(id) })
|
||||
|
||||
describe('ui-model-selection dual entry', () => {
|
||||
it('carries writer contention to the model seat and localizes the command failure', async () => {
|
||||
const b = await bench()
|
||||
b.mint('owned')
|
||||
const input = projection('owned')
|
||||
const options = await b.popup().options(input, new AbortController().signal)
|
||||
b.rejectSelection()
|
||||
await expect(b.popup().onSelect(options[0]!, input)).rejects.toThrow(zh['error.sessionInUse'])
|
||||
expect(b.ctx.modelDirectories.directoryFor(sid('owned')).store.getSnapshot()).toMatchObject({
|
||||
status: 'error', error: 'session/writer-held: writer held',
|
||||
})
|
||||
})
|
||||
|
||||
it('registers the /model contribution and the composer model seat', async () => {
|
||||
const b = await bench()
|
||||
expect(b.contribution().name).toBe('model')
|
||||
@@ -229,7 +246,7 @@ describe('ui-model-selection dual entry', () => {
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
})).toBe(true)
|
||||
})).toEqual({ ok: true, value: undefined })
|
||||
expect(b.hostCurrent()).toEqual({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
@@ -409,7 +426,7 @@ describe('ui-model-selection dual entry', () => {
|
||||
const face = b.seat().inject!(sid('child'))
|
||||
expect(face.available).toBe(false)
|
||||
face.load()
|
||||
await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBe(false)
|
||||
await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBeUndefined()
|
||||
await expect(b.ctx.modelDirectories.directoryFor(sid('child')).load())
|
||||
.rejects.toThrow(/unavailable for addressed subagent/)
|
||||
await expect(b.ctx.modelDirectories.directoryFor(sid('child')).select({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ComponentProps } from 'react'
|
||||
@@ -57,7 +59,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state())
|
||||
const select = vi.fn(async (selection: ModelSelection) => {
|
||||
directory.set(state({ current: selection }))
|
||||
return true
|
||||
return { ok: true as const, value: undefined }
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
@@ -106,7 +108,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
@@ -122,7 +124,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
const directory = createSnapshotStore(state({
|
||||
current: { provider: 'deepseek-official', model: 'removed-model' },
|
||||
}))
|
||||
const select = vi.fn().mockResolvedValue(true)
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
@@ -154,7 +156,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
@@ -168,7 +170,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
|
||||
it.each([false, true])('announces rejected selections with ownership guidance only for held writers (%s)', async (sessionInUse) => {
|
||||
const groups = [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -179,8 +181,11 @@ describe('ModelSelect reasoning effort', () => {
|
||||
}]
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
|
||||
const select = vi.fn(async () => {
|
||||
directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' }))
|
||||
return false
|
||||
const error = sessionInUse
|
||||
? new RemoteError('session/writer-held', 'writer held', { sessionId: SessionId('owned') })
|
||||
: new RemoteError('session/model-unavailable', 'session already contains images', { provider: 'deepseek-official', model: 'deepseek-v4-pro' })
|
||||
directory.set(state({ groups, status: 'error', error: 'unrelated catalog refresh' }))
|
||||
return { ok: false as const, error }
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
@@ -195,7 +200,9 @@ describe('ModelSelect reasoning effort', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(toast.textContent).toContain('模型操作失败:session/model-unavailable: session already contains images')
|
||||
expect(toast.textContent).toBe(sessionInUse
|
||||
? zh['error.sessionInUse']
|
||||
: '模型操作失败:session/model-unavailable: session already contains images')
|
||||
// The selection failure does not render the in-menu load strip (no Retry).
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
})
|
||||
@@ -211,7 +218,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
available
|
||||
directory={createSnapshotStore(state())}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
||||
t={t}
|
||||
/>)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
@@ -244,7 +251,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
available={false}
|
||||
directory={createSnapshotStore(state())}
|
||||
load={load}
|
||||
select={vi.fn().mockResolvedValue(false)}
|
||||
select={vi.fn().mockResolvedValue(undefined)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
@@ -255,7 +262,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
|
||||
describe('ModelSelect keyboard walk', () => {
|
||||
function mountOpen() {
|
||||
const select = vi.fn().mockResolvedValue(true)
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
@@ -320,7 +327,7 @@ describe('ModelSelect keyboard walk', () => {
|
||||
available
|
||||
directory={createSnapshotStore(state())}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
||||
t={t}
|
||||
/>)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
@@ -367,7 +374,7 @@ describe('ModelSelect keyboard walk', () => {
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
||||
t={t}
|
||||
/>)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
@@ -429,7 +436,7 @@ describe('ModelSelect keyboard walk', () => {
|
||||
available
|
||||
directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
||||
t={t}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
|
||||
@@ -3980,7 +3980,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ApiSessionAgentError',
|
||||
declaration: 'export type ApiSessionAgentError = RemoteError<\'session/not-found\' | \'session/agent-busy\' | \'gateway/internal\'>;',
|
||||
declaration: 'export type ApiSessionAgentError = RemoteError<\'session/not-found\' | \'session/agent-busy\' | \'session/writer-held\' | \'gateway/internal\'>;',
|
||||
},
|
||||
{
|
||||
name: 'ApiSessionAgentResult',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
- alert: This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.
|
||||
- textbox "Message or run a task, / commands, @ files or sessions":
|
||||
- paragraph: Queue submission to retry
|
||||
- button "Add files or run commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Queue message"
|
||||
Reference in new issue
Block a user