fix: carry session writer failures by typed remote code

This commit is contained in:
07akioni committed 2026-09-16 15:38:34 +08:00
1 parent eb57fdb9c6
commit 5c369a0a1f
32 files changed
+189 -98

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` 防止可选依赖的值导入破坏模块加载。
+25 -1
View File
@@ -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 -2
View File
@@ -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: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488
event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d
event-producer-consumer.md: d5696c4d0ab84c0fac81995bc9bb40de14b90fa7
event-producer-consumer.zh.md: 05e21a08f6723326744b6a6785fc4e89c19fb942
+5 -5
View File
@@ -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) |
| `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` |
+5 -5
View File
@@ -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) |
| `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 -2
View File
@@ -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: 99c24562c178b35944e5616729076d015eb3f4c6
persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0
persistence-catalog.md: 916227711b4f11dd4ba70882f41b71696674e4ba
persistence-catalog.zh.md: b25d877c085221ed55f334c33b34c98f5012425d
+2 -2
View File
@@ -1790,7 +1790,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 |
|---|---|---|
@@ -6305,7 +6305,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`
+2 -2
View File
@@ -1792,7 +1792,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)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -6307,7 +6307,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`
+2 -2
View File
@@ -32047,7 +32047,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",
@@ -37981,7 +37981,7 @@
"event:agent/inbox/spliced.data.inserted[0].source[10]"
],
"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: fb00f6353c2f13b3b5a27eb49fa0ffbe08f4546a
README.zh.md: bb2953c64df7cf4d5e03901ccf5e62077b0c4ea5
README.md: a59f7dfc6e342b6eecf3aa60a7350a19e6daaf70
README.zh.md: 986e75fdfd18a2b07fb208a480592c05b127aee7
+1 -1
View File
@@ -41,7 +41,7 @@ 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/agent-busy` with reason `session-already-owned`; other resume failures retain `gateway/internal`.
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
+1 -1
View File
@@ -41,7 +41,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
恢复会话时若已有写句柄占用,返回 `session/agent-busy`,reason 为 `session-already-owned`;其他恢复失败仍返回 `gateway/internal`。
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
<a id="session-media-references"></a>
## 会话媒体引用
+3 -8
View File
@@ -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,13 +215,8 @@ export class ApiSessionAgentController {
if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
if (this.ctx.get('sessionPersistence') !== undefined) {
const { SessionAlreadyOwnedError } = await import('@deepseek-ai/dsh-session-persistence')
if (error instanceof SessionAlreadyOwnedError) {
return {
error: new RemoteError('session/agent-busy', error.message, { reason: 'session-already-owned' }),
}
}
if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
return { error: new RemoteError('session/writer-held', error.message, { sessionId }) }
}
return {
error: new RemoteError(
@@ -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 }
@@ -247,7 +247,11 @@ describe('ApiSession Agent lookup and recovery', () => {
})
const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new SessionAlreadyOwnedError(meta.id))
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'session/agent-busy', details: { reason: 'session-already-owned' } },
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({
@@ -9,7 +9,6 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
'hint.plan': PLAN_NEXT_ACTION_ZH,
'hint.goal': '输入目标,智能体将持续执行',
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
@@ -149,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': '插话发送失败,请重试。',
@@ -171,7 +171,6 @@ export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'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.',
'hint.plan': PLAN_NEXT_ACTION_EN,
'hint.goal': 'describe the objective for a long-running task',
'hint.goal.active': 'goal active — edit / pause / resume / clear',
@@ -311,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.',
@@ -104,7 +104,7 @@ export const InputBar = memo(function InputBar({
useEffect(() => {
if (promptError === null) return
const { error } = promptError
if (error.code === 'session/agent-busy' && error.details.reason === 'session-already-owned') {
if (error.code === 'session/writer-held') {
showToast(t('error.sessionInUse'))
return
}
@@ -1533,7 +1533,7 @@ describe('strips and variants', () => {
const send = bench({
promptError: {
op: 'send',
error: new RemoteError('session/agent-busy', 'internal writer diagnostic', { reason: 'session-already-owned' }),
error: new RemoteError('session/writer-held', 'internal writer diagnostic', { sessionId: SID }),
},
t: makeTranslate(dictionary, commonZh),
})
@@ -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: a6f19c96ab1610791a820d7e042739611c920134
README.zh.md: eb7a91eaadc8b2872f8ebc6ce339fdd9e9cb6962
README.md: 5e16cdf18d2eccb63097c2a524bfb2d20178d15e
README.zh.md: a99581e827c5ce66a31f6e4cbad11394e03dde4d
+2 -1
View File
@@ -35,8 +35,9 @@ 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.
When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
### Selection failures
When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
-----
@@ -35,8 +35,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
### 选择失败
当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
-----
@@ -275,21 +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: directory.getSnapshot().sessionInUse === true
? t('error.sessionInUse')
: 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'
@@ -33,8 +33,6 @@ export interface ModelDirectoryState {
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Whole-request or selection failure text; null when none. */
error: string | null
/** Whether the selection failed because another writer owns the Session. */
sessionInUse?: boolean
}
/** One session's shared directory controller; disposed with the session scope. */
@@ -84,13 +82,14 @@ 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; s.sessionInUse = false })
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const result = await this.sessions.selectModel({
sessionId: this.sessionId,
provider: selection.provider,
@@ -100,20 +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}`
s.sessionInUse = result.error.code === 'session/agent-busy'
&& result.error.details.reason === 'session-already-owned'
})
throw new Error(`session.selectModel failed: ${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 }
}
/**
@@ -152,7 +149,6 @@ export class ModelDirectory {
this.store.update((state) => {
state.status = 'error'
state.error = catalog.error
state.sessionInUse = false
})
}
return
@@ -164,11 +164,10 @@ 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')
}
try {
await directory.select(selection)
} catch (error: unknown) {
if (directory.store.getSnapshot().sessionInUse === true) throw new Error(t('error.sessionInUse'))
throw error
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
}
},
},
@@ -192,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))
@@ -10,7 +10,6 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
'command.label': '模型',
'command.description': '选择本会话使用的模型',
'option.loadError': '目录加载失败:{message}',
@@ -27,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': '没有可用的模型。',
@@ -39,7 +39,6 @@ export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'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.',
'command.label': 'Model',
'command.description': 'Select the model for this conversation',
'option.loadError': 'Catalog failed to load: {message}',
@@ -56,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,8 +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 { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
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'
@@ -75,7 +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/agent-busy'> | undefined
let selectionFailure: RemoteError<'session/writer-held'> | undefined
const sessionRemote = {
modelCatalog: () => {
calls.models += 1
@@ -177,7 +176,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => selected,
rejectSelection: () => {
selectionFailure = new RemoteError('session/agent-busy', 'writer held', { reason: 'session-already-owned' })
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) },
@@ -198,7 +197,7 @@ describe('ui-model-selection dual entry', () => {
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', sessionInUse: true,
status: 'error', error: 'session/writer-held: writer held',
})
})
@@ -247,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',
@@ -427,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}
/>)
@@ -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', sessionInUse, 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}
@@ -213,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: /选择模型/ })
@@ -246,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}
/>)
@@ -257,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
@@ -322,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: /选择模型/ })
@@ -369,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: /选择模型/ })
@@ -431,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: /选择模型/ }))
@@ -3955,7 +3955,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"