Skip to content
Open
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
68 changes: 68 additions & 0 deletions apps/desktop/e2e/workhub-reconstruction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@

import { expect, test, COMPOSER_INPUT } from './fixtures';

type WorkHubEvidenceWindow = Window & {
makaE2eLatch?: {
arm(key: 'workHub.record', options?: { oneShot?: boolean }): void;
reject(key: 'workHub.record', message: string): void;
waitForCall(key: 'workHub.record'): Promise<void>;
};
};

test('WorkHub rebuilds Session conversation after navigating away and back', async ({
window: page,
}) => {
Expand Down Expand Up @@ -65,6 +73,66 @@ test('WorkHub rebuilds Session conversation after navigating away and back', asy
).toBeVisible();
});

test('WorkHub retries one accepted action after summary failure and renderer reload', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('检查支付回调重复投递时的幂等性');
await composer.press('Enter');
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
await page.evaluate(async () => {
await window.maka.settings.updateClient({ workHub: { enabled: true } });
});
await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible();

const latchInstalled = await page.evaluate(() => {
const e2e = window as WorkHubEvidenceWindow;
if (!e2e.makaE2eLatch) return false;
e2e.makaE2eLatch.arm('workHub.record', { oneShot: true });
return true;
});
expect(latchInstalled, 'the isolated E2E summary latch is installed').toBe(true);

const routedPrompt = '继续这个工作,补充重复投递测试点。';
const workHubComposer = page.locator(
'.workhub-surface .maka-composer-editor [contenteditable="true"]',
);
await workHubComposer.fill(routedPrompt);
const recordReached = page.evaluate(() =>
(window as WorkHubEvidenceWindow).makaE2eLatch?.waitForCall('workHub.record'),
);
await workHubComposer.press('Enter');
await recordReached;
await page.evaluate(() => {
(window as WorkHubEvidenceWindow).makaE2eLatch?.reject(
'workHub.record',
'forced WorkHub summary failure',
);
});

const failed = page.locator('.workhub-turn', { hasText: routedPrompt });
await expect(failed.locator('.workhub-error')).toContainText('输入未能送达');
await expect(workHubComposer).toHaveText(routedPrompt);

await page.reload();

await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible();
const reloadedComposer = page.locator(
'.workhub-surface .maka-composer-editor [contenteditable="true"]',
);
await expect(reloadedComposer).toHaveText(routedPrompt);
await reloadedComposer.press('Enter');

await expect(page.locator('.workhub-submitted').last()).toBeVisible();
await expect(
page.locator('.workhub-user-bubble > p', {
hasText: routedPrompt,
}),
).toHaveCount(1);
});

test('WorkHub defers destructive correction until linked delegation exists', async ({
window: page,
}) => {
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EMPTY_USAGE_PROVENANCE } from '@maka/core/usage-ledger-merge';
import {
projectDesktopSessionEvent,
projectDesktopSessionSummary,
projectDesktopStoredMessage,
projectDesktopTurnRecord,
projectDesktopUsageStats,
} from '../../shared/desktop-session-projection.js';
Expand Down Expand Up @@ -199,6 +200,33 @@ test('projects only present Usage Session ids into the Desktop host namespace',
assert.equal(projected.logs[1]?.sessionId, undefined);
});

test('projects durable WorkHub delegation targets into the Desktop host namespace', () => {
const projected = projectDesktopStoredMessage(
{ hostId: 'remote-root' },
{
type: 'workhub_coordination',
id: 'delegation-commit-message',
turnId: 'coordination-turn',
ts: 2,
schemaVersion: 1,
kind: 'delegation_committed',
actionId: 'action-id',
actionFingerprint: `sha256:${'a'.repeat(64)}`,
coordinationTurnId: 'coordination-turn',
targetSessionId: 'payments',
disposition: 'delegate_existing',
userText: 'Continue payment work',
delegationId: 'delegation-id',
targetTurnId: 'payments-turn',
},
);

assert.equal(projected.type, 'workhub_coordination');
if (projected.type === 'workhub_coordination') {
assert.equal(projected.targetSessionId, JSON.stringify(['remote-root', 'payments']));
}
});

function summary(id: string): SessionSummary {
return {
id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import assert from 'node:assert/strict';
import test from 'node:test';
import { RuntimeHostOperationError } from '@maka/runtime-host/client';
import { registerRuntimeHostWorkHubIpc } from '../runtime-host-workhub-ipc-main.js';

test('projects WorkHub coordination resolution through its dedicated IPC domain', async () => {
Expand Down Expand Up @@ -111,9 +112,12 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain'
},
}),
{
disposition: 'create_new',
targetSessionId: createdSessionId,
targetTurnId: 'created-turn',
ok: true,
result: {
disposition: 'create_new',
targetSessionId: createdSessionId,
targetTurnId: 'created-turn',
},
},
);
assert.deepEqual(actions, [{
Expand All @@ -126,3 +130,43 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain'
}]);
assert.deepEqual(changes, [{ reason: 'created', sessionId: createdSessionId }]);
});

test('serializes typed WorkHub action failures across Electron IPC', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
registerRuntimeHostWorkHubIpc(
{
actWorkHubCoordination: async () => {
throw new RuntimeHostOperationError(
'workhub.coordination.act',
'operation_conflict',
'WorkHub action is permanently abandoned',
);
},
} as never,
{
handle: (channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler);
},
} as never,
{
resolveCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }),
emitSessionsChanged: () => undefined,
},
);

assert.deepEqual(
await handlers.get('workhub:act')?.({}, {
actionId: 'abandoned-action',
userText: 'Continue payment work',
candidateSetId: `sha256:${'a'.repeat(64)}`,
proposal: { disposition: 'delegate_existing', candidateRef: 'candidate' },
}),
{
ok: false,
error: {
code: 'operation_conflict',
message: 'WorkHub action is permanently abandoned',
},
},
);
});
48 changes: 48 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,54 @@ test('production submission delegates only through the Runtime-owned candidate r
}]);
});

test('production retry reaches durable Action Gate replay while target is waiting', async () => {
const actions: unknown[] = [];
const sessions = port([
session('payment', { state: 'waiting_for_user' }),
]);
const controller = createGatedWorkHubController({
sessions,
coordination: {
open: async () => ({ close: async () => undefined }),
answer: async (input) => ({ turnId: input.turnId }),
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => ({
candidateSetId: `sha256:${'c'.repeat(64)}`,
candidates: [{
candidateRef: 'candidate-payment',
sessionId: 'payment',
sessionName: 'payment',
workspace: {
target: { kind: 'host_path', path: '/workspace/payment' },
hostCwd: '/workspace/payment',
},
state: 'waiting_for_user',
updatedAt: 2,
}],
}),
act: async (input) => {
actions.push(input);
return {
disposition: 'delegate_existing',
targetSessionId: 'payment',
targetTurnId: 'already-committed-turn',
};
},
},
});

const result = await controller.submit({
requestId: 'summary-recovery-action',
text: '继续支付工作',
explicitTarget: { sessionId: 'payment' },
retryAction: true,
});

assert.equal(result.kind, 'submitted');
assert.equal(result.kind === 'submitted' ? result.turnId : undefined, 'already-committed-turn');
assert.equal(actions.length, 1);
});

test('production defers destructive correction until persistent delegation exists', async () => {
const actions: unknown[] = [];
const sessions = port([session('source'), session('target')]);
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/main/__tests__/workhub-session-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,11 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and
candidates: [],
}),
act: async () => ({
disposition: 'answer_here',
coordinationTurnId: 'coordination-turn',
ok: true,
result: {
disposition: 'answer_here',
coordinationTurnId: 'coordination-turn',
},
}),
});

Expand Down
Loading