Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ export async function ensureSidebarExpanded(page: Page): Promise<void> {
).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<void> {
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
Expand Down
27 changes: 11 additions & 16 deletions apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<HTMLElement>('.workhub-submitted > button')!;
const buttonContainsProject = await submittedTurn.evaluate((turn) => {
const button = turn.querySelector<HTMLElement>('.workhub-submitted > button')!;
const project = button.querySelector<HTMLElement>('.workhub-submitted-session small')!;
const result = document.querySelector<HTMLElement>('.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 ({
Expand Down
26 changes: 15 additions & 11 deletions apps/desktop/e2e/workhub-reconstruction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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('从这里继续所有工作');

Expand All @@ -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,
Expand All @@ -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 ({
Expand All @@ -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"]',
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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' });
Expand All @@ -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();
});

Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src/renderer/workhub-coordination-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
};
Expand All @@ -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);
})
Expand All @@ -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);
Expand Down