fix(cua): keep existing conversations usable after activation

This commit is contained in:
creatixchu committed 2026-09-25 19:55:44 +08:00
1 parent e0d9be74df
commit ce11b81599
8 files changed
+64 -10

No files matched your search

+11 -1
View File
@@ -8,7 +8,7 @@ import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-ptc-runtime'
import { expect, it } from 'vitest'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { connectFreshWorkspaceZh, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts'
it('keeps Computer Use off by default and persists its explicit profile-wide PTC switch', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cua-optin-web-'))
@@ -24,6 +24,9 @@ it('keeps Computer Use off by default and persists its explicit profile-wide PTC
browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]')
await writeComposerDraft(page, input, 'Retained draft')
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
@@ -42,6 +45,10 @@ it('keeps Computer Use off by default and persists its explicit profile-wide PTC
await expect.poll(() => scaffold.ctx.ptcRuntime.programFormat).toBe('repl')
expect(selected()).toContain('@deepseek-ai/dsh-cua-bundle')
expect(scaffold.ctx.get('computerUse')).toBeDefined()
await page.getByRole('treeitem', { name: '新会话', exact: true }).click()
await input.and(page.locator('[contenteditable="true"]')).waitFor()
expect(await input.textContent()).toBe('Retained draft')
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const runtime = scaffold.ctx.ptcRuntime
const stateOwner = {}
const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1'
@@ -53,6 +60,9 @@ it('keeps Computer Use off by default and persists its explicit profile-wide PTC
await expect.poll(() => scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
expect(selected()).not.toContain('@deepseek-ai/dsh-cua-bundle')
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
await page.getByRole('treeitem', { name: '新会话', exact: true }).click()
await input.and(page.locator('[contenteditable="true"]')).waitFor()
expect(await input.textContent()).toBe('Retained draft')
} finally {
try { await browser?.close() } finally {
try { await ownedScaffold?.close() } finally { await rm(directory, { recursive: true, force: true }) }
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: b1a66e01de414924
zh: 7c01f66b94b01ff9
en: 01d523b8fc1b89c0
zh: 915b7100e911f6d6
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+1 -1
View File
@@ -35,7 +35,7 @@ Client list rows and resident Sessions use the current `sessionListMetadata` pro
Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies.
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children.
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. A current Host addition or reconciled list restores a retained Session after plugin reload, preserving its draft and clearing the removed state; a removal received during a list request takes precedence over that response.
Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only.
+1 -1
View File
@@ -35,7 +35,7 @@ Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠
显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 当前 Host 新增事件或对账后的列表会恢复插件重载前保留的 Session,清除移除状态并保留草稿;列表请求期间收到的移除事件优先于该次响应。
后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。
@@ -426,6 +426,7 @@ export class SessionManager {
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
if (session === undefined) continue
session.handlePresence(true)
session.handleBlank(this.effectiveBlank(s))
session.handleRunning(s.running)
}
@@ -642,6 +643,7 @@ export class SessionManager {
handleSessionAdded(summary: SessionSummary): void {
this.mergeSummary(summary)
if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId)
this.sessions.get(summary.sessionId)?.handlePresence(true)
this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary))
if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections)
}
@@ -681,7 +683,7 @@ export class SessionManager {
? { kind: 'status', sessionId, running: false, agentAvailable: false }
: { kind: 'remove', sessionId })
if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
else this.sessions.get(sessionId)?.handleRemoved()
else this.sessions.get(sessionId)?.handlePresence(false)
const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog
if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
this.projectionStores.delete(sessionId)
@@ -577,9 +577,12 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
handleRemoved(): void {
this.removed = true
/**
* Update Host membership while retaining the resident instance and its draft.
* @param present - Whether a current Host event or reconciled list contains this Session.
*/
handlePresence(present: boolean): void {
this.removed = !present
this.notifier.markDirty()
}
@@ -829,8 +829,10 @@ describe('remaining branches', () => {
const before = session.getSnapshot()
session.handleRunning(false) // already false: dedup branch
expect(session.getSnapshot()).toBe(before)
session.handleRemoved()
session.handlePresence(false)
expect(session.getSnapshot().removed).toBe(true)
session.handlePresence(true)
expect(session.getSnapshot().removed).toBe(false)
})
it('drops live events while cold/error (no window upkeep)', async ({ mock, start }) => {
@@ -1013,6 +1013,43 @@ describe('catalog arrival', () => {
})
})
describe('Host membership recovery', () => {
it.each(['event', 'list'] as const)('restores a retained Session through a current %s after plugin reload', async (source, { bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const session = reference.binding.session
b.svc.handleSessionRemoved(sid('s1'))
expect(session.getSnapshot().removed).toBe(true)
if (source === 'event') {
b.svc.handleSessionAdded({ agentAvailable: true, sessionId: sid('s1'), updatedAt: 2, running: false, blank: false })
} else {
await feedList(b, [{ id: 's1' }])
}
expect(b.svc.binding(sid('s1'))).toBe(reference.binding)
expect(session.getSnapshot().removed).toBe(false)
})
it('keeps a removal newer than an in-flight list response', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
b.unblock.push(() => { gate.resolve() })
b.mock.remote.session.list.mockImplementation(async () => {
await gate.promise
return ok({ items: [{ agentAvailable: true, sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] })
})
const refresh = b.svc.refresh()
b.svc.handleSessionRemoved(sid('s1'))
gate.resolve()
await refresh
expect(reference.binding.session.getSnapshot().removed).toBe(true)
})
})
describe('blank mirror', () => {
it('flips blank=false from the running:true status frame (cross-client conversion)', async ({ bench }) => {
const b = bench()