From e0f170b8d046a92cfd4288b89bf42d42f95abaac Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 16 Sep 2026 14:50:38 +0800 Subject: [PATCH] fix: explain session writer contention in toasts --- .../api/session-controller/README.i18n.yaml | 4 ++-- packages/api/session-controller/README.md | 2 ++ packages/api/session-controller/README.zh.md | 2 ++ packages/api/session-controller/src/agent.ts | 7 ++++++- .../tests/agent.host.spec.ts | 18 ++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 ++ packages/client/ui-conversation/README.zh.md | 2 ++ .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/InputBar.tsx | 8 ++++++-- .../tests/input-bar.client.spec.tsx | 13 ++++++++++++- .../client/ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 3 +++ .../client/ui-model-selection/README.zh.md | 3 +++ .../src/client/ModelSelect.tsx | 7 ++++++- .../ui-model-selection/src/client/directory.ts | 12 ++++++++++-- .../ui-model-selection/src/client/index.ts | 7 ++++++- .../ui-model-selection/src/client/locales.ts | 2 ++ .../tests/browser-plugin.client.spec.ts | 18 ++++++++++++++++++ .../tests/model-select.client.spec.tsx | 8 +++++--- 20 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 54f3e511fd..94336f6f7d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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: fb00f6353c2f13b3b5a27eb49fa0ffbe08f4546a +README.zh.md: bb2953c64df7cf4d5e03901ccf5e62077b0c4ea5 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 772cec4f15..fb00f6353c 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -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/agent-busy` with reason `session-already-owned`; other resume failures retain `gateway/internal`. + ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 0f04e539d3..bb2953c64d 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -41,6 +41,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。 +恢复会话时若已有写句柄占用,返回 `session/agent-busy`,reason 为 `session-already-owned`;其他恢复失败仍返回 `gateway/internal`。 + ## 会话媒体引用 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 68faea7d67..9e916d03bb 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-presets' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import { SessionAlreadyOwnedError, type SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' @@ -215,6 +215,11 @@ export class ApiSessionAgentController { if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } + if (error instanceof SessionAlreadyOwnedError) { + return { + error: new RemoteError('session/agent-busy', error.message, { reason: 'session-already-owned' }), + } + } return { error: new RemoteError( 'gateway/internal', diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 2f8ae62c9a..a202d9281f 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -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,23 @@ 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/agent-busy', details: { reason: 'session-already-owned' } }, + }) + resume.mockRejectedValue(new Error('unrelated failure')) + await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({ + error: { code: 'gateway/internal' }, + }) + }) + it('requires projected observations before activation', async () => { const { agents } = await harness() const meta = header('unprojected-observation') diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 00716afb9e..6773e76b8e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 802ba3b8d853457e00cab3180c251ffec5291f19 -README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866 +README.md: aca764a048c929be1488345b214f12b94b25f646 +README.zh.md: c9702f1afd17992cdfda0107c5a52b534de4c91b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 802ba3b8d8..aca764a048 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -62,6 +62,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. + ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8319941728..c9702f1afd 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -62,6 +62,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。 +当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。 + ## 临时 composer entry diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c1a6606d86..3810bc6cab 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,7 @@ 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 清除', @@ -170,6 +171,7 @@ 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', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index af46ed3a63..a7c32c4325 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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/agent-busy' && error.details.reason === 'session-already-owned') { + 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})`) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index fc7089b423..3fb4615177 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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/agent-busy', 'internal writer diagnostic', { reason: 'session-already-owned' }), + }, + 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 { diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index b3485170c3..938099062a 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -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: a6f19c96ab1610791a820d7e042739611c920134 +README.zh.md: eb7a91eaadc8b2872f8ebc6ce339fdd9e9cb6962 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index b3c3b25c25..a6f19c96ab 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -35,6 +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. + + ----- diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index e705c4f1a3..eb7a91eaad 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -35,6 +35,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。 +当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。 + + ----- diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 422994dbb0..7905cf433b 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -283,7 +283,12 @@ export function ModelSelect( const message = directory.getSnapshot().error if (message !== null) { toastSeq.current += 1 - setToast({ seq: toastSeq.current, text: t('error.action', { message }) }) + setToast({ + seq: toastSeq.current, + text: directory.getSnapshot().sessionInUse === true + ? t('error.sessionInUse') + : t('error.action', { message }), + }) } } diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 6ceee704ed..1d3f40ce7c 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -33,6 +33,8 @@ 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. */ @@ -88,7 +90,7 @@ export class ModelDirectory { async select(selection: ModelSelection): Promise { this.assertAvailable() const generation = ++this.generation - this.store.update((s) => { s.status = 'selecting'; s.error = null }) + this.store.update((s) => { s.status = 'selecting'; s.error = null; s.sessionInUse = false }) const result = await this.sessions.selectModel({ sessionId: this.sessionId, provider: selection.provider, @@ -102,7 +104,12 @@ export class ModelDirectory { return } if (!result.ok) { - this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) + 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}`) } this.store.update((s) => { s.status = 'ready'; s.error = null }) @@ -145,6 +152,7 @@ export class ModelDirectory { this.store.update((state) => { state.status = 'error' state.error = catalog.error + state.sessionInUse = false }) } return diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 592081037a..95e3711f49 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -164,7 +164,12 @@ 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) + try { + await directory.select(selection) + } catch (error: unknown) { + if (directory.store.getSnapshot().sessionInUse === true) throw new Error(t('error.sessionInUse')) + throw error + } }, }, }), 'ui-model-selection: /model contribution') diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 570565ce60..f63e7a9a16 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -10,6 +10,7 @@ /** 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}', @@ -38,6 +39,7 @@ 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}', diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 6267e63f9e..7e42a230f9 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -14,6 +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 type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' @@ -74,6 +75,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 const sessionRemote = { modelCatalog: () => { calls.models += 1 @@ -89,6 +91,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 +176,9 @@ 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' }) + }, setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) }, address: (id: SessionId) => { addressed.add(id) }, @@ -184,6 +190,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', sessionInUse: true, + }) + }) + it('registers the /model contribution and the composer model seat', async () => { const b = await bench() expect(b.contribution().name).toBe('model') diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index c2b14e103d..b332940812 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -168,7 +168,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,7 +179,7 @@ describe('ModelSelect reasoning effort', () => { }] const directory = createSnapshotStore(state({ groups })) const select = vi.fn(async () => { - directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' })) + directory.set(state({ groups, status: 'error', sessionInUse, error: 'session/model-unavailable: session already contains images' })) return false }) render( { 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() })