diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index 3779b7de90..b9b073c917 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -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 }) } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index c1f577ec93..803c8a1924 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index d1ece35b6b..5865369f76 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -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. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index ae4c924b1f..b063d35698 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -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);控制流只承载投影。 diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 50af09df13..9fce3579dc 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -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) diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 284abc5656..f0764da6fc 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -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() } diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index ccba6d3a6a..0c325e4462 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -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 }) => { diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index ef3012c393..1748db55d7 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -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 = 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()