diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index cff13431e1..2a725c5029 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -71,6 +71,21 @@ export async function ensureSidebarExpanded(page: Page): Promise { ).toBeVisible(); } +/** + * Wait for the default Host's Coordination Session and the WorkHub projection + * to agree that the surface is ready. A mounted WorkHub main is not sufficient: + * it is also rendered while the Host reconnects and the projection reloads. + */ +export async function waitForWorkHubReady(page: Page, workCount: number): Promise { + await expect + .poll(async () => { + const snapshot = await page.evaluate(() => window.maka.runtimeHostProfiles.getSnapshot()); + return snapshot.entries.find(({ isDefault }) => isDefault)?.readiness; + }) + .toBe('ready'); + await expect(page.getByText(`${workCount} 项工作`, { exact: true })).toBeVisible(); +} + /** * Wait for Runtime's authoritative Skill projection, not merely for the * composer DOM to mount. The renderer requests this projection after its first diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 28523dff4c..1e25de1205 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -17,9 +17,9 @@ * under the License. */ -import { expect, test, COMPOSER_INPUT } from './fixtures'; +import { COMPOSER_INPUT, expect, test, waitForWorkHubReady } from './fixtures'; -test('WorkHub target metadata does not overlap the submitted Session result', async ({ +test('WorkHub target metadata remains within the submitted Session control', async ({ window: page, }) => { const composer = page.locator(COMPOSER_INPUT); @@ -36,31 +36,26 @@ test('WorkHub target metadata does not overlap the submitted Session result', as await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); - await expect(page.getByText('1 项工作', { exact: true })).toBeVisible(); + await waitForWorkHubReady(page, 1); + const routedPrompt = `继续${sessionName},补充重复投递测试点。`; const workHubComposer = page.locator( '.workhub-surface .maka-composer-editor [contenteditable="true"]', ); - await workHubComposer.fill(`继续${sessionName},补充重复投递测试点。`); + await workHubComposer.fill(routedPrompt); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-result')).toBeVisible(); + const submittedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); + await expect(submittedTurn.locator('.workhub-submitted-session small')).toBeVisible(); - const geometry = await page.evaluate(() => { - const button = document.querySelector('.workhub-submitted > button')!; + const buttonContainsProject = await submittedTurn.evaluate((turn) => { + const button = turn.querySelector('.workhub-submitted > button')!; const project = button.querySelector('.workhub-submitted-session small')!; - const result = document.querySelector('.workhub-result')!; const buttonBox = button.getBoundingClientRect(); const projectBox = project.getBoundingClientRect(); - const resultBox = result.getBoundingClientRect(); - return { - buttonContainsProject: buttonBox.bottom >= projectBox.bottom, - overlapHeight: - Math.min(projectBox.bottom, resultBox.bottom) - Math.max(projectBox.top, resultBox.top), - }; + return buttonBox.bottom >= projectBox.bottom; }); - expect(geometry.buttonContainsProject).toBe(true); - expect(geometry.overlapHeight).toBeLessThanOrEqual(0); + expect(buttonContainsProject).toBe(true); }); test('WorkHub explains Coordination startup failure and recovers after a default model is set', async ({ diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 0b29525c03..4586b37964 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -17,7 +17,13 @@ * under the License. */ -import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; +import { + COMPOSER_INPUT, + ensureSidebarExpanded, + expect, + test, + waitForWorkHubReady, +} from './fixtures'; test('WorkHub rebuilds delegated execution feedback after navigating away and back', async ({ window: page, @@ -37,10 +43,9 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); // The conversation is the Coordination Session transcript. An ordinary // Session is a routing target and a status row, never a turn in WorkHub. - await expect(page.getByText('1 项工作', { exact: true })).toBeVisible(); + await waitForWorkHubReady(page, 1); await expect(page.locator('.workhub-turn')).toHaveCount(0); await expect(page.locator('.workhub-empty h2')).toHaveText('从这里继续所有工作'); @@ -50,15 +55,14 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba ); await workHubComposer.fill(routedPrompt); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-submitted').last()).toBeVisible(); - await page.locator('.workhub-turn', { hasText: routedPrompt }) - .locator('.workhub-submitted > button') - .click(); + const routedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); + await expect(routedTurn.locator('.workhub-submitted')).toBeVisible(); + await routedTurn.locator('.workhub-submitted > button').click(); await expect(page.getByRole('main', { name: 'WorkHub' })).toBeHidden(); await ensureSidebarExpanded(page); await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); + await waitForWorkHubReady(page, 1); await expect( page.locator('.workhub-projected-turn .workhub-user-bubble > p', { hasText: routedPrompt, @@ -67,7 +71,7 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba await expect( page.locator('.workhub-projected-turn', { hasText: routedPrompt }) .locator('.workhub-submitted-state'), - ).toHaveText('进行中'); + ).toHaveText('已完成'); }); test('WorkHub defers destructive correction until linked delegation exists', async ({ @@ -88,11 +92,10 @@ test('WorkHub defers destructive correction until linked delegation exists', asy await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); - await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); await page.evaluate(async () => { await window.maka.sessions.create({ name: '登录稳定性' }); }); - await expect(page.getByText('2 项工作', { exact: true })).toBeVisible(); + await waitForWorkHubReady(page, 2); const workHubComposer = page.locator( '.workhub-surface .maka-composer-editor [contenteditable="true"]', @@ -105,6 +108,7 @@ test('WorkHub defers destructive correction until linked delegation exists', asy await expect( continuedTurn.locator('.workhub-submitted-session strong'), ).toHaveText(sourceSessionName); + await waitForWorkHubReady(page, 2); await workHubComposer.fill('不是这个,换成登录稳定性,补充刷新令牌失败判定。'); await expect( diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts index c1aa00b35d..55ac1c1fff 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts @@ -19,19 +19,26 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import { startWorkHubCoordinationLifecycle, type WorkHubCoordinationHostChange, } from '../../renderer/workhub-coordination-lifecycle.js'; -test('WorkHub resolves on open and ready Host changes, then stops on feature disable', () => { +const coordinationSessionId = (hostId: string) => desktopSessionKey({ + hostId, + sessionId: 'maka_workhub_coordination', +}); + +test('WorkHub resolves on open and when the default Host authority changes', async () => { let hostChange: ((event: WorkHubCoordinationHostChange) => void) | undefined; let availabilityChange: (() => void) | undefined; const calls: string[] = []; + let activeHostId = 'host-a'; const stop = startWorkHubCoordinationLifecycle({ resolve: () => { calls.push('resolve'); - return Promise.resolve('coordination-session'); + return Promise.resolve(coordinationSessionId(activeHostId)); }, subscribeHostChanges(handler) { hostChange = handler; @@ -47,27 +54,27 @@ test('WorkHub resolves on open and ready Host changes, then stops on feature dis }); assert.deepEqual(calls, ['resolving', 'resolve']); + await Promise.resolve(); hostChange?.({ isDefault: false, readiness: 'ready' }); - hostChange?.({ isDefault: true, readiness: 'reconnecting' }); - assert.deepEqual(calls, ['resolving', 'resolve', 'resolving']); + hostChange?.({ isDefault: true, readiness: 'reconnecting', hostId: 'host-a' }); + assert.deepEqual(calls, [ + 'resolving', + 'resolve', + `resolved:${coordinationSessionId('host-a')}`, + ]); availabilityChange?.(); - assert.deepEqual(calls, ['resolving', 'resolve', 'resolving']); + assert.equal(calls.at(-1), `resolved:${coordinationSessionId('host-a')}`); - hostChange?.({ isDefault: true, readiness: 'ready' }); - assert.deepEqual(calls, ['resolving', 'resolve', 'resolving', 'resolving', 'resolve']); + activeHostId = 'host-b'; + hostChange?.({ isDefault: true, readiness: 'ready', hostId: 'host-b' }); + assert.deepEqual(calls.slice(-2), ['resolving', 'resolve']); + await Promise.resolve(); + assert.equal(calls.at(-1), `resolved:${coordinationSessionId('host-b')}`); stop(); - assert.deepEqual(calls, [ - 'resolving', - 'resolve', - 'resolving', - 'resolving', - 'resolve', - 'unsubscribe-hosts', - 'unsubscribe-availability', - ]); - hostChange?.({ isDefault: true, readiness: 'ready' }); + assert.deepEqual(calls.slice(-2), ['unsubscribe-hosts', 'unsubscribe-availability']); + hostChange?.({ isDefault: true, readiness: 'ready', hostId: 'host-c' }); availabilityChange?.(); assert.equal(calls.at(-1), 'unsubscribe-availability'); }); @@ -134,7 +141,7 @@ test('WorkHub exposes a retry and automatically retries when model availability resolveAttempts += 1; return resolveAttempts < 3 ? Promise.reject(failure) - : Promise.resolve('coordination-session'); + : Promise.resolve(coordinationSessionId('host-a')); }, subscribeHostChanges(handler) { hostChange = handler; @@ -164,7 +171,7 @@ test('WorkHub exposes a retry and automatically retries when model availability availabilityChange?.(); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(failures, [failure, failure, 'coordination-session']); + assert.deepEqual(failures, [failure, failure, coordinationSessionId('host-a')]); availabilityChange?.(); hostChange?.({ isDefault: false, readiness: 'ready' }); @@ -189,12 +196,12 @@ test('WorkHub ignores a stale Host resolution that settles after a newer Host', }); hostChange?.({ isDefault: true, readiness: 'ready' }); - pending[1]?.('host-b-coordination'); + pending[1]?.(coordinationSessionId('host-b')); await Promise.resolve(); - pending[0]?.('host-a-coordination'); + pending[0]?.(coordinationSessionId('host-a')); await Promise.resolve(); - assert.deepEqual(resolved, ['host-b-coordination']); + assert.deepEqual(resolved, [coordinationSessionId('host-b')]); stop(); }); diff --git a/apps/desktop/src/renderer/workhub-coordination-lifecycle.ts b/apps/desktop/src/renderer/workhub-coordination-lifecycle.ts index e5607ff872..e042990049 100644 --- a/apps/desktop/src/renderer/workhub-coordination-lifecycle.ts +++ b/apps/desktop/src/renderer/workhub-coordination-lifecycle.ts @@ -18,10 +18,11 @@ */ import type { DesktopRuntimeHostProfileChangedEvent } from '../preload/bridge-contract.js'; +import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; export type WorkHubCoordinationHostChange = Pick< DesktopRuntimeHostProfileChangedEvent, - 'isDefault' | 'readiness' | 'removed' + 'hostId' | 'isDefault' | 'readiness' | 'removed' >; const UNAVAILABLE_DEFAULT_HOST = 'The default Runtime Host is unavailable'; @@ -40,10 +41,12 @@ export function startWorkHubCoordinationLifecycle(input: { let stopped = false; let generation = 0; let failedGeneration: number | undefined; + let resolvedHostId: string | undefined; const revoke = () => { const currentGeneration = ++generation; failedGeneration = undefined; + resolvedHostId = undefined; input.onResolving(); return currentGeneration; }; @@ -57,6 +60,7 @@ export function startWorkHubCoordinationLifecycle(input: { void input.resolve() .then((sessionId) => { if (stopped || currentGeneration !== generation) return; + resolvedHostId = parseDesktopSessionKey(sessionId).hostId; failedGeneration = undefined; input.onResolved(sessionId); }) @@ -69,6 +73,14 @@ export function startWorkHubCoordinationLifecycle(input: { const unsubscribeHosts = input.subscribeHostChanges((event) => { if (stopped || !event.isDefault) return; + if ( + resolvedHostId !== undefined && + event.hostId === resolvedHostId && + event.readiness !== 'unavailable' && + !event.removed + ) { + return; + } const currentGeneration = revoke(); if (event.readiness === 'ready') { resolveGeneration(currentGeneration);