diff --git a/.github/assets/workhub-turn-admission/receipts-dark.png b/.github/assets/workhub-turn-admission/receipts-dark.png new file mode 100644 index 0000000000..2d6fb9594a Binary files /dev/null and b/.github/assets/workhub-turn-admission/receipts-dark.png differ diff --git a/.github/assets/workhub-turn-admission/receipts-light.png b/.github/assets/workhub-turn-admission/receipts-light.png new file mode 100644 index 0000000000..7042dcdb20 Binary files /dev/null and b/.github/assets/workhub-turn-admission/receipts-light.png differ diff --git a/apps/desktop/e2e/new-task-reload.spec.ts b/apps/desktop/e2e/new-task-reload.spec.ts index b35056981d..a6c54cac76 100644 --- a/apps/desktop/e2e/new-task-reload.spec.ts +++ b/apps/desktop/e2e/new-task-reload.spec.ts @@ -28,6 +28,9 @@ test('archived-only history boots into a usable new task', async ({ window: page await composer.press('Enter'); await expect(reply).toBeVisible({ timeout: 20_000 }); + // Visible streaming text is not proof that the Host has released the Turn. + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1); + // Prove bootstrap can restore this history before archiving it. await page.reload(); await expect(reply).toBeVisible(); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 3acf19c471..f1cc999677 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -36,10 +36,13 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba timeout: 20_000, }); - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); + // This test owns navigation identity, not asynchronous title generation. + const sessionName = initialPrompt; + await page.evaluate(async (name) => { + const session = (await window.maka.sessions.list())[0]; + if (!session) throw new Error('Source Session was not found'); + await window.maka.sessions.rename(session.id, name); + }, sessionName); await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4712d5e0c1..098cfae5ac 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -747,7 +747,6 @@ "window.maka.transcripts": 2, "window.maka.workHub.act": 1, "window.maka.workHub.candidates": 1, - "window.maka.workHub.record": 1, "window.maka.workHub.resolveCoordinationSession": 1 }, "environmentCapabilities": { @@ -895,7 +894,7 @@ "react": 1 }, "importSpecifiers": 116, - "nonTriviaTokens": 14338 + "nonTriviaTokens": 14318 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 373f18d046..3922353f03 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -10155,7 +10155,7 @@ THE SOFTWARE. ================================================================================ -Package: js-yaml@4.3.1 +Package: js-yaml@4.3.2 Declared license: MIT Selected license: MIT Repository: nodeca/js-yaml diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 302d644243..e5e97357da 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -100,7 +100,6 @@ test('resolves WorkHub coordination through the dedicated Host operation', async { sessionId: 'maka_workhub_coordination' }, { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }, { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, - { turnId: 'summary-turn' }, ]); assert.deepEqual(await client.resolveWorkHubCoordinationSession(), { @@ -118,14 +117,6 @@ test('resolves WorkHub coordination through the dedicated Host operation', async }), { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, ); - assert.deepEqual( - await client.recordWorkHubCoordination({ - turnId: 'summary-turn', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'summary-turn' }, - ); assert.deepEqual(requests, [ { operation: 'workhub.coordination.resolve', input: {} }, { operation: 'workhub.coordination.candidates', input: {} }, @@ -137,14 +128,7 @@ test('resolves WorkHub coordination through the dedicated Host operation', async proposal: { disposition: 'answer_here' }, }, }, - { - operation: 'workhub.coordination.record', - input: { - turnId: 'summary-turn', - userText: 'Request', - assistantText: 'Summary', - }, - }, + ]); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts index 33e8313b62..bd15684858 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -25,7 +25,6 @@ import { registerRuntimeHostWorkHubIpc } from '../runtime-host-workhub-ipc-main. test('projects WorkHub coordination resolution through its dedicated IPC domain', async () => { const handlers = new Map unknown>(); let resolveCalls = 0; - const records: unknown[] = []; const actions: unknown[] = []; const changes: unknown[] = []; const createdSessionId = 'runtime-created-session'; @@ -35,14 +34,6 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' resolveCalls += 1; return { sessionId: 'maka_workhub_coordination' }; }, - recordWorkHubCoordination: async (input: { - turnId: string; - userText: string; - assistantText: string; - }) => { - records.push(input); - return { turnId: input.turnId }; - }, listWorkHubCoordinationCandidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -74,19 +65,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' assert.ok(handler); assert.deepEqual(await handler({}), { sessionId: 'maka_workhub_coordination' }); assert.equal(resolveCalls, 1); - assert.deepEqual( - await handlers.get('workhub:record')?.({}, { - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'record' }, - ); - assert.deepEqual(records, [{ - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }]); + assert.equal(handlers.has('workhub:record'), false); assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -123,14 +102,15 @@ 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 () => { +for (const code of ['operation_conflict', 'candidate_set_stale'] as const) { +test(`serializes typed WorkHub action failures across Electron IPC (${code})`, async () => { const handlers = new Map unknown>(); registerRuntimeHostWorkHubIpc( { actWorkHubCoordination: async () => { throw new RuntimeHostOperationError( 'workhub.coordination.act', - 'operation_conflict', + code, 'WorkHub action is permanently abandoned', ); }, @@ -156,9 +136,10 @@ test('serializes typed WorkHub action failures across Electron IPC', async () => { ok: false, error: { - code: 'operation_conflict', + code, message: 'WorkHub action is permanently abandoned', }, }, ); }); +} diff --git a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts index e0dcbb3c1a..d2f701052f 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts @@ -83,7 +83,7 @@ export function createWorkHubController({ ...(routingStrategy ? { routingStrategy } : {}), coordination: { open: async (handler) => { handler(transcript); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { const candidates = (await sessions.list()) .filter((entry) => entry.kind === 'ordinary' && !entry.archived) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 98b68f833d..04e259ca0a 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -112,7 +112,7 @@ test('conversation acknowledges a durable assignment before projecting target ex handler([assignment]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -159,7 +159,7 @@ test('conversation feedback never lets an older refresh overwrite newer target s handler([assignment]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -201,7 +201,7 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati handler([coordinationAssignmentTurn()]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { candidateReads += 1; return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; @@ -257,7 +257,7 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), }, @@ -284,7 +284,7 @@ test('a named resume submits and reports what the Host did', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -329,7 +329,7 @@ test('an anaphoric resume asks for a named work item', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('resume clarification must not read route candidates'), act: async () => assert.fail('anaphoric resume must not reach the Action Gate'), }, @@ -356,7 +356,7 @@ test('a resume the Host will not admit becomes its clarification', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -394,7 +394,7 @@ test('a resume identity conflict is not mislabeled as a missing target', async ( handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -421,7 +421,7 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -455,7 +455,7 @@ test('a recovering Runtime Host tells the user to retry resume', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -486,7 +486,7 @@ test('a named stop reports the Gate refusal instead of judging the target itself handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => { submitted += 1; @@ -520,7 +520,7 @@ test('a stop that fails for any other reason is a fault, not a clarification', a handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => { throw new WorkHubCoordinationFailure('persistence_failed', 'WorkHub stop state is unavailable'); @@ -550,7 +550,7 @@ test('stop-shaped ordinary work routes normally instead of looping on clarificat handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ @@ -1669,7 +1669,7 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -1716,7 +1716,7 @@ test('production submission delegates only through the Runtime-owned candidate r sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [{ @@ -1771,7 +1771,7 @@ test('production retry reaches durable Action Gate replay while target is waitin sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [{ @@ -1817,7 +1817,7 @@ test('production sends an explicit correction as a linked replacement', async () sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [ @@ -1943,7 +1943,7 @@ test('production natural-language corrections retain the prior delegation link', sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId, candidates: candidates.map((candidate) => { @@ -2043,7 +2043,7 @@ test('production correction-shaped creation stays create_new without an existing sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -2074,9 +2074,6 @@ test('production clarification is persisted through the typed Action Gate dispos sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - record: async () => { - throw new Error('legacy summary recording must not persist clarification'); - }, candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -2091,11 +2088,10 @@ test('production clarification is persisted through the typed Action Gate dispos }, }); - assert.deepEqual(await controller.recordConversationTurn({ + assert.deepEqual(await controller.requestClarification({ turnId: 'clarification-action', userText: '继续稳定性问题', assistantText: '请选择目标 Session', - disposition: 'clarify', }), { turnId: 'clarification-turn' }); assert.deepEqual(actions, [{ actionId: 'clarification-action', @@ -2117,7 +2113,7 @@ test('production creation leaves Session identity and workspace authority to mai sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -3281,7 +3277,7 @@ for (const createStrategy of [createWorkHubR24RoutingStrategy, () => createWorkH routingStrategy, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'payments-ref', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }] }), act: async (input) => { assert.equal(input.proposal.disposition, 'resume_work'); @@ -3362,3 +3358,45 @@ test('composer defaults apply only to creation while attachments follow explicit assert.deepEqual(actions[1]?.newWorkDefaults, newWorkDefaults); assert.deepEqual(actions[1]?.attachments, attachments); }); + +for (const mode of ['refresh', 'missing', 'renamed', 'churn', 'conflict'] as const) { + test(`replacement candidate refresh preserves the chosen Session (${mode})`, async () => { + const actions: WorkHubCoordinationActInput[] = []; + let reads = 0; + const controller = createGatedWorkHubController({ + sessions: port([session('source'), session('target')]), + coordination: { + open: async () => ({ close: async () => undefined }), + candidates: async () => { + const version = reads++; + return { + candidateSetId: `sha256:${String(version).repeat(64)}`, + candidates: ['other', 'target', 'source'].filter((id) => !(mode === 'missing' && version > 0 && id === 'target')).map((id) => ({ + candidateRef: `${id}-${version}`, sessionId: id, + sessionName: mode === 'renamed' && version > 0 && id === 'target' ? 'different work' : id, + workspace: { target: { kind: 'host_path' as const, path: `/workspace/${id}` }, hostCwd: `/workspace/${id}` }, + state: 'active' as const, updatedAt: version, + })), + }; + }, + act: async (input) => { + actions.push(input); + if (actions.length === 1 || mode === 'churn') throw new WorkHubCoordinationFailure( + mode === 'conflict' ? 'operation_conflict' : 'candidate_set_stale', 'Snapshot changed'); + return { disposition: 'replace', replacementDisposition: 'delegate_existing', targetSessionId: 'target', targetTurnId: 'replacement-turn' }; + }, + }, + }); + const submit = () => controller.submit({ newSessionFallbackTitle: 'New task', requestId: 'same-action', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' } }); + if (mode === 'refresh') { + assert.equal((await submit()).kind, 'submitted'); + assert.equal(actions.length, 2); + assert.deepEqual(actions.map((action) => action.actionId), ['same-action', 'same-action']); + assert.deepEqual(actions.map((action) => action.proposal), [0, 1].map((version) => ({ disposition: 'replace', replacesActionId: 'source-action', target: { disposition: 'delegate_existing', candidateRef: `target-${version}` } }))); + assert.notEqual(actions[0]!.candidateSetId, actions[1]!.candidateSetId); + } else { + await assert.rejects(submit, WorkHubCoordinationFailure); + assert.equal(actions.length, mode === 'churn' ? 3 : 1); + } + }); +} diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 8d3220cfb8..3cba8f91ee 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -138,7 +138,6 @@ test('Coordination tail recovery converges through the preload with a fragmented return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); }, }, - record: async (input) => ({ turnId: input.turnId }), candidates: async () => assert.fail('unused'), act: async () => assert.fail('unused'), }); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index e5b668ca78..c4568225bb 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -106,68 +106,71 @@ function transcriptsWith(messages: readonly StoredMessage[]) { }; } -test('projects the durable Coordination transcript into the WorkHub conversation', () => { - const messages: StoredMessage[] = [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 11, - text: 'Slice 3 is next.', - modelId: 'test-model', - }, - { - type: 'turn_state', - id: 'state-1', +for (const physicalTurnId of ['action-1', 'action-1-retry']) { + test(`projects the durable Coordination transcript into the WorkHub conversation (${physicalTurnId})`, () => { + const messages: StoredMessage[] = [ + { type: 'user', id: 'action-user', turnId: physicalTurnId, ts: 19, text: 'Continue payments' }, + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 11, + text: 'Slice 3 is next.', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 12, + status: 'completed', + }, + { + type: 'workhub_coordination', + id: 'assignment-1', + turnId: physicalTurnId, + ts: 20, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-1', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: physicalTurnId, + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }, + ]; + assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ + messageId: 'user-1', turnId: 'turn-1', - ts: 12, - status: 'completed', - }, - { - type: 'workhub_coordination', - id: 'assignment-1', + text: 'What is next?', + result: 'Slice 3 is next.', + state: 'completed', + updatedAt: 11, + }, { + messageId: 'assignment-1', turnId: 'action-1', - ts: 20, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-1', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-1', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', - delegationId: 'payments-delegation', - disposition: 'delegate_existing', - userText: 'Continue payments', - }, - ]; - assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ - messageId: 'user-1', - turnId: 'turn-1', - text: 'What is next?', - result: 'Slice 3 is next.', - state: 'completed', - updatedAt: 11, - }, { - messageId: 'assignment-1', - turnId: 'action-1', - text: 'Continue payments', - state: 'completed', - assignment: { - actionId: 'action-1', - delegationId: 'payments-delegation', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetMessageId: 'payments-message', - targetTurnId: 'payments-turn', - feedbackState: 'accepted', - linkState: 'active', - }, - updatedAt: 20, - }]); -}); + text: 'Continue payments', + state: 'completed', + assignment: { + actionId: 'action-1', + delegationId: 'payments-delegation', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetMessageId: 'payments-message', + targetTurnId: 'payments-turn', + feedbackState: 'accepted', + linkState: 'active', + }, + updatedAt: 20, + }]); + }); +} test('bounds the visible timeline independently of old delegation linkage', () => { const assignment: StoredMessage = { @@ -309,6 +312,11 @@ test('direct-stop projection is retryable until resolved and preserves not_owned outcome: 'not_owned', }); assert.equal(projected[0]?.assignment?.linkState, 'active'); + const retriedStop = { ...requested, turnId: 'stop-retry', coordinationTurnId: 'stop-retry' }; + assert.equal( + projectWorkHubCoordinationTurns([assignment, retriedStop])[1]?.turnId, + 'stop-action', + ); const stopped = { ...notOwned, outcome: 'stop_delivered' as const }; assert.equal( @@ -448,7 +456,7 @@ test('Coordination transcript adapter never replays history and completes only t }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -532,7 +540,7 @@ test('Coordination transcript adapter retries latest-record completion in the sa }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -630,7 +638,7 @@ test('Coordination transcript adapter ignores a stale latest-record failure afte }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -1158,3 +1166,55 @@ test('desktop adapter derives stable origin evidence from the existing Session l assert.deepEqual(second, first); assert.equal(reads, 1); }); + + +test('admitted clarification and resume project receipts without assistant messages', () => { + const turns = projectWorkHubCoordinationTurns([ + { type: 'user', id: 'failed-user', turnId: 'request', ts: 0, text: 'Which task?' }, + { type: 'turn_state', id: 'failed-state', turnId: 'request', ts: 1, status: 'failed' }, + { type: 'user', id: 'u', turnId: 'retry-turn', ts: 1, text: 'Which task?' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'receipt', turnId: 'retry-turn', ts: 2, + receipt: { actionId: 'request', userText: 'Which task?', clarification: 'Please name a task.', + result: { disposition: 'clarify', coordinationTurnId: 'retry-turn' } } }, + { type: 'turn_state', id: 'done', turnId: 'retry-turn', ts: 3, status: 'completed' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resume-receipt', turnId: 'resume-turn', ts: 4, + receipt: { actionId: 'resume', userText: 'Resume Payments', + result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, + { type: 'turn_state', id: 'resume-done', turnId: 'resume-turn', ts: 5, status: 'completed' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resume-retry-receipt', turnId: 'resume-retry-turn', ts: 6, + receipt: { actionId: 'resume', userText: 'Resume Payments', + result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, + { type: 'turn_state', id: 'resume-retry-done', turnId: 'resume-retry-turn', ts: 7, status: 'completed' }, + + ]); + assert.equal(turns.length, 2); + assert.equal(turns[0]?.turnId, 'request'); + assert.equal(turns[0]?.result, 'Please name a task.'); + assert.equal(turns[0]?.state, 'completed'); + assert.equal(turns[1]?.result, undefined); + assert.deepEqual(turns[1]?.resume, { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' }); +}); + + +test('failed action inputs remain visible until a visible receipt replaces every physical retry', () => { + const failed: StoredMessage[] = ['resume', 'retry-one', 'retry-two'].flatMap((turnId, index) => [ + { type: 'user' as const, id: `u-${turnId}`, turnId, ts: index * 2, + text: 'Resume Payments', coordinationActionId: 'resume' }, + { type: 'turn_state' as const, id: `s-${turnId}`, turnId, ts: index * 2 + 1, status: 'failed' as const }, + ]); + const receipt: StoredMessage = { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resumed', turnId: 'retry-three', ts: 10, + receipt: { actionId: 'resume', userText: 'Resume Payments', result: { + disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'original-target', + } } }; + assert.deepEqual(projectWorkHubCoordinationTurns(failed).map((row) => [row.text, row.state, row.coordinationActionId]), + Array.from({ length: 3 }, () => ['Resume Payments', 'failed', 'resume'])); + const visible = projectWorkHubCoordinationTurns([...failed, receipt]); + assert.equal(visible.length, 1); + assert.equal(visible[0]?.resume?.targetTurnId, 'original-target'); + // A bounded older page with no visible receipt must still reconstruct its inputs. + assert.equal(projectWorkHubCoordinationTurns(failed).length, 3); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 0644ad6553..40b16980da 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -18,6 +18,8 @@ */ import assert from 'node:assert/strict'; +import type { StoredMessage } from '@maka/core/session'; +import { projectWorkHubCoordinationTurns } from '../../renderer/workhub-coordination-port.js'; import test from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; @@ -449,7 +451,7 @@ test('surface keeps clarification and successful routing in WorkHub', async () = handler([]); return { close: async () => undefined }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), resetVisitContext: () => {}, subscribe: () => () => {}, submit: async (input) => { @@ -511,7 +513,7 @@ test('ambiguous creation is durably clarified before a fresh imperative creates }, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -593,7 +595,7 @@ test('surface leaves discussion in WorkHub instead of creating a task view', asy handler([]); return { close: async () => undefined }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), resetVisitContext: () => {}, subscribe: () => () => {}, submit: async (input) => ({ @@ -684,7 +686,7 @@ test('real Session projection creates new guide topics and preserves origin ambi sessions: port, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: sessions.map((entry) => ({ @@ -879,7 +881,7 @@ test('successful delegated submission needs no renderer summary write', async () assert.equal(records, 0); }); -test('resume records ordinary conversation text without persisting execution fields', async () => { +test('resume relies on the admitted Host receipt without a second conversation write', async () => { const records: unknown[] = []; const controller = fakeController({ submit: async (input) => ({ @@ -895,11 +897,7 @@ test('resume records ordinary conversation text without persisting execution fie summary: () => 'Resume requested. See the target Session for current progress.', onSummaryError: () => assert.fail('conversation write must succeed'), }); - assert.deepEqual(records, [{ - turnId: 'resume-1', userText: 'Resume Payments', - assistantText: 'Resume requested. See the target Session for current progress.', disposition: 'summary', - }]); -}); + assert.deepEqual(records, []);}); test('lease retires only after an acknowledged submission', async () => { const { storage } = memoryStorage(); @@ -969,14 +967,53 @@ function memoryStorage() { function fakeController(input: { submit: WorkHubController['submit']; - record: WorkHubController['recordConversationTurn']; + record: WorkHubController['requestClarification']; }): WorkHubController { return { read: async () => ({ sessions: [], turns: [] }), submit: input.submit, openConversation: async () => ({ close: async () => undefined }), - recordConversationTurn: input.record, + requestClarification: input.record, subscribe: () => () => undefined, resetVisitContext: () => undefined, }; } + +for (const kind of ['clarify', 'resume'] as const) { + for (const state of ['failed', 'routing'] as const) { + test(`committed ${kind} result outranks local ${state} transport uncertainty`, () => { + const messages: StoredMessage[] = [ + { type: 'user', id: 'input', turnId: 'physical-turn', coordinationActionId: 'action', ts: 1, text: 'Request' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'receipt', turnId: 'physical-turn', ts: 2, + receipt: { actionId: 'action', userText: 'Request', + ...(kind === 'clarify' ? { clarification: 'Which task?' } : {}), + result: kind === 'clarify' ? { disposition: 'clarify', coordinationTurnId: 'physical-turn' } : { + disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'target', targetTurnId: 'target-turn', + }, + }, + }, + ]; + const durable = projectWorkHubCoordinationTurns(messages); + const visible = visibleWorkHubConversation(durable, [{ requestId: 'action', text: 'Request', state }]); + assert.deepEqual(visible, { coordination: durable, local: [] }); + assert.equal(visible.coordination.length, 1); + const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(WorkHubCoordinationTurnView, { + turn: visible.coordination[0]!, projection: { sessions: [], turns: [] }, locale: 'en', onOpenSession: () => undefined, + }), + }), + })); + if (kind === 'clarify') assert.match(markup, /Which task\?/u); + else { + assert.equal(visible.coordination[0]!.resume?.targetSessionId, 'target'); + assert.match(markup, /