fix: explain session writer contention in toasts
This commit is contained in:
1 parent
e879c7456a
commit
e0f170b8d0
20 files changed
+111
-17
No files matched your search
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
<a id="session-media-references"></a>
|
||||
## Session media references
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|
||||
|
||||
分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
|
||||
|
||||
恢复会话时若已有写句柄占用,返回 `session/agent-busy`,reason 为 `session-already-owned`;其他恢复失败仍返回 `gateway/internal`。
|
||||
|
||||
<a id="session-media-references"></a>
|
||||
## 会话媒体引用
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## Temporary composer entries
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
|
||||
|
||||
文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
|
||||
|
||||
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## 临时 composer entry
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -35,6 +35,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
|
||||
|
||||
当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
|
||||
|
||||
当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
|
||||
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ModelDirectoryState>(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(<ModelSelect
|
||||
@@ -195,7 +195,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()
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user