fix(cua): keep existing conversations usable after activation
This commit is contained in:
1 parent
e0d9be74df
commit
ce11b81599
8 files changed
+64
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user