diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index f4fcde9100..69bad64557 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -73,10 +73,34 @@ 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 ({ +test('WorkHub explicitly announces a newly created work item', 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 waitForWorkHubReady(page, 1); + + const prompt = '创建一个新的 Session,名为登录稳定性'; + const workHubComposer = page.locator( + '.workhub-surface .maka-composer-editor [contenteditable="true"]', + ); + await workHubComposer.fill(prompt); + await workHubComposer.press('Enter'); + + const createdTurn = page.locator('.workhub-turn', { hasText: prompt }); + await expect(createdTurn.locator('.workhub-submitted')).toContainText('已创建新工作:'); + await expect(createdTurn.locator('.workhub-submitted-session strong')).toHaveText('登录稳定性'); +}); + +test('WorkHub replaces the exact linked delegation across Sessions', async ({ window: page, }) => { const sourceSessionName = '检查支付回调重复投递时的幂等性'; @@ -122,8 +146,16 @@ test('WorkHub defers destructive correction until linked delegation exists', asy const correctionTurn = page.locator('.workhub-turn', { hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', }); - await expect(correctionTurn.locator('.workhub-error')).toContainText( - '跨 Session 更正将在持久委托关联完成后开放', + await expect( + correctionTurn.locator('.workhub-submitted-session strong'), + ).toHaveText('登录稳定性'); + await expect(correctionTurn.locator('.workhub-error')).toHaveCount(0); + await expect( + continuedTurn.locator('.workhub-submitted-state'), + ).toHaveText('已被更正'); + await expect( + correctionTurn.locator('.workhub-submitted-state'), + ).toContainText( + /^关联有效 · (?:已接收|进行中|等待你|已完成|失败|已中止|正在恢复)$/u, ); - await expect(correctionTurn.locator('.workhub-submitted')).toHaveCount(0); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 9fa160354b..c6dfc9c59c 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -5351,7 +5351,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./workhub-controller.js": 1 + "./application/contracts/workhub-request-intent.js": 1 } }, "src/renderer/workhub-send-lease.ts": { diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index 8506d8a503..cd12d9bb18 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -260,7 +260,10 @@ test('projects durable WorkHub delegation targets into the Desktop host namespac ); assert.equal(projected.type, 'workhub_coordination'); - if (projected.type === 'workhub_coordination') { + if ( + projected.type === 'workhub_coordination' && + projected.kind === 'delegation_assigned' + ) { assert.equal(projected.targetSessionId, JSON.stringify(['remote-root', 'payments'])); } }); diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 81e7538696..5301584e28 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; +import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; import { createWorkHubController as createGatedWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, @@ -27,6 +28,10 @@ import { type WorkHubSessionPort, type WorkHubCoordinationTurn, } from '../../renderer/workhub-controller.js'; +import { + createWorkHubRoutePolicy, + workHubNewSessionName, +} from '../../renderer/workhub-route-policy.js'; const appShellUrl = [ new URL('../../renderer/app-shell.tsx', import.meta.url), @@ -153,6 +158,33 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { ...(admitted.steered ? { steered: true as const } : {}), }; } + if (input.proposal.disposition === 'replace') { + if (input.proposal.target.disposition === 'create_new') { + const created = await sessions.create({ name: input.proposal.target.title }); + const admitted = await sessions.submit(created.target, input.userText, input.actionId); + return { + disposition: 'replace', + replacementDisposition: 'create_new', + targetSessionId: created.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } + const replacementTarget = candidateByRef.get(input.proposal.target.candidateRef); + if (!replacementTarget) throw new Error('unknown test replacement candidate'); + const admitted = await sessions.submit( + replacementTarget.target, + input.userText, + input.actionId, + ); + return { + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: replacementTarget.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } const target = candidateByRef.get(input.proposal.candidateRef); if (!target) throw new Error('unknown test candidate'); const admitted = await sessions.submit(target.target, input.userText, input.actionId); @@ -174,12 +206,14 @@ function coordinationAssignmentTurn(): WorkHubCoordinationTurn { text: 'Continue payments', state: 'completed', assignment: { + actionId: 'action-1', delegationId: 'delegation-1', targetSessionId: 'payment', targetSessionName: 'Payments', targetMessageId: 'payment-message', targetTurnId: 'payment-turn', feedbackState: 'accepted', + linkState: 'active', }, updatedAt: 10, }; @@ -203,7 +237,11 @@ test('conversation acknowledges a durable assignment before projecting target ex sessions, coordination: { open: async (handler) => { - handler([assignment]); + handler([assignment], [{ + actionId: assignment.assignment!.actionId, + targetSessionId: assignment.assignment!.targetSessionId, + sequence: 0, + }]); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), @@ -247,7 +285,12 @@ test('conversation feedback never lets an older refresh overwrite newer target s sessions, coordination: { open: async (handler) => { - handler([coordinationAssignmentTurn()]); + const assignment = coordinationAssignmentTurn(); + handler([assignment], [{ + actionId: assignment.assignment!.actionId, + targetSessionId: assignment.assignment!.targetSessionId, + sequence: 0, + }]); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), @@ -1428,7 +1471,7 @@ test('production retry reaches durable Action Gate replay while target is waitin assert.equal(actions.length, 1); }); -test('production defers destructive correction until persistent delegation exists', async () => { +test('production sends an explicit correction as a linked replacement', async () => { const actions: unknown[] = []; const sessions = port([session('source'), session('target')]); const controller = createGatedWorkHubController({ @@ -1465,21 +1508,40 @@ test('production defers destructive correction until persistent delegation exist }), act: async (input) => { actions.push(input); - throw new Error('incomplete correction must not reach the Action Gate'); + return { + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: 'target', + targetTurnId: 'replacement-turn', + }; }, }, }); - await assert.rejects( - controller.submit({ - requestId: 'deferred-correction', - text: 'No, use target instead', - explicitTarget: { sessionId: 'target' }, - correction: { from: { sessionId: 'source' }, turnId: 'source-turn' }, - }), - /linked correction requires persistent delegation support/u, - ); - assert.deepEqual(actions, []); + const result = await controller.submit({ + requestId: 'linked-correction', + text: 'No, use target instead', + explicitTarget: { sessionId: 'target' }, + correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' }, + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(actions, [ + { + actionId: 'linked-correction', + userText: 'No, use target instead', + candidateSetId: `sha256:${'d'.repeat(64)}`, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing', + candidateRef: 'candidate-target', + }, + }, + }, + ]); }); const PRODUCTION_CORRECTION_CREATION_CASES = [ @@ -1497,8 +1559,8 @@ const PRODUCTION_CORRECTION_CREATION_CASES = [ ['production-correction-with-alternate-cue-zh', '不对,创建一个新会话叫Login'], ] as const; -test('production natural-language correction fails closed before a second delegation', async () => { - const actions: unknown[] = []; +test('production natural-language corrections retain the prior delegation link', async () => { + const actions: WorkHubCoordinationActInput[] = []; const sessions = port([ session('login', { sessionName: '登录稳定性', @@ -1544,10 +1606,30 @@ test('production natural-language correction fails closed before a second delega candidates: async () => ({ candidateSetId, candidates }), act: async (input) => { actions.push(input); + if (input.proposal.disposition === 'replace') { + if (input.proposal.target.disposition === 'create_new') { + return { + disposition: 'replace', + replacementDisposition: 'create_new', + targetSessionId: `created-${input.actionId}`, + targetTurnId: `turn-${input.actionId}`, + }; + } + return { + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: input.proposal.target.candidateRef === 'candidate-login' + ? 'login' + : 'payment', + targetTurnId: 'runtime-login-turn', + }; + } + if (input.proposal.disposition !== 'delegate_existing') { + throw new Error('unexpected test disposition'); + } return { disposition: 'delegate_existing', - targetSessionId: input.proposal.disposition === 'delegate_existing' && - input.proposal.candidateRef === 'candidate-login' + targetSessionId: input.proposal.candidateRef === 'candidate-login' ? 'login' : 'payment', targetTurnId: input.actionId === 'production-wrong-payment' @@ -1563,30 +1645,37 @@ test('production natural-language correction fails closed before a second delega text: '继续这个工作,补充验收项', }); - await assert.rejects( - controller.submit({ - requestId: 'production-natural-correction', - text: '不是这个,换成登录那个,补充刷新令牌失败判定', - }), - /linked correction requires persistent delegation support/u, - ); + const corrected = await controller.submit({ + requestId: 'production-natural-correction', + text: '不是这个,换成登录稳定性,补充刷新令牌失败判定', + }); + assert.equal(corrected.kind, 'submitted'); - for (const [requestId, text] of PRODUCTION_CORRECTION_CREATION_CASES) { - await assert.rejects( - controller.submit({ requestId, text }), - /linked correction requires persistent delegation support/u, - ); - } + const [creationRequestId, creationText] = PRODUCTION_CORRECTION_CREATION_CASES[0]; + assert.equal( + (await controller.submit({ requestId: creationRequestId, text: creationText })).kind, + 'submitted', + ); - assert.deepEqual(actions, [{ - actionId: 'production-wrong-payment', - userText: '继续这个工作,补充验收项', + assert.equal(actions.length, 3); + assert.deepEqual(actions[1], { + actionId: 'production-natural-correction', + userText: '不是这个,换成登录稳定性,补充刷新令牌失败判定', candidateSetId, + confirmation: { kind: 'user_correction' }, proposal: { - disposition: 'delegate_existing', - candidateRef: 'candidate-payment', + disposition: 'replace', + replacesActionId: 'production-wrong-payment', + target: { + disposition: 'delegate_existing', + candidateRef: 'candidate-login', + }, }, - }]); + }); + assert.deepEqual( + actions.slice(2).map((action) => action.proposal.disposition), + ['replace'], + ); }); test('production correction-shaped creation stays create_new without an existing focus', async () => { @@ -1815,6 +1904,119 @@ test('English explicit creation extracts the requested Session name', async () = assert.deepEqual(created, ['Parser Cleanup']); }); +test('Chinese explicit creation strips Session naming introducers', () => { + assert.deepEqual( + [ + '创建一个新的 Session,名为登录稳定性', + '新建工作叫支付回调幂等性', + '开一个任务命名为消息恢复', + '不对,请创建一个新的 Session 标题为登录稳定性', + '错了,新建一个会话名称为支付任务', + '不对,不要创建一个新会话叫登录;而是创建一个新会话叫支付。', + ].map((text) => workHubNewSessionName(text)), + ['登录稳定性', '支付回调幂等性', '消息恢复', '登录稳定性', '支付任务', '支付'], + ); + assert.equal( + workHubNewSessionName( + 'No, create a new Session called Payments, and add documentation containing the example new Session called Fraud.', + ), + 'Payments', + ); + assert.equal(workHubNewSessionName('Create a new Session called U.S. Payments'), 'U.S. Payments'); + assert.equal(workHubNewSessionName('Create a new Session called Dr. Login'), 'Dr. Login'); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Payments'), + 'Acme Inc. Payments', + ); + assert.equal(workHubNewSessionName('Create a new Session called No. 5 Login'), 'No. 5 Login'); + assert.equal( + workHubNewSessionName('Create a new Session called Ph.D. Research'), + 'Ph.D. Research', + ); + assert.equal(workHubNewSessionName('Create a new Session called App. Fix login'), 'App'); + assert.equal(workHubNewSessionName('Create a new Session called Fix. Add documentation.'), 'Fix'); + assert.equal(workHubNewSessionName('Create a new Session called Go. Then add tests.'), 'Go'); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Fix login.'), + 'Acme Inc', + ); + assert.equal(workHubNewSessionName('Create a new Session called U.S. Fix login.'), 'U.S'); + assert.equal(workHubNewSessionName('Create a new Session called Ph.D. Fix login.'), 'Ph.D'); + assert.equal(workHubNewSessionName('Create a new Session called No. Fix login.'), 'No'); + assert.equal(workHubNewSessionName('Create a new Session called St. Fix login.'), 'St'); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Then fix login.'), + 'Acme Inc', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Please fix login.'), + 'Acme Inc', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Please then fix login.'), + 'Acme Inc', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Then, please fix login.'), + 'Acme Inc', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Finally fix login.'), + 'Acme Inc', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Acme Inc. Afterwards fix login.'), + 'Acme Inc', + ); + assert.equal(workHubNewSessionName('Create a new Session called U.S. Can you fix login?'), 'U.S'); + assert.equal(workHubNewSessionName('Create a new Session called U.S. Next, fix login.'), 'U.S'); + assert.equal(workHubNewSessionName('Create a new Session called U.S. Also fix login.'), 'U.S'); + assert.equal( + workHubNewSessionName('Create a new Session called U.S. Immediately fix login.'), + 'U.S', + ); + assert.equal( + workHubNewSessionName('Create a new Session called U.S. Proceed to fix login.'), + 'U.S', + ); + assert.equal( + workHubNewSessionName('Create a new Session called U.S. At that point fix login.'), + 'U.S', + ); + assert.equal(workHubNewSessionName('Create a new Session called U.S. Daily Fix'), 'U.S. Daily Fix'); + assert.equal( + workHubNewSessionName('Create a new Session called U.S. Monthly Update'), + 'U.S. Monthly Update', + ); + assert.equal( + workHubNewSessionName('Create a new Session called U.S. Monthly update'), + 'U.S. Monthly update', + ); + assert.equal( + workHubNewSessionName('Create a new Session called U.S. customer update'), + 'U.S. customer update', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Ph.D. Could you fix login?'), + 'Ph.D', + ); + assert.equal(workHubNewSessionName('Create a new Session called Ph.D. Now fix login.'), 'Ph.D'); + assert.equal( + workHubNewSessionName('Create a new Session called Ph.D. Finally fix login.'), + 'Ph.D', + ); + assert.equal(workHubNewSessionName('Create a new Session called Ph.D. 接下来修复登录。'), 'Ph.D'); + assert.equal(workHubNewSessionName('Create a new Session called Ph.D. 最后修复登录。'), 'Ph.D'); + assert.equal( + workHubNewSessionName('Create a new Session called Ph.D. Friendly Fix'), + 'Ph.D. Friendly Fix', + ); + assert.equal( + workHubNewSessionName('Create a new Session called Ph.D. Friendly fix'), + 'Ph.D. Friendly fix', + ); +}); + test('English routing boilerplate does not make an old analysis look related', async () => { const created: string[] = []; const sessions = port([ @@ -1862,6 +2064,808 @@ test('negated and deliberative creation language never creates a Session', async assert.deepEqual(created, []); }); +test('polite executable questions and file-level constraints still create new work', () => { + const cases = [ + 'Can you fix login stability?', + 'Can you please fix login?', + 'Could you implement payment retry?', + 'Could you kindly implement payment retry?', + 'If retries fail, can you fix login?', + 'If retries fail can you fix login?', + 'If retries fail then can you fix login?', + 'When retries fail, can you fix login?', + 'If retries fail then fix login?', + 'When retries fail, fix login?', + 'When retries fail fix login?', + '如果重试失败就请修复登录?', + 'Fix login, but leave documentation unchanged', + 'Fix login, but hold API behavior constant', + 'Fix login, but wait for tests before merging', + 'Create a new Session called App. Fix login', + 'Create a new Session called Fix. Add documentation.', + 'Create a new Session called Go. Then add tests.', + 'Create a new Session called Acme Inc. Fix login.', + 'Create a new Session called U.S. Fix login.', + 'Create a new Session called Ph.D. Fix login.', + 'Create a new Session called No. Fix login.', + 'Create a new Session called St. Fix login.', + 'Create a new Session called Acme Inc. Then fix login.', + 'Create a new Session called Acme Inc. Please fix login.', + 'Create a new Session called U.S. Can you fix login?', + 'Create a new Session called Ph.D. Could you fix login?', + 'Explain the issue, then fix login', + 'Tell me the cause and fix login', + 'Tell me the options and fix login', + 'Recommend options and fix login', + 'Can you fix login, but leave documentation unchanged?', + 'Please try to reproduce and fix login', + 'Try to reproduce and fix login', + 'Work to diagnose and fix login', + 'Explain that issue and fix login', + 'Update the label to How can I help?', + 'Fix copy to say What should I do?', + 'Implement an FAQ answering How can I recover?', + 'Update the prompt to How can I help?', + 'Fix the heading to What should I do?', + 'Update the tooltip to Where can I find files?', + 'Update the message to Why did this fail?', + 'Investigate and fix login', + 'Analyze and fix login', + 'Debug and fix login', + 'Review and update docs', + 'First investigate, then fix login', + 'Assess and fix login', + 'Examine and fix login', + '调查并修复登录', + '先分析,然后修复登录', + 'Investigate the issue and fix both login and logout.', + 'Review the failure and fix the affected user accounts.', + 'Analyze the suite and update the generated docs.', + '先分析,然后修复已经失败的测试。', + 'Investigate and fix login stability.', + 'Review and update API docs.', + 'Analyze and fix payment retry logic.', + 'Audit and update generated API docs.', + 'Investigate issue and fix login for mobile.', + 'Assess logs and update docs for operators.', + 'Review issue and fix login in production.', + 'Discuss the approach, then implement retry', + 'Consider the options, but fix login now', + '请修复支付回调重复投递?', + 'Fix how login errors are reported', + 'Update how retries are calculated', + '请修复用户不知道怎么登录的问题', + '请实现如何恢复失败任务的逻辑', + 'Create a new Session to fix how login errors are reported', + 'Implement docs to explain how retries work', + 'Update the guide to discuss why login fails', + '修复帮助页以解释如何恢复失败任务', + 'Fix login stability, but do not create any files', + '修复登录稳定性,但不要创建任何文件', + 'Create a new Session for login, but do not create files', + 'If retries fail, fix login', + 'If tests fail, fix login.', + 'If needed fix login.', + 'If necessary implement retries.', + 'When ready fix login.', + 'If possible fix login.', + 'If required fix login.', + 'If safe fix login.', + 'If appropriate implement retries.', + 'When convenient update docs.', + 'When available fix login.', + 'When feasible fix login.', + 'If desired fix login.', + 'If applicable fix login.', + 'When practical update docs.', + 'If advisable implement retries.', + 'If permitted fix login.', + 'When complete update docs.', + 'If urgent fix login.', + 'When sensible implement retries.', + 'If needed, implement payment retry', + ]; + for (const text of cases) { + assert.equal( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }).kind, + 'new_session', + text, + ); + } +}); + +test('advisory how-to ambiguity asks for a direct instruction', () => { + for (const text of [ + 'Explain how to fix login, then update the docs.', + 'Tell me how to diagnose login, and fix the bug.', + '解释如何修复登录,然后更新文档。', + 'Explain how to diagnose login; then fix it.', + 'Explain how to diagnose and reproduce login, then fix it.', + 'Explain how to diagnose the text "do not fix", then update docs.', + 'Explain how to diagnose the text `do not fix`, then update docs.', + 'Explain how to diagnose the text (do not fix), then update docs.', + 'Show me how to diagnose login, then fix it.', + 'Walk me through how to diagnose login, then fix it.', + '教我如何诊断登录,然后修复它。', + ]) { + assert.deepEqual( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }), + { kind: 'clarification', options: [], reason: 'ambiguous_command' }, + text, + ); + } +}); + +test('advisory ambiguity overrides explicit, exact-name, and recent-focus routing', () => { + const login = session('login', { sessionName: 'Login' }); + const text = 'Explain how to diagnose Login, then fix it.'; + const expected = { kind: 'clarification', options: [], reason: 'ambiguous_command' }; + + assert.deepEqual( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [login], + originPromptBySessionId: new Map(), + explicitTarget: login.target, + }), + expected, + ); + assert.deepEqual( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [login], + originPromptBySessionId: new Map(), + }), + expected, + ); + + const focusedPolicy = createWorkHubRoutePolicy(); + focusedPolicy.rememberTarget(login.target); + assert.deepEqual( + focusedPolicy.resolve({ + text: 'Explain how to diagnose this, then fix it.', + sessions: [login], + originPromptBySessionId: new Map(), + }), + expected, + ); +}); + +test('literal negator targets still create new work', () => { + for (const text of [ + "Create a new Session for parsing don't", + "Fix parsing of don't", + 'Update the button label to do not', + '修改按钮文案为不要了', + "Create a new Session for parsing contractions, e.g. don't", + 'Fix parsing examples, i.e. do not', + "Create a new Session for parsing contractions, e.g., don't", + 'Fix parsing examples, i.e., do not', + 'Update the button label to:\ndo not', + "Fix parser support for this token:\ndon't", + "Fix parser for these literals:\ndon't\ndo not", + '修改按钮文案为:\n不要了', + "Create a new Session for parsing this token:\ndon't", + "Create a new Session to test cases\n1. don't", + "Fix parser for cases\n1. don't", + "Create a new Session to test this code\n don't", + "Fix parser for this code\n\tdon't", + "Create a new Session to test list items\n- don't", + "Fix parser for list items\n- don't", + "Update parser examples:\n- do\n- don't", + "Update parser examples:\n1. do\n2. don't", + "Update parser examples:\n do\n don't", + "Create a new Session for parser examples:\n- do\n- don't", + "Create a new Session to test parser\n*Examples:*\n- don't", + "Create a new Session to test parser\n_Examples:_\n- don't", + 'Create a new Session to update copy\n帮我修改按钮文案为:\n不要了', + '请帮我修改按钮文案为:\n不要了', + "Fix parser support for foo-don't", + "Create a new Session for parsing foo-don't", + ]) { + assert.equal( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }).kind, + 'new_session', + text, + ); + } +}); + +test('withdrawing the requested action keeps the input in WorkHub', () => { + for (const text of [ + "Fix login stability, actually don't fix it", + "Fix login stability — actually, don't", + '修复登录稳定性,还是别了', + "Fix login, logout, etc. Don't.", + "Fix login\nDon't.", + '修复登录稳定性\n还是别了', + "Fix login stability - don't", + '修复登录稳定性 - 还是别了', + "Fix parser tokens:\ndon't\n\nactually, don't", + "Fix login\nCorrection:\ndon't", + '修复登录\n更正:\n还是别了', + "Fix login\nWait:\ndon't", + '修复登录\n不对:\n还是别了', + "Fix login\nCorrection note:\n- don't", + '修复登录\n想了想:\n 还是别了', + "Fix login\nIn that case:\ndon't", + "Fix login\nFor example:\ndon't", + "Fix login\nIn this test case:\ndon't", + "Fix login\nParser in this case:\ndon't", + "Fix login\nWith this config value:\ndon't", + "Create a new Session for login\nConfig in this case:\ndon't", + "Create a new Session for login\nTesting, for example:\ndon't", + "Fix login stability, but don't fix it", + "Fix login stability, but don't do that", + "Fix login stability, but don't implement it", + "Implement login stability, but don't fix it", + "Fix login stability, actually don't fix login stability", + 'Fix login stability, but do not fix login stability', + 'Fix login stability and do not fix login stability', + "Fix login stability then don't fix login stability", + 'Fix login stability and please do not fix login stability', + "Fix login stability then kindly don't fix login stability", + 'Fix login stability and could you please not fix login stability', + '修复登录稳定性,不过不要修复它', + '修复登录稳定性,但不要修改它', + '修复登录稳定性,不过不要修复登录稳定性', + '修复登录稳定性并且不要修复登录稳定性', + '修复登录稳定性然后请不要修复登录稳定性', + '修复登录稳定性然后麻烦你不要修复登录稳定性', + '修复登录稳定性然后真的不要修复登录稳定性', + 'Fix login stability and just do not fix login stability', + "Fix login stability and simply don't fix login stability", + '修复登录稳定性然后千万不要修复登录稳定性', + 'Do not create a new Session to fix login stability', + '不要创建一个新的 Session 来修复登录稳定性', + 'Do not create a new Session. Fix login stability', + ]) { + assert.equal( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }).kind, + 'discussion', + text, + ); + } +}); + +test('a later affirmative clause creates work after withdrawing an earlier action', () => { + for (const text of [ + "Fix login, but don't do that; instead implement payment retry", + '修复登录,但不要这样做;而是实现支付重试', + "Fix login, but don't do that. Implement payment retry", + "Create a new Session for login, but don't do that; instead implement payment retry", + '创建一个新的 Session 处理登录,不过不要这样做;而是实现支付重试', + 'Fix login and do not fix login documentation', + 'Fix checkout, but do not fix checkout tests', + '修复登录,但不要修复登录文档', + 'Update API documentation, but do not update API', + 'Fix checkout tests, but do not fix checkout', + '修复登录文档,但不要修复登录', + ]) { + assert.equal( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }).kind, + 'new_session', + text, + ); + } +}); + +test('a correction with a negated creation tail never proposes a new Session', () => { + const login = session('login', { sessionName: 'Login 登录稳定性', updatedAt: 20 }); + const payment = session('payment', { sessionName: 'Payment 支付稳定性', updatedAt: 30 }); + const cases = [ + '不是这个,换成登录稳定性,不要创建新会话', + 'Wrong session; switch to Login; do not create a new session', + 'Wrong session; switch to Login without creating a new session', + '不是这个,而是不要真的创建一个新的 Session', + 'Wrong session; do not actually create a new Session', + '不是这个,而是不要在没有我确认的情况下创建一个新的 Session', + 'Wrong session; do not under any circumstances whatsoever ever create a new session', + 'Wrong session; create a note and do not ever create a new session', + '不是这个,而是请勿创建一个新的 Session', + '我不想创建一个新的 Session', + '我不打算创建一个新的 Session', + '我不是要创建一个新的 Session,只是讨论', + '我并非要创建一个新的 Session,只是讨论', + '不是想创建一个新的 Session,只是问问', + '我不是让你创建一个新的 Session,只是讨论', + '我不是说要创建一个新的 Session,只是讨论', + '并非让你创建一个新的 Session,只是讨论', + '我没让你创建一个新的 Session,只是讨论', + '我没有让你创建一个新的 Session,只是讨论', + '我不希望你创建一个新的 Session,只是讨论', + '我不是请你创建一个新的 Session,只是讨论', + '我没说要创建一个新的 Session,只是讨论', + '我没有说要创建一个新的 Session,只是讨论', + '我没有打算创建一个新的 Session,只是讨论', + '我没准备创建一个新的 Session,只是讨论', + 'Please refuse to create a new Session', + 'I decline to create a new Session; just discuss', + '我未打算创建一个新的 Session,只是讨论', + 'I will not create a new session', + 'not create a new session; just discuss', + 'Under no circumstances create a new session', + 'Create a new Session; do not create a new Session', + '创建一个新的 Session;不要创建一个新的 Session', + "Create a new Session, but don't create it", + "Create a new Session for login — actually, don't", + '创建一个新的 Session 处理登录,还是别了', + "Create a new Session to fix login, logout, etc. Don't.", + "Create a new Session for login\nDon't.", + "Create a new Session for login - don't", + "Create a new Session for parser tokens:\ndon't\n\nactually, don't", + "Create a new Session for login\nCorrection:\ndon't", + "Create a new Session for login\nFinal correction:\ndon't", + "Create a new Session for login\nCorrection:\n- don't", + "Create a new Session for login\nCorrection:\n1. don't", + "Create a new Session for login\nCorrection:\n don't", + '创建一个新的 Session 处理登录\n更正:\n- 还是别了', + "Create a new Session for login\nCorrection note:\n- don't", + "Create a new Session for login\nOn second thought:\n1. don't", + '创建一个新的 Session 处理登录\n想了想:\n 还是别了', + "Create a new Session for login\n## Correction:\n- don't", + "Create a new Session for login\n**Correction:**\n- don't", + '创建一个新的 Session 处理登录\n## 更正:\n- 还是别了', + "Create a new Session for login\nChange to:\n- don't", + "Create a new Session for login\nUpdate to:\ndon't", + "Create a new Session for login\nCorrection to:\n1. don't", + '创建一个新的 Session 处理登录\n改为:\n- 还是别了', + "Create a new Session for login\nIn any case:\ndon't", + "Create a new Session for parser examples:\n- do\n \n- don't", + "Create a new Session for parser examples:\n1. do\n\t\n2. don't", + "Create a new Session for login\nFor this parser case:\ndon't", + "Create a new Session, but don't create one after all", + '创建一个新的 Session,不过不要创建它', + '不是这个,而是创建一个新的 Session;不要创建一个新的 Session', + 'Wrong session; don’t ever create a new session', + 'Wrong session; do not, under any circumstances, create a new session', + '不是这个,而是创建一个新的 Session;不过不要这样做', + 'No examples create a new Session.', + 'This note says no, create a new Session called Payments', + "No, create a new Session for login but don't", + 'No, please explain how to create a new Session', + 'No, tell me how to create a new Session', + '不对,请解释如何创建一个新的 Session', + '错了,请告诉我怎么创建一个新的 Session', + ]; + + for (const text of cases) { + const policy = createWorkHubRoutePolicy(); + policy.rememberTarget(payment.target); + const decision = policy.resolve({ + text, + sessions: [login, payment], + originPromptBySessionId: new Map(), + }); + + assert.notEqual(decision.kind, 'new_session', text); + } +}); + +test('a pronoun correction uses the shared affirmative target span', () => { + const source = session('source', { sessionName: 'Source', updatedAt: 30 }); + const payments = session('payments', { sessionName: 'Payments', updatedAt: 20 }); + const policy = createWorkHubRoutePolicy(); + policy.rememberTarget(source.target); + + assert.deepEqual( + policy.resolve({ + text: 'Not this session; move it to Payments', + sessions: [source, payments], + originPromptBySessionId: new Map(), + }), + { + kind: 'target', + target: payments.target, + evidence: 'route_correction', + correctedFrom: source.target, + }, + ); +}); + +test('correction routing preserves quoted and punctuated Session identities', () => { + const source = session('source', { sessionName: 'Source', updatedAt: 30 }); + for (const [text, target] of [ + ['No, use "Payments"', session('payments', { sessionName: 'Payments', updatedAt: 20 })], + [ + 'No, use Research and Development', + session('research', { sessionName: 'Research and Development', updatedAt: 20 }), + ], + [ + 'No, use Payments, Retry', + session('payment-retry', { sessionName: 'Payments, Retry', updatedAt: 20 }), + ], + ['不是这个,换成“支付任务”', session('payment', { sessionName: '支付任务', updatedAt: 20 })], + ] as const) { + const policy = createWorkHubRoutePolicy(); + policy.rememberTarget(source.target); + const decision = policy.resolve({ + text, + sessions: [source, target], + originPromptBySessionId: new Map(), + }); + assert.equal(decision.kind, 'target', text); + assert.equal(decision.kind === 'target' ? decision.target.sessionId : undefined, target.target.sessionId); + assert.equal(decision.kind === 'target' ? decision.evidence : undefined, 'route_correction'); + } +}); + +test('a negated existing-target correction never proposes destructive replacement', () => { + const login = session('login', { sessionName: 'Login 登录稳定性', updatedAt: 20 }); + const payment = session('payment', { sessionName: 'Payment 支付任务', updatedAt: 30 }); + for (const text of [ + "Not this session; don't move it to Login", + '不是这个会话,但不要转到登录稳定性', + "Not this session; move to Login, but I don't want to move anymore", + '不是这个会话,转到登录稳定性,不过我不想转了', + 'No examples use Login.', + 'This note says no, use Login', + "No, use Login but don't", + "No, use Login and actually don't", + "No, use Login and don't want to move it", + "No, use Login and don't proceed", + "No, use Login and don't go ahead with that", + 'No, use Login, forget it', + 'No, use Login, on second thought leave it', + '不是这个会话,转到登录稳定性然后不想转了', + '不是这个会话,转到登录稳定性然后不要继续', + '不是这个会话,转到登录稳定性,当我没说', + '不是这个会话,转到登录稳定性,还是维持原样', + 'No, use Login, fix payments. Forget it', + 'No, use Login — on second thought leave it', + '不是这个会话,转到登录稳定性,修复支付。当我没说', + ]) { + const policy = createWorkHubRoutePolicy(); + policy.rememberTarget(payment.target); + const decision = policy.resolve({ + text, + sessions: [login, payment], + originPromptBySessionId: new Map(), + }); + assert.notEqual(decision.kind, 'new_session', text); + if (decision.kind === 'target') { + assert.equal(decision.target.sessionId, payment.target.sessionId, text); + assert.notEqual(decision.evidence, 'route_correction', text); + assert.equal(decision.correctedFrom, undefined, text); + } + } +}); + +test('indirect questions containing action words stay in WorkHub', () => { + for (const text of [ + '我想知道如何修复登录问题', + '我们应该如何修复登录问题', + 'Please explain how to fix login', + 'Tell me how to fix login', + 'I would like to understand how to fix login', + '麻烦解释如何修复登录问题', + '请告诉我如何修复登录问题', + 'Show me how to fix login', + 'Could you walk me through how to fix login', + 'What steps should I take to fix login', + '教我怎么修复登录问题', + '给我讲讲如何修复登录问题', + 'Can you tell me if we should fix login?', + 'Could you evaluate if we should implement payment retry?', + '请告诉我该不该修复登录问题?', + '请告诉我应不应该实现支付重试?', + 'Can you give me steps to fix login', + 'Please give me a way to fix login', + 'Could you outline the steps to fix login', + '告诉我修复登录的步骤', + '给我一个修复登录的方法', + 'Can you tell me: should I fix login?', + 'Can you recommend I fix login?', + '请告诉我:我应该修复登录吗?', + '请告诉我应否修复登录?', + 'Can you tell me if I need to fix login', + 'Could you tell me if I must implement retry', + 'I need to know if I need to fix login', + '请告诉我我应否修复登录', + 'Can you fix login, or should we wait?', + 'Can you fix login? Actually, should we?', + 'Can you fix login, or should we wait', + 'Can you fix login. Actually, should we', + '请修复登录,还是应该先等等?', + '请修复登录,还是应该先等等', + 'Can you fix login, or leave it for now?', + '请修复登录,还是等等吧?', + 'Fix login. Maybe we should wait', + 'Fix login. On second thought, maybe wait', + 'Can you tell me if it is necessary to fix login', + 'Explain when to fix login', + 'Tell me in which cases to fix login', + '请告诉我在什么情况下修复登录', + 'Fix login, but maybe we should wait', + 'Fix login; perhaps we should wait', + '请修复登录,不过也许应该等等', + 'Fix login. Actually, I am not sure.', + 'Fix login. Do you think we should?', + "Fix login. On second thought, I'm not sure", + '请修复登录。我不确定。', + 'Can you recommend I fix login', + 'Could you suggest I implement retry', + 'Explain the circumstances in which to fix login', + 'Tell me the best time to fix login', + 'When should I fix login?', + 'When do we fix login?', + '如果什么时候修复登录?', + 'If unsure whether to fix login?', + 'When in doubt, ask whether to fix login?', + 'If it is unclear how to implement retry?', + 'When is it appropriate to fix login?', + '如果适合修复登录?', + 'Fix login. Is that wise?', + 'Fix login, cancel this task.', + 'Fix login, I take that back.', + 'Create a new Session for Payments; cancel the creation.', + 'Create a new Session for Payments. Cancel the new session.', + '创建一个新会话用于支付然后停止创建。', + 'Fix login, cancel this job.', + 'Fix login, withdraw the request.', + 'Fix login, retract that.', + 'Fix login, forget the request.', + 'Create a new Session for Payments; cancel my request.', + 'Create a new Session for Payments; withdraw that request.', + 'Create a new Session for Payments. Revoke that request.', + 'When might we fix login?', + 'When may we fix login?', + 'When will we fix login?', + 'If it makes sense to fix login?', + '如果现在修复登录合适吗?', + 'Fix login. Are we sure?', + 'Fix login. Are you sure?', + 'Fix login. Are they sure?', + 'Fix login. Should we really?', + 'Fix login, cancel the current task.', + 'Fix login, rescind my request.', + 'Fix login, drop this task.', + 'Fix login. Please cancel the task.', + 'Fix login. I want to cancel the task.', + 'Fix login. Could you cancel the task?', + "Fix login. Let's cancel the task.", + 'Fix login. I would prefer to cancel the task.', + 'Fix login. Please do not proceed with the task.', + 'Fix login. I do not wish to proceed.', + 'When should the service fix login?', + 'When will Alice fix login?', + 'When will the patch fix login?', + 'When must the service fix login?', + 'When ought we fix login?', + 'If you think we should fix login?', + 'If you believe we ought to implement retry?', + 'If it is advisable to fix login?', + 'If I wanted you to fix login, what would happen?', + 'If I asked you to fix login, how would you approach it?', + 'If the plan were to fix login, would that be wise?', + 'If I asked you to fix login?', + 'If I wanted you to fix login?', + 'If the plan were to fix login?', + 'If I wanted you to fix login what would happen?', + 'If I asked you to fix login how would you approach it?', + 'If the plan were to fix login would that be wise?', + '如果现在修复登录可以吗?', + '如果现在修复登录可行吗?', + '如果我让你修复登录会怎样?', + 'Fix login. Do you agree?', + 'Fix login. Are you certain?', + 'Fix login. Do you still want that?', + 'Fix login — do you agree?', + 'Fix login: are you sure?', + 'Fix login, okay?', + 'Fix login, sound good?', + 'Fix login, maybe?', + 'Fix login, perhaps?', + 'Fix login, not sure?', + 'Fix login, any concerns?', + '修复登录,没问题吧?', + 'Could you suggest ways to monitor and fix login', + 'Explain techniques that diagnose and fix login errors.', + 'Discuss approaches that prevent and fix login errors.', + 'Explain the steps to diagnose and fix login.', + 'Explain strategies that diagnose and fix login.', + 'Recommend patterns that detect and fix login.', + 'Could you suggest practical options to monitor and fix login', + 'Tell me possible solutions to identify and fix login', + 'Describe techniques that diagnose and fix login.', + 'Analyze strategies that diagnose and fix login.', + 'Explain a process where we diagnose and fix login.', + 'Describe a framework that diagnoses and fix login.', + 'Outline a workflow that detects and fix login.', + 'Summarize a proposal where we diagnose and fix login.', + 'Compare tools that detect and fix login.', + 'I plan to fix login myself.', + 'The team will fix login.', + 'Suppose we fix login.', + 'If we fix login, users will be happier.', + 'When we fix login, users will be happier.', + '如果我们修复登录,用户会更满意。', + 'If the team can fix login, users will be happier.', + 'If Alice can fix login, users will be happier.', + '如果团队能修复登录,用户会更满意。', + 'Should we fix login and then update docs?', + 'Can we diagnose login and then fix it?', + 'What if we fix login and then update docs?', + 'Maybe investigate and fix login.', + 'Perhaps review and update docs.', + 'Potentially debug and fix login.', + 'Our goal is to investigate and fix login.', + 'The requirement is to investigate and fix login.', + 'The service must diagnose and fix login.', + 'Should we diagnose, then fix login?', + 'How should we fix login, then update docs?', + 'Explain how to fix login and then update docs.', + 'Tell me how to diagnose and then fix login.', + 'Can you explain how to diagnose login and then fix it?', + 'Explain whether we should diagnose then fix login.', + 'Discuss whether to diagnose then fix login.', + 'Tell me how we should diagnose then fix login.', + 'Explain how to diagnose, fix, and test login.', + 'Recommend ways to diagnose, fix, and test login.', + 'Explain how to diagnose login, fix it, and update docs.', + 'Explain whether we should diagnose, then fix login.', + 'Explain the workflow: diagnose, then fix login.', + 'Discuss the sequence: diagnose, then fix login.', + 'Review notes and fix status are attached.', + 'Audit results and fix plans are attached.', + 'Research findings and fix recommendations are attached.', + 'Explain how to diagnose login; then fix it. Is that wise?', + 'Explain how to diagnose login, then fix it—but is that wise?', + 'Explain how to diagnose the text "login, then fix it".', + 'Explain how to diagnose a phrase saying "login; then fix it".', + 'Explain how to diagnose login, and test results are attached.', + 'Explain how to diagnose login; then test results are available.', + 'Audit findings and fix recommendations both matter.', + 'Research findings and fix recommendations changed yesterday.', + '分析报告并修复建议已经附上。', + '调查结果并修复建议都很重要。', + 'Explain how to diagnose the text `login, then fix it`.', + 'Explain how to diagnose the sequence (login, then fix it).', + 'Explain how to diagnose login; then fix it, any concerns?', + 'Explain how to diagnose login; then fix it, do you agree?', + 'Audit findings and fix recommendations matter.', + 'Review notes and fix status matters.', + 'Research findings and fix recommendations changed.', + 'Explain how to diagnose login; then test results matter.', + 'Explain how to diagnose login; then test coverage improved.', + 'Explain how to diagnose login; then update metrics increased.', + 'Explain how to diagnose the text "login, then fix it.', + 'Explain how to diagnose the text `login, then fix it.', + 'Explain how to diagnose the sequence (login (primary), then fix it).', + 'Explain how to diagnose the sequence [login, then fix it].', + ]) { + assert.equal( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }).kind, + 'discussion', + text, + ); + } +}); + +test('a fuzzy correction target never becomes destructive routing authority', () => { + const source = session('source', { sessionName: 'Source', updatedAt: 30 }); + const paymentCallback = session('payment-callback', { + sessionName: '支付回调', + updatedAt: 20, + }); + const policy = createWorkHubRoutePolicy(); + policy.rememberTarget(source.target); + + const decision = policy.resolve({ + text: '不是这个,换成支付页面', + sessions: [source, paymentCallback], + originPromptBySessionId: new Map(), + }); + + assert.notEqual(decision.kind, 'new_session'); + if (decision.kind === 'target') { + assert.equal(decision.target.sessionId, source.target.sessionId); + assert.notEqual(decision.evidence, 'route_correction'); + assert.equal(decision.correctedFrom, undefined); + } +}); + +test('a candidate name cannot absorb unquoted withdrawal semantics', () => { + const source = session('source', { sessionName: 'Source', updatedAt: 30 }); + for (const [text, sessionName] of [ + ["No, use Payments and don't proceed", "Payments and don't proceed"], + ['不是这个,转到支付任务然后不要继续', '支付任务然后不要继续'], + ['No, use Payments and stop.', 'Payments and stop'], + ['不是这个,转到支付任务然后停止。', '支付任务然后停止'], + ['No, use Payments and abort.', 'Payments and abort'], + ['No, use Payments and cancel.', 'Payments and cancel'], + ['No, use Payments and stop now.', 'Payments and stop now'], + ['No, use Payments and halt this.', 'Payments and halt this'], + ['No, use Payments and ABORT.', 'Payments and ABORT'], + ['No, use Payments but abort.', 'Payments but abort'], + ['No, use Payments; stop now.', 'Payments; stop now'], + ['No, use Payments. Abort.', 'Payments. Abort'], + ['No, use Payments, cancel.', 'Payments, cancel'], + ['不是这个,转到支付任务然后作罢。', '支付任务然后作罢'], + ['不是这个,转到支付任务然后停止执行。', '支付任务然后停止执行'], + ['不是这个,转到支付任务但是作罢。', '支付任务但是作罢'], + ['不是这个,转到支付任务。作罢。', '支付任务。作罢'], + ['No, use Payments. I changed my mind.', 'Payments. I changed my mind'], + [ + 'No, use Payments. On second thought, keep it here.', + 'Payments. On second thought, keep it here', + ], + ['不是这个,转到支付任务。我改主意了。', '支付任务。我改主意了'], + ] as const) { + const candidate = session('candidate', { sessionName, updatedAt: 20 }); + const policy = createWorkHubRoutePolicy(); + policy.rememberTarget(source.target); + const decision = policy.resolve({ + text, + sessions: [source, candidate], + originPromptBySessionId: new Map(), + }); + assert.notEqual(decision.kind === 'target' ? decision.evidence : undefined, 'route_correction'); + } +}); + +test('malformed or unbound creation naming stays in WorkHub discussion', () => { + for (const text of [ + 'Create a new Session called "Payments', + '创建一个新会话叫“支付任务', + "No, don't create a new session called Login; instead create a new session for Payments", + "Create a new Session called Payments and don't proceed.", + '创建一个新会话叫支付任务然后不要继续。', + 'Create a new Session called Payments and stop.', + 'Create a new Session called Payments and abort.', + 'Create a new Session called Payments and cancel.', + 'Create a new Session called Payments and stop now.', + 'Create a new Session called Payments and halt this operation immediately.', + 'Create a new Session called Payments and ABORT.', + 'Create a new Session called Payments but abort.', + 'Create a new Session called Payments; stop now.', + 'Create a new Session called Payments. Abort.', + 'Create a new Session called Payments, cancel.', + '创建一个新会话叫支付任务然后作罢。', + '创建一个新会话叫支付任务然后停止执行。', + '创建一个新会话叫支付任务但是作罢。', + '创建一个新会话叫支付任务。作罢。', + 'No, create a new Session called Payments. Example: create a new Session called Fraud.', + 'Create a new Session called Payments.Example: create a new Session called Fraud', + 'Create a new Session called App. Example: create a new Session called Fraud', + 'No, create a new Session called Payments. Fix login, cancel this task.', + ]) { + assert.equal( + createWorkHubRoutePolicy().resolve({ + text, + sessions: [], + originPromptBySessionId: new Map(), + }).kind, + 'discussion', + text, + ); + } +}); + test('subscribe exposes Session invalidations without inventing WorkHub state', () => { let listener: (() => void) | undefined; let unsubscribed = false; 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 fbf60f1c43..b982a265d2 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -29,6 +29,7 @@ import { } from '../../renderer/workhub-session-port.js'; import { createDesktopWorkHubCoordinationPort, + projectWorkHubActiveDelegations, projectWorkHubCoordinationTurns, } from '../../renderer/workhub-coordination-port.js'; @@ -106,7 +107,7 @@ function transcriptsWith(messages: readonly StoredMessage[]) { } test('projects the durable Coordination transcript into the WorkHub conversation', () => { - assert.deepEqual(projectWorkHubCoordinationTurns([ + const messages: StoredMessage[] = [ { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, { type: 'assistant', @@ -142,7 +143,8 @@ test('projects the durable Coordination transcript into the WorkHub conversation disposition: 'delegate_existing', userText: 'Continue payments', }, - ]), [{ + ]; + assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ messageId: 'user-1', turnId: 'turn-1', text: 'What is next?', @@ -155,15 +157,204 @@ test('projects the durable Coordination transcript into the WorkHub conversation 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, }]); + assert.deepEqual(projectWorkHubActiveDelegations( + messages.map((message, sequence) => ({ message, sequence })), + ), [{ + actionId: 'action-1', + targetSessionId: 'payments', + sequence: 3, + }]); +}); + +test('rebuilds active linkage outside the bounded visible timeline in transcript order', () => { + const assignment: StoredMessage = { + type: 'workhub_coordination', + id: 'assignment-old', + turnId: 'action-old', + ts: 100, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-old', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'action-old', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }; + const messages: StoredMessage[] = [ + assignment, + ...Array.from({ length: 45 }, (_, index): StoredMessage => ({ + type: 'user', + id: `later-${index}`, + turnId: `later-${index}`, + ts: 100, + text: `Later coordination ${index}`, + })), + ]; + + assert.equal( + projectWorkHubCoordinationTurns(messages).some((turn) => turn.messageId === assignment.id), + false, + ); + assert.deepEqual(projectWorkHubActiveDelegations( + messages.map((message, sequence) => ({ message, sequence })), + ), [{ + actionId: 'action-old', + targetSessionId: 'payments', + sequence: 0, + }]); +}); + +test('projects durable create_new disposition as an explicit new-work announcement', () => { + const assignment: StoredMessage = { + type: 'workhub_coordination', + id: 'assignment-created', + turnId: 'action-created', + ts: 1, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-created', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'action-created', + targetSessionId: 'login', + targetSessionName: 'Login stability', + targetTurnId: 'login-turn', + targetMessageId: 'login-message', + delegationId: 'login-delegation', + disposition: 'create_new', + userText: 'Fix login stability', + create: { + title: 'Login stability', + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }; + + assert.equal( + projectWorkHubCoordinationTurns([assignment])[0]?.assignment?.createdNew, + true, + ); +}); + +test('a durable replacement abort terminalizes the retired source linkage', () => { + const assignment: StoredMessage = { + type: 'workhub_coordination', + id: 'assignment-old', + turnId: 'action-old', + ts: 1, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-old', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'action-old', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }; + const aborted: StoredMessage = { + type: 'workhub_coordination', + id: 'replacement-aborted', + turnId: 'replacement-action', + ts: 2, + schemaVersion: 2, + kind: 'delegation_replacement_aborted', + actionId: 'replacement-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + coordinationTurnId: 'replacement-action', + abortedActionId: 'action-old', + abortedDelegationId: 'payments-delegation', + targetSessionId: 'login', + reason: 'target_unavailable', + }; + + assert.deepEqual(projectWorkHubActiveDelegations([ + { sequence: 0, message: assignment }, + { sequence: 1, message: aborted }, + ]), []); + assert.equal( + projectWorkHubCoordinationTurns([assignment, aborted])[0]?.assignment?.linkState, + 'aborted', + ); +}); + +test('durable supersession terminalizes only the replaced linkage', () => { + const source: StoredMessage = { + type: 'workhub_coordination', + id: 'assignment-old', + turnId: 'action-old', + ts: 1, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-old', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'action-old', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }; + const replacement: StoredMessage = { + ...source, + id: 'assignment-new', + turnId: 'action-new', + ts: 2, + schemaVersion: 2, + actionId: 'action-new', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + coordinationTurnId: 'action-new', + targetSessionId: 'login', + targetSessionName: 'Login', + targetTurnId: 'login-turn', + targetMessageId: 'login-message', + delegationId: 'login-delegation', + userText: 'Switch to login', + replacesActionId: 'action-old', + replacesDelegationId: 'payments-delegation', + }; + const superseded: StoredMessage = { + type: 'workhub_coordination', + id: 'superseded-old', + turnId: 'action-new', + ts: 3, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: 'action-new', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + coordinationTurnId: 'action-new', + supersededActionId: 'action-old', + supersededDelegationId: 'payments-delegation', + replacementDelegationId: 'login-delegation', + }; + const messages = [source, replacement, superseded]; + + assert.deepEqual( + projectWorkHubCoordinationTurns(messages).map((turn) => turn.assignment?.linkState), + ['superseded', 'active'], + ); + assert.deepEqual(projectWorkHubActiveDelegations( + messages.map((message, sequence) => ({ message, sequence })), + ), [{ actionId: 'action-new', targetSessionId: 'login', sequence: 1 }]); }); test('Coordination transcript adapter emits an initial empty ready snapshot and closes cleanly', async () => { @@ -214,12 +405,169 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and }), }); - const handle = await adapter.open((turns) => snapshots.push(turns), () => {}); - assert.deepEqual(snapshots, [[]]); + const handle = await adapter.open( + (turns, activeDelegations) => snapshots.push([turns, activeDelegations]), + () => {}, + ); + assert.deepEqual(snapshots, [[[], []]]); await handle.close(); assert.equal(closes, 1); }); +test('Coordination transcript reset rebuilds active linkage outside the resident window', async () => { + const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); + const assignment: StoredMessage = { + type: 'workhub_coordination', + id: 'assignment-old', + turnId: 'action-old', + ts: 1, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-old', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'action-old', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }; + const recent: StoredMessage = { + type: 'user', + id: 'recent-user', + turnId: 'recent-turn', + ts: 2, + text: 'Recent coordination', + }; + const fragment = (message: StoredMessage, sequence: number) => { + const data = new TextEncoder().encode(JSON.stringify(message)); + return { + source: 'durable' as const, + identity: sequence, + order: null, + byteOffset: 0, + totalBytes: data.byteLength, + data, + }; + }; + let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; + let generation = 'generation-1'; + let historyLoads = 0; + let historyBatchReady = true; + const snapshots: unknown[] = []; + const adapter = createDesktopWorkHubCoordinationPort({ + sessionId, + transcripts: { + open: async (_requestedSessionId, handler) => { + deliver = handler; + handler({ + sessionId: 'coordination', + deliverySequence: 1, + generation, + hostEpoch: 'epoch-1', + durableThrough: 1, + fragments: [fragment(recent, 1)], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: true, + hasNewer: false, + reset: true, + ready: true, + }); + return { + sessionId, + generation, + hostEpoch: 'epoch-1', + readThroughMessageId: null, + loadBefore: async () => { + historyLoads += 1; + handler({ + sessionId: 'coordination', + deliverySequence: historyLoads + 1, + generation, + hostEpoch: 'epoch-1', + durableThrough: 1, + fragments: [fragment(assignment, 0)], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + reset: false, + ready: historyBatchReady, + }); + }, + loadAround: async () => {}, + close: async () => {}, + }; + }, + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'b'.repeat(64)}`, + candidates: [], + }), + act: async () => ({ + ok: true, + result: { disposition: 'answer_here', coordinationTurnId: 'coordination-turn' }, + }), + }); + + const handle = await adapter.open( + (_turns, activeDelegations) => snapshots.push(activeDelegations), + (error) => assert.fail(String(error)), + ); + assert.deepEqual(snapshots.at(-1), [{ + actionId: 'action-old', + targetSessionId: desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }), + sequence: 0, + }]); + + generation = 'generation-2'; + historyBatchReady = false; + const snapshotsBeforeReset = snapshots.length; + deliver?.({ + sessionId: 'coordination', + deliverySequence: 3, + generation, + hostEpoch: 'epoch-1', + durableThrough: 1, + fragments: [fragment(recent, 1)], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: true, + hasNewer: false, + reset: true, + ready: false, + }); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(historyLoads, 2); + assert.equal(snapshots.length, snapshotsBeforeReset); + deliver?.({ + sessionId: 'coordination', + deliverySequence: 5, + generation, + hostEpoch: 'epoch-1', + durableThrough: 1, + fragments: [fragment(recent, 1)], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + reset: false, + ready: true, + }); + assert.deepEqual(snapshots.at(-1), [{ + actionId: 'action-old', + targetSessionId: desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }), + sequence: 0, + }]); + await handle.close(); +}); + test('projects durable Session messages into an ordered WorkHub conversation', () => { const turns = projectWorkHubSessionTurns({ target: { sessionId: 'payment' }, 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 98af7045e5..447b5969b5 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -31,6 +31,7 @@ import { submitLeasedWorkHubSurfaceInput, submitWorkHubSurfaceInput, visibleWorkHubConversation, + workHubAmbiguousCommandPrompt, workHubSurfaceFailure, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; @@ -57,7 +58,7 @@ test('surface turns Action Gate rejections into safe actionable failures', () => ); assert.equal( workHubSurfaceFailure( - new Error('WorkHub linked correction requires persistent delegation support'), + new Error('WorkHub linked correction requires an active durable delegation'), ), 'linked_correction_unavailable', ); @@ -127,12 +128,14 @@ test('durable delegation renders every projected target state as a navigable res text: 'Continue payments', state: 'completed', assignment: { + actionId: 'action-1', delegationId: 'delegation-1', targetSessionId: 'payment', targetSessionName: 'Payments', targetMessageId: 'payment-message', targetTurnId: 'payment-turn', feedbackState: state, + linkState: 'active', }, updatedAt: 10, }; @@ -151,11 +154,98 @@ test('durable delegation renders every projected target state as a navigable res ); assert.match(markup, / - ))} - +

{turn.outcome.reason === 'ambiguous_command' + ? copy.confirmCommand + : copy.chooseWork}

+ {turn.outcome.options.length > 0 ? ( +
+ {turn.outcome.options.map((option) => ( + + ))} +
+ ) : null} ) : turn.outcome?.kind === 'discussion' ? ( <> @@ -667,7 +683,7 @@ function WorkHubTurnView(props: { @@ -740,6 +758,12 @@ function SubmittedWorkView(props: { ); } +export function workHubAmbiguousCommandPrompt(locale: UiLocale): string { + return locale === 'zh' + ? '没有开始新工作。如果需要我直接执行,请给出明确指令,例如“修复登录”。' + : 'I did not start new work. If you want me to do it, give a direct instruction, for example “Fix login”.'; +} + function workHubCopy(locale: UiLocale) { if (locale === 'zh') { return { @@ -751,11 +775,12 @@ function workHubCopy(locale: UiLocale) { : '提出一个明确目标,WorkHub 会创建普通 Session 并把结果带回这里。', workCount: (count: number) => `${count} 项工作`, clarification: '选择工作', chooseWork: '这条输入可能与多项工作有关,请选择目标:', + confirmCommand: workHubAmbiguousCommandPrompt(locale), discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。', discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', answering: '正在回答…', choseWork: (name: string) => `选择“${name}”`, - sentTo: '已交给:', accepted: '已接收', sessionFallback: '普通 Session', + sentTo: '已交给:', createdWork: '已创建新工作:', accepted: '已接收', sessionFallback: '普通 Session', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -766,7 +791,7 @@ function workHubCopy(locale: UiLocale) { retry: '重试', submitFailures: { candidates_changed: '工作列表已变化,请重新发送以使用最新目标。', - linked_correction_unavailable: '跨 Session 更正将在持久委托关联完成后开放;请先打开原 Session 停止当前工作。', + linked_correction_unavailable: '找不到可更正的有效委托关联;请重新发送,或打开原 Session 确认当前工作。', target_waiting: '目标 Session 正在等待你的处理;请先打开并完成该交互。', action_changed: '这次操作已发生变化,请重新发送。', delivery_failed: '输入未能送达,请重试。', @@ -781,6 +806,11 @@ function workHubCopy(locale: UiLocale) { aborted: '已中止', recovering: '正在恢复', }, + assignmentLinkStates: { + active: (execution: string) => `关联有效 · ${execution}`, + superseded: '已被更正', + aborted: '更正已中止', + }, turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, } as const; } @@ -793,11 +823,12 @@ function workHubCopy(locale: UiLocale) { : 'State a clear goal and WorkHub will create an ordinary Session and bring its result back here.', workCount: (count: number) => `${count} work item${count === 1 ? '' : 's'}`, clarification: 'Choose work', chooseWork: 'This input may relate to more than one task. Choose a target:', + confirmCommand: workHubAmbiguousCommandPrompt(locale), discussionStayed: 'This stayed in WorkHub without creating or changing a Session.', discussionHint: 'State an executable goal and I will hand it to the owning Session.', answering: 'Answering…', choseWork: (name: string) => `Choose “${name}”`, - sentTo: 'Sent to:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', + sentTo: 'Sent to:', createdWork: 'Created new work:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', @@ -808,7 +839,7 @@ function workHubCopy(locale: UiLocale) { retry: 'Retry', submitFailures: { candidates_changed: 'The work list changed. Send again to use the latest targets.', - linked_correction_unavailable: 'Cross-Session correction will be available with persistent delegation. Open the original Session to stop its current work first.', + linked_correction_unavailable: 'No active delegation link is available to correct. Send again, or open the original Session to confirm its current work.', target_waiting: 'The target Session needs your input. Open it and resolve that interaction first.', action_changed: 'This action changed. Send it again.', delivery_failed: 'The input could not be delivered. Try again.', @@ -823,6 +854,11 @@ function workHubCopy(locale: UiLocale) { aborted: 'Aborted', recovering: 'Recovering', }, + assignmentLinkStates: { + active: (execution: string) => `Active link · ${execution}`, + superseded: 'Superseded link', + aborted: 'Aborted replacement', + }, turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, } as const; } diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index 44a66567ea..a032114ba8 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -128,9 +128,18 @@ export function projectDesktopStoredMessage( ? { ...message, parentSessionId: projectSessionId(host, message.parentSessionId) } : message; case 'workhub_coordination': + if (message.kind === 'delegation_superseded') return message; return { ...message, targetSessionId: projectSessionId(host, message.targetSessionId), + ...(message.kind === 'delegation_replacement_requested' + ? { + replacedTargetSessionId: projectSessionId( + host, + message.replacedTargetSessionId, + ), + } + : {}), }; default: return message; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 7b49723565..06ae83fd5e 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -82,12 +82,18 @@ Every WorkHub input resolves to exactly one proposed **disposition**: - `clarify`: continue clarification in the Coordination Session without guessing a target or creating a Session. +Linked correction is a user-confirmed coordination operation over a prior durable +delegation, not a model disposition. Its replacement target is still restricted to +`delegate_existing` or explicit `create_new` admission. + All model and routing output is advisory. Before any write, a deterministic **Action Gate** admits or rejects the proposed disposition and operation. The gate enforces Runtime Host and target validity, archive and waiting state, self-route -exclusion, explicit `create_new`, and existing tool and permission ceilings. -Replacement, supersession, and Stop ownership remain deferred. Neither a model -nor a routing policy can directly authorize a write or expand execution authority. +exclusion, explicit `create_new`, and existing tool and permission ceilings. For a +replacement, the gate additionally requires explicit correction evidence in the +trusted user text, claims the source delegation in Coordination transcript order, +and rejects any later competing replacement intent. Neither a model nor a routing +policy can directly authorize a write, Stop, or expansion of execution authority. ## Delegation links rather than copies transcripts @@ -107,13 +113,20 @@ The initial assignment link does not mirror the target Turn's execution lifecycl Target acceptance, running, waiting, completion, failure, abort, and recovery state remain ordinary Session facts. WorkHub derives those states as read-only projections and does not persist them as independent Coordination Session truth. -Future replacement support may add coordination-owned `active` / `superseded` -linkage without turning target execution status into WorkHub-owned state. +Linked correction records coordination-owned `active` / `superseded` / `aborted` +linkage without turning target execution status into WorkHub-owned state. Here, +`aborted` means the source link was retired but replacement admission could not +complete because the selected target became archived, disappeared, or began +waiting for user input. The renderer rebuilds active linkage from the complete +Coordination transcript separately from its bounded visible timeline, preserving +durable transcript sequence rather than wall-clock order. The ordinary Session records the delegated request, tools, side effects, and authoritative result. WorkHub may display a bounded projection or record a coordination summary, but it does not copy the ordinary Session's complete -transcript into the Coordination Session. +transcript into the Coordination Session. The assignment projection preserves +`create_new` so the visible card explicitly tells the user that WorkHub created a +new work item rather than merely saying that an existing item accepted the request. Delegation linkage uses one closed, typed `delegation_assigned` record in the existing Coordination Session transcript. Under the Coordination and target @@ -154,6 +167,23 @@ idempotency without freezing old text or coupling draft edits to Host authority. `waiting_for_user` remains a local, retryable result because no assignment has yet been committed. +Before any destructive retirement, replacement persists a +`delegation_replacement_requested` record whose identity is unique to the source +delegation. The target Session's ordinary Message authority then either cancels +the exact still-pending delegated Message or resolves how the Message entered an +execution Turn. A root Turn created by that Message may be stopped; a pre-existing +user Turn that merely consumed it as steering is shared authority and must remain +running. Replacement assignment and the old link's `delegation_superseded` proof +commit atomically. Retrying the same action recovers the crash seam after +retirement/Stop and before replacement assignment. The replacement fingerprint +binds the resolved stable target Session id rather than its transient candidate +reference, so metadata refreshes do not change action identity and a retry cannot +select a different Session. If the target becomes archived, unavailable, or +waiting after the destructive retirement boundary, Coordination appends a +`delegation_replacement_aborted` terminal fact. That auditable fact removes the +retired source from active linkage and makes later retries return the same terminal +outcome instead of displaying a stopped, unsuperseded link. + ## Consequences, costs, and reevaluation - WorkHub gains persistent conversational continuity without adding another @@ -174,8 +204,10 @@ been committed. recovery, per-Host UI resolution, persistent transcript, closed dispositions, and the Action Gate are implemented. Durable delegation linkage is encoded in that transcript; target lifecycle projection and the hybrid first-response - contract are implemented as rebuildable reads. Linked correction and destructive - replacement/Stop recovery remain later work. + contract are implemented as rebuildable reads. Linked correction, exact + target-owned pending cancellation/Turn Stop, atomic supersession, and retry-based + replacement recovery are implemented. Broader stop/resume controls remain later + work. Reevaluate the per-Host decision if supported workflows require one WorkHub conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index 8ad8586f53..a78f03655a 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -64,27 +64,46 @@ work. **disposition**: The single proposed coordination outcome for one WorkHub input: `answer_here` answers in the Coordination Session; `delegate_existing` targets one bounded, valid ordinary Session; `create_new` creates an ordinary Session before -delegating; and `clarify` continues in the Coordination Session without guessing or -creating. +delegating and is visibly announced as new work; and `clarify` continues in the +Coordination Session without guessing or creating. **delegation**: A bounded reference from a Coordination Turn to one target ordinary Session and Turn, including only its identity, disposition, and coordination-owned -link status (`active` or `superseded`). Delegation links the separately authoritative -transcripts; it does not copy the target's complete execution transcript into -WorkHub. Target acceptance, running, waiting, completion, failure, abort, and -recovery state remain ordinary Session facts and appear in WorkHub only as read-only -projections. +link status (`active`, `superseded`, or `aborted`). A link is `aborted` only when a +correction retired its source but the replacement target became unavailable or +started waiting before admission; it is not the target Turn's execution status. +Delegation links the separately authoritative transcripts; it does not copy the +target's complete execution transcript into WorkHub. Target acceptance, running, +waiting, completion, failure, abort, and recovery state remain ordinary Session +facts and appear in WorkHub only as read-only projections. **Action Gate**: The deterministic Runtime boundary that validates a proposed disposition and operation before any write, including target/Host validity, archive and waiting state, self-routing, explicit creation, expected-Turn Stop ownership, confirmation, tools, and permissions. All model and routing output is -advisory and cannot authorize a write. - -**Route correction**: A user's decision that an input belongs to a different -existing Session. R2.4 retains only bounded inference memory for later target -resolution. Correction precedence follows user submission order, not asynchronous -completion order, and it never replaces either Session's transcript authority. +advisory and cannot authorize a write. An initial `create_new` requires affirmative, +executable trusted user text; a corrective `create_new` additionally requires an +explicit new-Session clause. Negated or withdrawn creation intent is rejected in +both cases. + +**Route correction**: A user's explicit decision that an input belongs to a +different existing or newly created Session. R2.4 retains only bounded inference +memory for target resolution; destructive confirmation must also be evidenced by +an affirmative target action in the trusted user text; negated or withdrawn target +actions fail closed and cannot come from routing output alone. The Coordination +Session durably claims one replacement intent per source delegation in transcript +order. It delegates exact pending-Message cancellation or owning-Turn Stop to the +target Session, then atomically records the replacement link and supersession. +Only a root Turn created by the delegated Message may be stopped; consuming the +Message as steering does not give WorkHub ownership of the surrounding user Turn. +When recovery folds multiple source Messages into one successor Turn, every source +shares that Turn and no individual delegation owns Stop authority over it. +Replacement replay is bound to the resolved stable target Session identity. If +that target becomes unavailable, waits for user input, or corrective creation +cannot be admitted after source retirement, +the Coordination transcript records an auditable replacement-aborted terminal +fact and removes the retired source from active linkage. +Correction never replaces either Session's transcript authority. **R2.4**: The deterministic context-continuity routing baseline. It remains useful as an experiment baseline or target resolver behind WorkHub's coordination layer; diff --git a/packages/core/package.json b/packages/core/package.json index 5dd68a2c16..e055d6d74d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -77,6 +77,7 @@ "./deep-research-client-progress": "./dist/deep-research-client-progress.js", "./daily-review": "./dist/daily-review.js", "./work-board": "./dist/work-board.js", + "./workhub-creation-intent": "./dist/workhub-creation-intent.js", "./deep-research": "./dist/deep-research.js", "./long-term-memory": "./dist/long-term-memory.js", "./local-memory": "./dist/local-memory.js", diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index 9856a12d54..ad167c6cc3 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -115,4 +115,88 @@ describe('WorkHub Coordination stored records', () => { /Invalid stored message schema/u, ); }); + + test('decodes the durable replacement intent and linked supersession proof', () => { + const replacement = { + type: 'workhub_coordination', + id: 'replacement-intent-id', + turnId: 'replacement-action', + ts: 2, + schemaVersion: 2, + kind: 'delegation_replacement_requested', + actionId: 'replacement-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'replacement-action', + targetSessionId: 'login', + targetSessionName: 'Login', + disposition: 'delegate_existing', + userText: 'No, use login instead', + replacesActionId: 'original-action', + replacesDelegationId: 'original-delegation', + replacedTargetSessionId: 'payments', + replacedTargetMessageId: 'payments-message', + } as const; + const assigned = { + type: 'workhub_coordination', + id: 'replacement-assignment-id', + turnId: 'replacement-action', + ts: 3, + schemaVersion: 2, + kind: 'delegation_assigned', + actionId: 'replacement-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'replacement-action', + targetSessionId: 'login', + targetSessionName: 'Login', + disposition: 'delegate_existing', + userText: 'No, use login instead', + delegationId: 'replacement-delegation', + targetTurnId: 'login-turn', + targetMessageId: 'login-message', + replacesActionId: 'original-action', + replacesDelegationId: 'original-delegation', + } as const; + const superseded = { + type: 'workhub_coordination', + id: 'supersession-id', + turnId: 'replacement-action', + ts: 3, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: 'replacement-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'replacement-action', + supersededActionId: 'original-action', + supersededDelegationId: 'original-delegation', + replacementDelegationId: 'replacement-delegation', + } as const; + const aborted = { + type: 'workhub_coordination', + id: 'replacement-aborted-id', + turnId: 'replacement-action', + ts: 3, + schemaVersion: 2, + kind: 'delegation_replacement_aborted', + actionId: 'replacement-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'replacement-action', + abortedActionId: 'original-action', + abortedDelegationId: 'original-delegation', + targetSessionId: 'login', + reason: 'target_unavailable', + } as const; + + assert.deepEqual(decodeCanonicalMessage(replacement), replacement); + assert.deepEqual(decodeCanonicalMessage(assigned), assigned); + assert.deepEqual(decodeCanonicalMessage(superseded), superseded); + assert.deepEqual(decodeCanonicalMessage(aborted), aborted); + assert.throws( + () => decodeCanonicalMessage({ ...superseded, replacementDelegationId: '' }), + /Invalid stored message schema/u, + ); + assert.throws( + () => decodeCanonicalMessage({ ...aborted, reason: 'retry_later' }), + /Invalid stored message schema/u, + ); + }); }); diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts new file mode 100644 index 0000000000..bce72071bf --- /dev/null +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -0,0 +1,999 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + readWorkHubRequestIntent, + workHubCorrectionTargetsSession, + workHubCreationAuthorizesTitle, +} from '../workhub-creation-intent.js'; + +const intentFor = readWorkHubRequestIntent; +const affirmativeWorkHubExistingCorrectionTarget = (value: string) => + intentFor(value).correction.existingTarget; +const affirmativeWorkHubNamedCreationTitle = (value: string) => { + const naming = intentFor(value).creation.naming; + return naming.kind === 'named' ? naming.title : undefined; +}; +const hasNegatedWorkHubCreationRequest = (value: string) => !intentFor(value).creation.explicit; +const hasWorkHubNamedCreationClause = (value: string) => + intentFor(value).creation.naming.kind !== 'none'; +const isAffirmativeWorkHubCorrectionRequest = (value: string) => { + const intent = intentFor(value); + return ( + intent.correction.cue && + Boolean( + intent.correction.existingTarget || + (intent.creation.explicit && intent.execution === 'imperative'), + ) + ); +}; +const isAffirmativeWorkHubExistingTargetCorrectionRequest = ( + value: string, + expectedTargetName?: string, +) => { + const intent = intentFor(value); + return expectedTargetName + ? workHubCorrectionTargetsSession(intent, expectedTargetName) + : Boolean(intent.correction.existingTarget); +}; +const isAffirmativeWorkHubNewTopicRequest = (value: string) => + intentFor(value).execution === 'imperative'; +const isExplicitWorkHubCreationRequest = (value: string) => intentFor(value).creation.explicit; + +test('requires an affirmative target action for destructive corrections', () => { + for (const text of [ + 'No, use Payments instead', + 'Not this session; move it to Payments', + 'Wrong session; switch to Payments', + '不是这个会话,转到支付任务', + '不是这个,换成登录那个', + ]) { + assert.equal(isAffirmativeWorkHubExistingTargetCorrectionRequest(text), true, text); + assert.equal(isAffirmativeWorkHubCorrectionRequest(text), true, text); + } + assert.equal( + affirmativeWorkHubExistingCorrectionTarget('Not this session; move it to Payments'), + 'Payments', + ); + assert.equal( + affirmativeWorkHubExistingCorrectionTarget('No, use Payments instead of Login'), + 'Payments instead of Login', + ); + for (const [text, targetName] of [ + ['No, use "Payments"', 'Payments'], + ['不是这个,换成“支付任务”', '支付任务'], + ['No, use Research and Development', 'Research and Development'], + ['No, use Payments, Retry', 'Payments, Retry'], + ] as const) { + assert.equal(isAffirmativeWorkHubExistingTargetCorrectionRequest(text, targetName), true, text); + } + assert.equal( + isAffirmativeWorkHubExistingTargetCorrectionRequest('No, use Payments, not Login', 'Login'), + false, + ); + for (const [text, targetName] of [ + ['No, use rapid instead', 'API'], + ['No, use Repayment instead', 'Payment'], + ['No, use Payments instead of Login', 'Login'], + ['No, use Payments, not Login', 'Login'], + ['No, move it to Login frontend', 'Login backend'], + ['No, move it to API docs', 'API client'], + ['不是这个,换成支付页面', '支付回调'], + ['不是这个,换成登录文档', '登录稳定性'], + ['不是这个,转到支付回调', '支付任务'], + ] as const) { + assert.equal( + isAffirmativeWorkHubExistingTargetCorrectionRequest(text, targetName), + false, + `${text} must not authorize ${targetName}`, + ); + } + for (const text of [ + "Not this session; don't move it to Payments", + 'Wrong session; do not switch to Payments', + '不是这个会话,但不要转到支付任务', + '不是这个,别换成登录那个', + "No, move it to Payments, actually don't", + "Not this session; move to Payments, but I don't want to move anymore", + '不是这个会话,转到支付任务,不过我不想转了', + 'No examples use Payments.', + 'This note says no, use Payments', + "No, use Payments but don't", + "No, use Payments and actually don't", + "No, use Payments and don't want to move it", + '不是这个,转到支付任务然后不想转了', + ]) { + assert.equal(isAffirmativeWorkHubExistingTargetCorrectionRequest(text), false, text); + assert.equal(isAffirmativeWorkHubCorrectionRequest(text), false, text); + } + for (const [text, targetName] of [ + ["No, use Payments and don't proceed", 'Payments'], + ["No, use Payments and don't go ahead with that", 'Payments'], + ['No, use Payments, forget it', 'Payments'], + ['No, use Payments, on second thought leave it', 'Payments'], + ['不是这个,转到支付任务然后不要继续', '支付任务'], + ['不是这个,换成支付任务,当我没说', '支付任务'], + ['不是这个,换成支付任务,还是维持原样', '支付任务'], + ["No, use Payments and don't proceed", "Payments and don't proceed"], + ['不是这个,转到支付任务然后不要继续', '支付任务然后不要继续'], + ['No, use Payments, fix login. Forget it', 'Payments'], + ['No, use Payments — on second thought leave it', 'Payments'], + ['不是这个,换成支付任务,修复登录。当我没说', '支付任务'], + ['No, use Payments and stop.', 'Payments and stop'], + ['不是这个,转到支付任务然后停止。', '支付任务然后停止'], + ['No, use Payments and abort.', 'Payments and abort'], + ['No, use Payments and cancel.', 'Payments and cancel'], + ['No, use Payments and stop now.', 'Payments and stop now'], + ['No, use Payments and halt this.', 'Payments and halt this'], + ['No, use Payments and ABORT.', 'Payments and ABORT'], + ['No, use Payments but abort.', 'Payments but abort'], + ['No, use Payments; stop now.', 'Payments; stop now'], + ['No, use Payments. Abort.', 'Payments. Abort'], + ['No, use Payments, cancel.', 'Payments, cancel'], + ['不是这个,转到支付任务然后作罢。', '支付任务然后作罢'], + ['不是这个,转到支付任务然后停止执行。', '支付任务然后停止执行'], + ['不是这个,转到支付任务但是作罢。', '支付任务但是作罢'], + ['不是这个,转到支付任务。作罢。', '支付任务。作罢'], + ['No, use Payments. I changed my mind.', 'Payments. I changed my mind'], + [ + 'No, use Payments. On second thought, keep it here.', + 'Payments. On second thought, keep it here', + ], + ['不是这个,转到支付任务。我改主意了。', '支付任务。我改主意了'], + ] as const) { + assert.equal( + isAffirmativeWorkHubExistingTargetCorrectionRequest(text, targetName), + false, + text, + ); + } + assert.equal( + isAffirmativeWorkHubCorrectionRequest('No, create a new Session for Payments instead'), + true, + ); + assert.equal( + isAffirmativeWorkHubExistingTargetCorrectionRequest('No, use red instead', 'Payments'), + false, + ); + for (const text of [ + 'No examples create a new Session.', + 'This note says no, create a new Session called Payments', + "No, create a new Session for login but don't", + 'No, please explain how to create a new Session', + 'No, tell me how to create a new Session', + '不对,请解释如何创建一个新的 Session', + '错了,请告诉我怎么创建一个新的 Session', + ]) { + assert.equal(isAffirmativeWorkHubCorrectionRequest(text), false, text); + assert.equal(isExplicitWorkHubCreationRequest(text), false, text); + } +}); + +test('recognizes affirmative creation after an explicit contrast', () => { + assert.equal( + isExplicitWorkHubCreationRequest('不是继续旧会话,而是创建一个新的 Session 登录稳定性'), + true, + ); + assert.equal( + isExplicitWorkHubCreationRequest( + "Don't continue the old task; instead create a new session called Login", + ), + true, + ); + assert.equal(isExplicitWorkHubCreationRequest('Can you create a new Session for login?'), true); + assert.equal( + affirmativeWorkHubNamedCreationTitle('No, create a new Session called Login instead'), + 'Login', + ); + assert.equal( + affirmativeWorkHubNamedCreationTitle('不是这个,请创建一个新会话叫登录稳定性'), + '登录稳定性', + ); + for (const [text, title] of [ + ['No, create a new Session titled Login instead', 'Login'], + ['No, create a new Session with title Login', 'Login'], + ['不对,请创建一个新的 Session 标题为登录稳定性', '登录稳定性'], + ['错了,新建一个会话名称为支付任务', '支付任务'], + [ + "No, don't create a new session called Login; instead create a new session called Payments.", + 'Payments', + ], + ['不对,不要创建一个新会话叫登录;而是创建一个新会话叫支付。', '支付'], + [ + 'No, create a new Session called Payments, and add documentation containing the example new Session called Fraud.', + 'Payments', + ], + ['Create a new Session called U.S. Payments', 'U.S. Payments'], + ['Create a new Session called Dr. Login', 'Dr. Login'], + ['Create a new Session called Acme Inc. Payments', 'Acme Inc. Payments'], + ['Create a new Session called No. 5 Login', 'No. 5 Login'], + ['Create a new Session called Ph.D. Research', 'Ph.D. Research'], + ['Create a new Session called Payments. Fix login', 'Payments'], + ['Create a new Session called App. Fix login', 'App'], + ['Create a new Session called Fix. Add documentation.', 'Fix'], + ['Create a new Session called Go. Then add tests.', 'Go'], + ['Create a new Session called Acme Inc. Fix login.', 'Acme Inc'], + ['Create a new Session called U.S. Fix login.', 'U.S'], + ['Create a new Session called Ph.D. Fix login.', 'Ph.D'], + ['Create a new Session called No. Fix login.', 'No'], + ['Create a new Session called St. Fix login.', 'St'], + ['Create a new Session called Acme Inc. Then fix login.', 'Acme Inc'], + ['Create a new Session called Acme Inc. Please fix login.', 'Acme Inc'], + ['Create a new Session called Acme Inc. Please then fix login.', 'Acme Inc'], + ['Create a new Session called Acme Inc. Then, please fix login.', 'Acme Inc'], + ['Create a new Session called Acme Inc. Finally fix login.', 'Acme Inc'], + ['Create a new Session called Acme Inc. Afterwards fix login.', 'Acme Inc'], + ['Create a new Session called U.S. Can you fix login?', 'U.S'], + ['Create a new Session called U.S. Next, fix login.', 'U.S'], + ['Create a new Session called U.S. Also fix login.', 'U.S'], + ['Create a new Session called U.S. Immediately fix login.', 'U.S'], + ['Create a new Session called U.S. Proceed to fix login.', 'U.S'], + ['Create a new Session called U.S. At that point fix login.', 'U.S'], + ['Create a new Session called U.S. Daily Fix', 'U.S. Daily Fix'], + ['Create a new Session called U.S. Monthly Update', 'U.S. Monthly Update'], + ['Create a new Session called U.S. Monthly update', 'U.S. Monthly update'], + ['Create a new Session called U.S. customer update', 'U.S. customer update'], + ['Create a new Session called Ph.D. Could you fix login?', 'Ph.D'], + ['Create a new Session called Ph.D. Now fix login.', 'Ph.D'], + ['Create a new Session called Ph.D. Finally fix login.', 'Ph.D'], + ['Create a new Session called Ph.D. 接下来修复登录。', 'Ph.D'], + ['Create a new Session called Ph.D. 最后修复登录。', 'Ph.D'], + ['Create a new Session called Ph.D. Friendly Fix', 'Ph.D. Friendly Fix'], + ['Create a new Session called Ph.D. Friendly fix', 'Ph.D. Friendly fix'], + ] as const) { + assert.equal(hasWorkHubNamedCreationClause(text), true, text); + assert.equal(affirmativeWorkHubNamedCreationTitle(text), title, text); + } + const unnamedLastCreation = + "No, don't create a new session called Login; instead create a new session for Payments"; + assert.equal(affirmativeWorkHubNamedCreationTitle(unnamedLastCreation), undefined); + assert.equal(hasWorkHubNamedCreationClause(unnamedLastCreation), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest(unnamedLastCreation), false); + for (const text of [ + 'Create a new Session called "Payments', + '创建一个新会话叫“支付任务', + "Create a new Session called Payments and don't proceed.", + '创建一个新会话叫支付任务然后不要继续。', + 'Create a new Session called Payments and stop.', + 'Create a new Session called Payments and abort.', + 'Create a new Session called Payments and cancel.', + 'Create a new Session called Payments and stop now.', + 'Create a new Session called Payments and halt this operation immediately.', + 'Create a new Session called Payments and ABORT.', + 'Create a new Session called Payments but abort.', + 'Create a new Session called Payments; stop now.', + 'Create a new Session called Payments. Abort.', + 'Create a new Session called Payments, cancel.', + '创建一个新会话叫支付任务然后作罢。', + '创建一个新会话叫支付任务然后停止执行。', + '创建一个新会话叫支付任务但是作罢。', + '创建一个新会话叫支付任务。作罢。', + 'No, create a new Session called Payments. Example: create a new Session called Fraud.', + 'Create a new Session called Payments.Example: create a new Session called Fraud', + 'Create a new Session called App. Example: create a new Session called Fraud', + 'No, create a new Session called Payments. Fix login, cancel this task.', + 'No, create a new Session called Payments. Cancel the current task.', + 'No, create a new Session called Payments. Rescind my request.', + 'No, create a new Session called Payments. Drop this task.', + 'No, create a new Session called Payments. Fix login. Please cancel the task.', + "No, create a new Session called Payments. Fix login. Let's cancel the task.", + 'No, create a new Session called Payments. I do not wish to proceed.', + ]) { + assert.equal(hasWorkHubNamedCreationClause(text), true, text); + assert.equal(affirmativeWorkHubNamedCreationTitle(text), undefined, text); + assert.equal(isAffirmativeWorkHubNewTopicRequest(text), false, text); + } + assert.equal( + isExplicitWorkHubCreationRequest('Can you explain whether we should create a new Session?'), + false, + ); +}); + +test('rejects direct, parenthetical, Unicode, and anaphoric creation negation', () => { + const cases = [ + '不是这个,而是请勿创建一个新的 Session', + 'Wrong session; don’t ever create a new session', + 'Wrong session; do not, under any circumstances, create a new session', + 'Wrong session; must not create a new session', + '我不想创建一个新的 Session', + '我不打算创建一个新的 Session', + '我无意创建一个新的 Session', + '我不是要创建一个新的 Session,只是讨论', + '我并非要创建一个新的 Session,只是讨论', + '不是想创建一个新的 Session,只是问问', + '我不是让你创建一个新的 Session,只是讨论', + '我不是说要创建一个新的 Session,只是讨论', + '并非让你创建一个新的 Session,只是讨论', + '我没让你创建一个新的 Session,只是讨论', + '我没有让你创建一个新的 Session,只是讨论', + '我不希望你创建一个新的 Session,只是讨论', + '我不是请你创建一个新的 Session,只是讨论', + '我没说要创建一个新的 Session,只是讨论', + '我没有说要创建一个新的 Session,只是讨论', + '我没有打算创建一个新的 Session,只是讨论', + '我没准备创建一个新的 Session,只是讨论', + 'Please refuse to create a new Session', + 'I decline to create a new Session; just discuss', + '我未打算创建一个新的 Session,只是讨论', + 'I will not create a new session', + 'not create a new session; just discuss', + 'Under no circumstances create a new session', + 'Create a new Session; do not create a new Session', + '创建一个新的 Session;不要创建一个新的 Session', + "Create a new Session, but don't create it", + "Create a new Session, but don't create one after all", + '创建一个新的 Session,不过不要创建它', + "Create a new Session for login — actually, don't", + '创建一个新的 Session 处理登录,还是别了', + "Create a new Session to fix login, logout, etc. Don't.", + "Create a new Session for login\nDon't.", + "Create a new Session for login - don't", + "Create a new Session for parser tokens:\ndon't\n\nactually, don't", + "Create a new Session for login\nCorrection:\ndon't", + "Create a new Session for login\nFinal correction:\ndon't", + "Create a new Session for login\nCorrection:\n- don't", + "Create a new Session for login\nCorrection:\n1. don't", + "Create a new Session for login\nCorrection:\n don't", + '创建一个新的 Session 处理登录\n更正:\n- 还是别了', + "Create a new Session for login\nCorrection note:\n- don't", + "Create a new Session for login\nOn second thought:\n1. don't", + '创建一个新的 Session 处理登录\n想了想:\n 还是别了', + "Create a new Session for login\n## Correction:\n- don't", + "Create a new Session for login\n**Correction:**\n- don't", + '创建一个新的 Session 处理登录\n## 更正:\n- 还是别了', + "Create a new Session for login\nChange to:\n- don't", + "Create a new Session for login\nUpdate to:\ndon't", + "Create a new Session for login\nCorrection to:\n1. don't", + '创建一个新的 Session 处理登录\n改为:\n- 还是别了', + "Create a new Session for login\nIn any case:\ndon't", + "Create a new Session for parser examples:\n- do\n \n- don't", + "Create a new Session for parser examples:\n1. do\n\t\n2. don't", + "Create a new Session for login\nFor this parser case:\ndon't", + '不是这个,而是创建一个新的 Session;不过不要这样做', + 'Wrong session; create a new session, but do not do that', + ]; + for (const value of cases) { + assert.equal(hasNegatedWorkHubCreationRequest(value), true, value); + assert.equal(isExplicitWorkHubCreationRequest(value), false, value); + assert.equal(isAffirmativeWorkHubNewTopicRequest(value), false, value); + } +}); + +test('recognizes affirmative executable topics without trusting negated work', () => { + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复支付回调重复投递'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Create an accessibility audit'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Can you fix login stability?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Could you implement payment retry?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('请修复支付回调重复投递?'), true); + for (const text of [ + 'Fix how login errors are reported', + 'Update how retries are calculated', + '请修复用户不知道怎么登录的问题', + '请实现如何恢复失败任务的逻辑', + 'Implement docs to explain how retries work', + 'Update the guide to discuss why login fails', + '修复帮助页以解释如何恢复失败任务', + ]) { + assert.equal(isAffirmativeWorkHubNewTopicRequest(text), true, text); + } + assert.equal( + isExplicitWorkHubCreationRequest('Create a new Session to fix how login errors are reported'), + true, + ); + for (const text of [ + '我想知道如何修复登录问题', + '我们应该如何修复登录问题', + 'Please explain how to fix login', + 'Tell me how to fix login', + 'I would like to understand how to fix login', + '麻烦解释如何修复登录问题', + '请告诉我如何修复登录问题', + 'Show me how to fix login', + 'Could you walk me through how to fix login', + 'What steps should I take to fix login', + '教我怎么修复登录问题', + '给我讲讲如何修复登录问题', + 'Can you tell me if we should fix login?', + 'Could you evaluate if we should implement payment retry?', + '请告诉我该不该修复登录问题?', + '请告诉我应不应该实现支付重试?', + 'Can you give me steps to fix login', + 'Please give me a way to fix login', + 'Could you outline the steps to fix login', + '告诉我修复登录的步骤', + '给我一个修复登录的方法', + 'Can you tell me: should I fix login?', + 'Can you recommend I fix login?', + '请告诉我:我应该修复登录吗?', + '请告诉我应否修复登录?', + 'Can you tell me if I need to fix login', + 'Could you tell me if I must implement retry', + 'I need to know if I need to fix login', + '请告诉我我应否修复登录', + 'Can you fix login, or should we wait?', + 'Can you fix login? Actually, should we?', + 'Can you fix login, or should we wait', + 'Can you fix login. Actually, should we', + '请修复登录,还是应该先等等?', + '请修复登录,还是应该先等等', + 'Can you fix login, or leave it for now?', + '请修复登录,还是等等吧?', + 'Fix login. Maybe we should wait', + 'Fix login. On second thought, maybe wait', + 'Can you tell me if it is necessary to fix login', + 'Explain when to fix login', + 'Tell me in which cases to fix login', + '请告诉我在什么情况下修复登录', + 'Fix login, but maybe we should wait', + 'Fix login; perhaps we should wait', + '请修复登录,不过也许应该等等', + 'Fix login. Actually, I am not sure.', + 'Fix login. Do you think we should?', + "Fix login. On second thought, I'm not sure", + '请修复登录。我不确定。', + 'Can you recommend I fix login', + 'Could you suggest I implement retry', + 'Explain the circumstances in which to fix login', + 'Tell me the best time to fix login', + 'When should I fix login?', + 'When do we fix login?', + '如果什么时候修复登录?', + 'If unsure whether to fix login?', + 'When in doubt, ask whether to fix login?', + 'If it is unclear how to implement retry?', + 'When is it appropriate to fix login?', + '如果适合修复登录?', + 'Fix login. Is that wise?', + 'Fix login, cancel this task.', + 'Fix login, I take that back.', + 'Create a new Session for Payments; cancel the creation.', + 'Create a new Session for Payments. Cancel the new session.', + '创建一个新会话用于支付然后停止创建。', + 'Fix login, cancel this job.', + 'Fix login, withdraw the request.', + 'Fix login, retract that.', + 'Fix login, forget the request.', + 'Create a new Session for Payments; cancel my request.', + 'Create a new Session for Payments; withdraw that request.', + 'Create a new Session for Payments. Revoke that request.', + 'When might we fix login?', + 'When may we fix login?', + 'When will we fix login?', + 'If it makes sense to fix login?', + '如果现在修复登录合适吗?', + 'Fix login. Are we sure?', + 'Fix login. Are you sure?', + 'Fix login. Are they sure?', + 'Fix login. Should we really?', + 'Fix login, cancel the current task.', + 'Fix login, rescind my request.', + 'Fix login, drop this task.', + 'Fix login. Please cancel the task.', + 'Fix login. I want to cancel the task.', + 'Fix login. Could you cancel the task?', + "Fix login. Let's cancel the task.", + 'Fix login. I would prefer to cancel the task.', + 'Fix login. Please do not proceed with the task.', + 'Fix login. I do not wish to proceed.', + 'When should the service fix login?', + 'When will Alice fix login?', + 'When will the patch fix login?', + 'When must the service fix login?', + 'When ought we fix login?', + 'If you think we should fix login?', + 'If you believe we ought to implement retry?', + 'If it is advisable to fix login?', + 'If I wanted you to fix login, what would happen?', + 'If I asked you to fix login, how would you approach it?', + 'If the plan were to fix login, would that be wise?', + 'If I asked you to fix login?', + 'If I wanted you to fix login?', + 'If the plan were to fix login?', + 'If I wanted you to fix login what would happen?', + 'If I asked you to fix login how would you approach it?', + 'If the plan were to fix login would that be wise?', + '如果现在修复登录可以吗?', + '如果现在修复登录可行吗?', + '如果我让你修复登录会怎样?', + 'Fix login. Do you agree?', + 'Fix login. Are you certain?', + 'Fix login. Do you still want that?', + 'Fix login — do you agree?', + 'Fix login: are you sure?', + 'Fix login, okay?', + 'Fix login, sound good?', + 'Fix login, maybe?', + 'Fix login, perhaps?', + 'Fix login, not sure?', + 'Fix login, any concerns?', + '修复登录,没问题吧?', + 'Could you suggest ways to monitor and fix login', + 'Explain techniques that diagnose and fix login errors.', + 'Discuss approaches that prevent and fix login errors.', + 'Explain the steps to diagnose and fix login.', + 'Explain strategies that diagnose and fix login.', + 'Recommend patterns that detect and fix login.', + 'Could you suggest practical options to monitor and fix login', + 'Tell me possible solutions to identify and fix login', + 'Describe techniques that diagnose and fix login.', + 'Analyze strategies that diagnose and fix login.', + 'Explain a process where we diagnose and fix login.', + 'Describe a framework that diagnoses and fix login.', + 'Outline a workflow that detects and fix login.', + 'Summarize a proposal where we diagnose and fix login.', + 'Compare tools that detect and fix login.', + 'I plan to fix login myself.', + 'The team will fix login.', + 'Suppose we fix login.', + 'If we fix login, users will be happier.', + 'When we fix login, users will be happier.', + '如果我们修复登录,用户会更满意。', + 'If the team can fix login, users will be happier.', + 'If Alice can fix login, users will be happier.', + '如果团队能修复登录,用户会更满意。', + 'Should we fix login and then update docs?', + 'Can we diagnose login and then fix it?', + 'What if we fix login and then update docs?', + 'Maybe investigate and fix login.', + 'Perhaps review and update docs.', + 'Potentially debug and fix login.', + 'Our goal is to investigate and fix login.', + 'The requirement is to investigate and fix login.', + 'The service must diagnose and fix login.', + 'Should we diagnose, then fix login?', + 'How should we fix login, then update docs?', + 'Explain how to fix login and then update docs.', + 'Tell me how to diagnose and then fix login.', + 'Can you explain how to diagnose login and then fix it?', + 'Explain whether we should diagnose then fix login.', + 'Discuss whether to diagnose then fix login.', + 'Tell me how we should diagnose then fix login.', + 'Explain how to diagnose, fix, and test login.', + 'Recommend ways to diagnose, fix, and test login.', + 'Explain how to diagnose login, fix it, and update docs.', + 'Explain whether we should diagnose, then fix login.', + 'Explain the workflow: diagnose, then fix login.', + 'Discuss the sequence: diagnose, then fix login.', + 'Review notes and fix status are attached.', + 'Audit results and fix plans are attached.', + 'Research findings and fix recommendations are attached.', + 'Explain how to diagnose login; then fix it. Is that wise?', + 'Explain how to diagnose login, then fix it—but is that wise?', + 'Explain how to diagnose the text "login, then fix it".', + 'Explain how to diagnose a phrase saying "login; then fix it".', + 'Explain how to diagnose login, and test results are attached.', + 'Explain how to diagnose login; then test results are available.', + 'Audit findings and fix recommendations both matter.', + 'Research findings and fix recommendations changed yesterday.', + '分析报告并修复建议已经附上。', + '调查结果并修复建议都很重要。', + 'Explain how to diagnose the text `login, then fix it`.', + 'Explain how to diagnose the sequence (login, then fix it).', + 'Explain how to diagnose login; then fix it, any concerns?', + 'Explain how to diagnose login; then fix it, do you agree?', + 'Audit findings and fix recommendations matter.', + 'Review notes and fix status matters.', + 'Research findings and fix recommendations changed.', + 'Explain how to diagnose login; then test results matter.', + 'Explain how to diagnose login; then test coverage improved.', + 'Explain how to diagnose login; then update metrics increased.', + 'Explain how to diagnose the text "login, then fix it.', + 'Explain how to diagnose the text `login, then fix it.', + 'Explain how to diagnose the sequence (login (primary), then fix it).', + 'Explain how to diagnose the sequence [login, then fix it].', + ]) { + assert.equal(isAffirmativeWorkHubNewTopicRequest(text), false, text); + } + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Don't modify the old task; instead fix login stability"), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If retries fail, fix login'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If tests fail, fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If needed, implement payment retry'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If needed fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If necessary implement retries.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When ready fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If possible fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If required fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If safe fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If appropriate implement retries.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When convenient update docs.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When available fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When feasible fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If desired fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If applicable fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When practical update docs.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If advisable implement retries.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If permitted fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When complete update docs.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If urgent fix login.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When sensible implement retries.'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Can you please fix login?'), true); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Could you kindly implement payment retry?'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If retries fail, can you fix login?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If retries fail can you fix login?'), true); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('If retries fail then can you fix login?'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When retries fail, can you fix login?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('If retries fail then fix login?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When retries fail, fix login?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('When retries fail fix login?'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Tell me the options and fix login'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Recommend options and fix login'), true); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Can you fix login, but leave documentation unchanged?'), + true, + ); + for (const text of [ + 'Please try to reproduce and fix login', + 'Try to reproduce and fix login', + 'Work to diagnose and fix login', + 'Explain that issue and fix login', + 'Update the label to How can I help?', + 'Fix copy to say What should I do?', + 'Implement an FAQ answering How can I recover?', + 'Update the prompt to How can I help?', + 'Fix the heading to What should I do?', + 'Update the tooltip to Where can I find files?', + 'Update the message to Why did this fail?', + 'Investigate and fix login', + 'Analyze and fix login', + 'Debug and fix login', + 'Review and update docs', + 'First investigate, then fix login', + 'Assess and fix login', + 'Examine and fix login', + '调查并修复登录', + '先分析,然后修复登录', + 'Investigate the issue and fix both login and logout.', + 'Review the failure and fix the affected user accounts.', + 'Analyze the suite and update the generated docs.', + '先分析,然后修复已经失败的测试。', + 'Investigate and fix login stability.', + 'Review and update API docs.', + 'Analyze and fix payment retry logic.', + 'Audit and update generated API docs.', + 'Investigate issue and fix login for mobile.', + 'Assess logs and update docs for operators.', + 'Review issue and fix login in production.', + ]) { + assert.equal(isAffirmativeWorkHubNewTopicRequest(text), true, text); + } + for (const text of [ + 'Explain how to fix login, then update the docs.', + 'Tell me how to diagnose login, and fix the bug.', + '解释如何修复登录,然后更新文档。', + 'Explain how to diagnose login, then fix and test it.', + 'Explain how to diagnose login; then fix it.', + 'Explain how to diagnose and reproduce login, then fix it.', + 'Tell me how to diagnose and test login, then update docs.', + 'Explain how to diagnose, reproduce, and test login; then fix it.', + 'Explain how to diagnose the text "login, fix it"; then update docs.', + 'Explain how to diagnose login; then update the prompt to "How can I help?"', + "Explain how to diagnose what's wrong, then fix what's broken.", + 'Explain how to diagnose the text "do not fix", then update docs.', + 'Explain how to diagnose the text `do not fix`, then update docs.', + 'Explain how to diagnose the text (do not fix), then update docs.', + 'Explain how to diagnose the text "do not update", then fix login.', + 'Show me how to diagnose login, then fix it.', + 'Walk me through how to diagnose login, then fix it.', + '教我如何诊断登录,然后修复它。', + ]) { + assert.equal(intentFor(text).execution, 'ambiguous', text); + } + assert.equal(isAffirmativeWorkHubNewTopicRequest('如果重试失败就请修复登录?'), true); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login, but leave documentation unchanged'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login, but hold API behavior constant'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login, but wait for tests before merging'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Explain the issue, then fix login'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('Tell me the cause and fix login'), true); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Discuss the approach, then implement retry'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Consider the options, but fix login now'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login stability, but do not create any files'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性,但不要创建任何文件'), true); + assert.equal( + isExplicitWorkHubCreationRequest('Create a new Session for login, but do not create files'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Create a new Session for login, but do not create files'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Don't modify any files"), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Don't fix or implement login stability"), + false, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('不要修复或实现登录稳定性'), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability, actually don't fix it"), + false, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login stability — actually, don't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性,还是别了'), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login, logout, etc. Don't."), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nDon't."), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性\n还是别了'), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login stability - don't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性 - 还是别了'), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix parser tokens:\ndon't\n\nactually, don't"), + false, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nCorrection:\ndon't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录\n更正:\n还是别了'), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nWait:\ndon't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录\n不对:\n还是别了'), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nCorrection note:\n- don't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录\n想了想:\n 还是别了'), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nIn that case:\ndon't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nFor example:\ndon't"), false); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login\nIn this test case:\ndon't"), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login\nParser in this case:\ndon't"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login\nWith this config value:\ndon't"), + false, + ); + for (const text of [ + "Create a new Session for login\nConfig in this case:\ndon't", + "Create a new Session for login\nTesting, for example:\ndon't", + ]) { + assert.equal(isExplicitWorkHubCreationRequest(text), false, text); + assert.equal(isAffirmativeWorkHubNewTopicRequest(text), false, text); + } + for (const text of [ + "Create a new Session for parsing don't", + "Fix parsing of don't", + 'Update the button label to do not', + '修改按钮文案为不要了', + "Create a new Session for parsing contractions, e.g. don't", + 'Fix parsing examples, i.e. do not', + "Create a new Session for parsing contractions, e.g., don't", + 'Fix parsing examples, i.e., do not', + 'Update the button label to:\ndo not', + "Fix parser support for this token:\ndon't", + "Fix parser for these literals:\ndon't\ndo not", + '修改按钮文案为:\n不要了', + "Create a new Session for parsing this token:\ndon't", + "Create a new Session to test cases\n1. don't", + "Fix parser for cases\n1. don't", + "Create a new Session to test this code\n don't", + "Fix parser for this code\n\tdon't", + "Create a new Session to test list items\n- don't", + "Fix parser for list items\n- don't", + "Update parser examples:\n- do\n- don't", + "Update parser examples:\n1. do\n2. don't", + "Update parser examples:\n do\n don't", + "Create a new Session for parser examples:\n- do\n- don't", + "Create a new Session to test parser\n*Examples:*\n- don't", + "Create a new Session to test parser\n_Examples:_\n- don't", + 'Create a new Session to update copy\n帮我修改按钮文案为:\n不要了', + '请帮我修改按钮文案为:\n不要了', + "Fix parser support for foo-don't", + "Create a new Session for parsing foo-don't", + ]) { + assert.equal(isAffirmativeWorkHubNewTopicRequest(text), true, text); + } + assert.equal(isExplicitWorkHubCreationRequest("Create a new Session for parsing don't"), true); + assert.equal( + isExplicitWorkHubCreationRequest("Create a new Session for parsing this token:\ndon't"), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest("Fix login stability, but don't fix it"), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability, but don't do that"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability, instead don't do that"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability, but don't implement it"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Implement login stability, but don't fix it"), + false, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性,但不要修改它'), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability, actually don't fix login stability"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login stability, but do not fix login stability'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('修复登录稳定性,不过不要修复登录稳定性'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login stability and do not fix login stability'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability then don't fix login stability"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login stability and then do not fix login stability'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest( + 'Fix login stability and please do not fix login stability', + ), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest( + "Fix login stability then kindly don't fix login stability", + ), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('修复登录稳定性然后请不要修复登录稳定性'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest( + 'Fix login stability and could you please not fix login stability', + ), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('修复登录稳定性然后麻烦你不要修复登录稳定性'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('修复登录稳定性然后真的不要修复登录稳定性'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login stability and just do not fix login stability'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login stability and simply don't fix login stability"), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('修复登录稳定性然后千万不要修复登录稳定性'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix login and do not fix login documentation'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix checkout, but do not fix checkout tests'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录,但不要修复登录文档'), true); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Update API documentation, but do not update API'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Fix checkout tests, but do not fix checkout'), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录文档,但不要修复登录'), true); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性并且不要修复登录稳定性'), false); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Do not create a new Session to fix login stability'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('不要创建一个新的 Session 来修复登录稳定性'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('Do not create a new Session. Fix login stability'), + false, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest( + "Fix login, but don't do that; instead implement payment retry", + ), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest('修复登录,但不要这样做;而是实现支付重试'), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest("Fix login, but don't do that. Implement payment retry"), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest( + "Create a new Session for login, but don't do that; instead implement payment retry", + ), + true, + ); + assert.equal( + isAffirmativeWorkHubNewTopicRequest( + '创建一个新的 Session 处理登录,不过不要这样做;而是实现支付重试', + ), + true, + ); + assert.equal(isAffirmativeWorkHubNewTopicRequest('修复登录稳定性,不过不要修复它'), false); +}); + +test('returns one bounded intent record for routing and admission', () => { + const named = readWorkHubRequestIntent('Create a new Session called Login'); + assert.equal(named.execution, 'imperative'); + assert.deepEqual(named.creation, { + explicit: true, + naming: { kind: 'named', title: 'Login' }, + }); + assert.equal(workHubCreationAuthorizesTitle(named, 'Login'), true); + assert.equal(workHubCreationAuthorizesTitle(named, 'Payments'), false); + + const direct = readWorkHubRequestIntent('Fix login, then update docs.'); + const quotedNegation = readWorkHubRequestIntent('Fix the text "do not fix", then update docs.'); + assert.equal(direct.execution, 'imperative'); + assert.equal(quotedNegation.execution, direct.execution); + + assert.equal( + readWorkHubRequestIntent("Explain how to diagnose what's wrong, then fix what's broken.") + .execution, + 'ambiguous', + ); + for (const text of [ + 'Explain how to diagnose the text "login, then fix it.', + 'Explain how to diagnose the sequence (login (primary), then fix it).', + 'Explain how to diagnose the sequence [login, then fix it].', + 'Fix login] then update docs.', + ]) { + assert.equal(readWorkHubRequestIntent(text).execution, 'non_executable', text); + } +}); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index cbfc7efec3..20ed63e2bd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -925,6 +925,7 @@ export interface TurnStateMessage { } export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const; +export const WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION = 2 as const; export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new'; @@ -943,7 +944,9 @@ interface WorkHubCoordinationMessageEnvelope { /** The Coordination Turn that owns this action. */ turnId: string; ts: number; - schemaVersion: typeof WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION; + schemaVersion: + | typeof WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION + | typeof WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION; actionId: string; actionFingerprint: `sha256:${string}`; coordinationTurnId: string; @@ -966,8 +969,61 @@ export interface WorkHubDelegationAssignedMessage extends WorkHubCoordinationMes targetMessageId: string; targetSessionName: string; steered?: true; + /** Present only when this assignment atomically supersedes an earlier link. */ + replacesActionId?: string; + replacesDelegationId?: string; +} + +/** Durable recovery intent written before destructive target cancellation/Stop. */ +export interface WorkHubDelegationReplacementRequestedMessage + extends WorkHubCoordinationMessageEnvelope { + schemaVersion: typeof WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION; + kind: 'delegation_replacement_requested'; + replacesActionId: string; + replacesDelegationId: string; + replacedTargetSessionId: string; + replacedTargetMessageId: string; + targetSessionName: string; +} + +/** Atomic proof that the old link became superseded by the replacement assignment. */ +export interface WorkHubDelegationSupersededMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION; + kind: 'delegation_superseded'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + supersededActionId: string; + supersededDelegationId: string; + replacementDelegationId: string; +} + +/** Durable terminal proof that retirement succeeded but replacement admission did not. */ +export interface WorkHubDelegationReplacementAbortedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION; + kind: 'delegation_replacement_aborted'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + abortedActionId: string; + abortedDelegationId: string; + targetSessionId: string; + reason: 'target_unavailable' | 'target_waiting_for_user'; } -export type WorkHubCoordinationMessage = WorkHubDelegationAssignedMessage; + +export type WorkHubCoordinationMessage = + | WorkHubDelegationAssignedMessage + | WorkHubDelegationReplacementRequestedMessage + | WorkHubDelegationReplacementAbortedMessage + | WorkHubDelegationSupersededMessage; export interface TurnRecord { turnId: string; @@ -1111,7 +1167,67 @@ const WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE = 'targetMessageId', 'targetSessionName', ], - ['create', 'steered'], + ['create', 'steered', 'replacesActionId', 'replacesDelegationId'], + ); +const WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'targetSessionId', + 'disposition', + 'userText', + 'replacesActionId', + 'replacesDelegationId', + 'replacedTargetSessionId', + 'replacedTargetMessageId', + 'targetSessionName', + ], + ['create'], + ); +const WORKHUB_DELEGATION_SUPERSEDED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'supersededActionId', + 'supersededDelegationId', + 'replacementDelegationId', + ], + [], + ); +const WORKHUB_DELEGATION_REPLACEMENT_ABORTED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'abortedActionId', + 'abortedDelegationId', + 'targetSessionId', + 'reason', + ], + [], ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], @@ -1289,9 +1405,47 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { + if (message.kind === 'delegation_replacement_aborted') { + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_REPLACEMENT_ABORTED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION && + typeof message.actionId === 'string' && + typeof message.actionFingerprint === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(message.actionFingerprint) && + typeof message.coordinationTurnId === 'string' && + message.turnId === message.coordinationTurnId && + typeof message.abortedActionId === 'string' && + message.abortedActionId.length > 0 && + typeof message.abortedDelegationId === 'string' && + message.abortedDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + (message.reason === 'target_unavailable' || message.reason === 'target_waiting_for_user') + ); + } + if (message.kind === 'delegation_superseded') { + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_SUPERSEDED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION && + typeof message.actionId === 'string' && + typeof message.actionFingerprint === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(message.actionFingerprint) && + typeof message.coordinationTurnId === 'string' && + message.turnId === message.coordinationTurnId && + typeof message.supersededActionId === 'string' && + message.supersededActionId.length > 0 && + typeof message.supersededDelegationId === 'string' && + message.supersededDelegationId.length > 0 && + typeof message.replacementDelegationId === 'string' && + message.replacementDelegationId.length > 0 + ); + } const common = hasMessageEnvelope(message, true) && - message.schemaVersion === WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION && + (message.schemaVersion === WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION || + message.schemaVersion === WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION) && typeof message.actionId === 'string' && typeof message.actionFingerprint === 'string' && /^sha256:[a-f0-9]{64}$/u.test(message.actionFingerprint) && @@ -1304,6 +1458,22 @@ function isWorkHubCoordinationMessage(message: Record): boolean (message.disposition === 'create_new' && isWorkHubDelegationCreateSpec(message.create))) && (message.disposition === 'delegate_existing' || message.disposition === 'create_new'); if (!common) return false; + if (message.kind === 'delegation_replacement_requested') { + return ( + message.schemaVersion === WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION && + hasExactShape(message, WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE) && + typeof message.replacesActionId === 'string' && + message.replacesActionId.length > 0 && + typeof message.replacesDelegationId === 'string' && + message.replacesDelegationId.length > 0 && + typeof message.replacedTargetSessionId === 'string' && + message.replacedTargetSessionId.length > 0 && + typeof message.replacedTargetMessageId === 'string' && + message.replacedTargetMessageId.length > 0 && + typeof message.targetSessionName === 'string' && + message.targetSessionName.trim().length > 0 + ); + } return ( message.kind === 'delegation_assigned' && hasExactShape(message, WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE) && @@ -1312,7 +1482,15 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.targetMessageId === 'string' && typeof message.targetSessionName === 'string' && message.targetSessionName.trim().length > 0 && - (message.steered === undefined || message.steered === true) + (message.steered === undefined || message.steered === true) && + ((message.schemaVersion === WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION && + message.replacesActionId === undefined && + message.replacesDelegationId === undefined) || + (message.schemaVersion === WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION && + typeof message.replacesActionId === 'string' && + message.replacesActionId.length > 0 && + typeof message.replacesDelegationId === 'string' && + message.replacesDelegationId.length > 0)) ); } diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts new file mode 100644 index 0000000000..613afb6684 --- /dev/null +++ b/packages/core/src/workhub-creation-intent.ts @@ -0,0 +1,1051 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const EXPLICIT_CREATION = + /(?:创建|新建|新开|开一个)(?:一个)?(?:全新的?|新的?)?(?:普通)?\s*(?:Session|会话|工作|任务)|\b(?:creat(?:e|ing)|start(?:ing)?|open(?:ing)?)\s+(?:a\s+)?(?:brand[- ]new|new)\s+(?:session|work|task)\b/iu; +const NEGATOR = + /(?:(?:不是|并非)(?:要|想|让你|叫你|请你|说要|打算|准备)|没(?:有)?(?:要|想|让你|叫你|请你|说要|打算|准备)|未(?:打算|准备|想|要)|不希望(?:你)?|不要|别|无需|不用|不需要|先不|暂不|禁止|请勿|切勿|不可|不能|不准|不想|不打算|无意|莫|勿)|\b(?:under\s+no\s+circumstances|do\s+not|don't|never|without|must\s+not|should\s+not|cannot|can't|may\s+not|will\s+not|won't|would\s+not|wouldn't|no\s+need\s+to|refrain\s+from|refuse\s+to|decline\s+to|avoid|not(?!\s+only\b))\b/iu; +const ANAPHORIC_CANCELLATION = + /(?:不要|别|无需|不用|不需要|先不|暂不|请勿|切勿|不可|不能|不准)(?:再)?(?:这样|这么|照此)(?:做|操作|执行)?|(?:算了|取消)(?:吧|这个|这项操作)?|\b(?:(?:do\s+not|don't|never|must\s+not|should\s+not|cannot|can't)\s+(?:do\s+)?(?:that|it)|(?:cancel|scratch)\s+(?:that|it)|never\s+mind)\b/iu; +const BARE_TRAILING_CANCELLATION = + /(^|\r\n|[\r\n,.!?;,。!?;—–-]|\b(?:but|and(?:\s+then)?)\b|不过|但是|但|然后|随后)([^\S\r\n]*)((?:还是)?(?:别|不要)了|(?:actually\s*,?\s*)?(?:do\s+not|don't))\s*[.!?。!?]?\s*$/iu; +const ABBREVIATION_BEFORE_BOUNDARY = /(?:\b[a-z0-9]\.[a-z0-9]|\b(?:etc|vs|mr|mrs|dr))\.\s*,?$/iu; +const MULTILINE_LITERAL_INTRODUCER = /[::]\s*$/u; +const EXACT_LITERAL_HEADER = + /^(?:(?:this|the|these|following|list)\s+)?(?:token|tokens|value|values|label|labels|literal|literals|input|inputs|code|example|examples|case|cases|item|items|text|string|strings)$|^(?:此|这个|这些|以下)?(?:文案|值|文本|字符串|输入|代码|示例|字面量|令牌)$/iu; +const ACTION_INTRODUCER_PREFIX = + /^(?:please|(?:can|could|would)\s+you|请|帮我|请帮我|麻烦(?:你)?)?$/iu; +const STRUCTURED_LITERAL_LINE = + /^\s*(?:(?:[-*+]|\d+[.)])\s*)?(?:(?:do\s+not|don't)|(?:还是)?(?:别|不要)了)\s*[.!?。!?]?\s*$/iu; +const PRIOR_STRUCTURED_LITERAL_ITEM = /^(?:\t| {4}|\s*(?:[-*+]|\d+[.)])\s+\S)/u; +const EXECUTION_ACTION = + /(?:修复|修改|更新|实现|创建|新增|删除|移除|处理|完成|运行|测试|提交|推送|检查|诊断|复现|优化|补充|整理)|\b(?:fix|modify|implement|update|create|add|remove|delete|handle|finish|run|test|commit|push|check|optimize|try|reproduce|diagnose|work)\b/iu; +const CREATION_ACTION = /(?:创建|新建|新开|开一个)|\b(?:create|start|open)\b/iu; +const ANAPHORIC_OBJECT = /^\s*(?:(?:it|one|that|this)\b|(?:它|这个|这项工作|这项任务))/iu; +const DELIBERATIVE_REQUEST = + /^\s*(?:(?:(?:我们|我)\s*)?(?:是否|要不要|该不该|应不应该|能不能|可不可以|为什么|如何|怎么|想知道(?:是否|为什么|如何|怎么)|应该(?:如何|怎么))|(?:should|whether|why|how|(?:can|could|would)\s+(?:we|i)|what\s+(?:is|are|was|were|should|would|could|do|does|did|can))\b|(?:i|we)\s+(?:(?:want|would\s+like)\s+to\s+(?:know|understand)|wonder)\s+(?:whether|why|how)\b|(?:(?:(?:can|could|would)\s+you\s+)?(?:(?:please|kindly)\s+)?(?:explain|discuss|consider|tell\s+me|help\s+me\s+understand)\s+(?:whether|why|how|when|if|in\s+which\s+cases?)\b)|(?:(?:请|帮我|请帮我|麻烦(?:你)?)\s*)?(?:解释|讨论|考虑|告诉我|帮我理解).{0,18}(?:是否|为什么|如何|怎么|何时|什么时候|在什么情况))/iu; +const ADVISORY_SPEECH_ACT = + /^\s*(?:(?:can|could|would)\s+you(?:\s+(?:please|kindly))?|please|kindly)?\s*(?:recommend|suggest|advise|explain|discuss|consider|tell\s+me|help\s+me\s+understand|show\s+me|teach\s+me|walk\s+me\s+through)\b|^\s*(?:(?:请|帮我|请帮我|麻烦(?:你)?)\s*)?(?:建议|解释|讨论|考虑|告诉我|帮我理解|教我|讲讲)/iu; +const COORDINATED_DIRECT_ACTION_PREFIX = + /(?:(?:[,.;]\s*)(?:(?:and|but)\s*)?|(?:and\s+then|then)\s*|(?:[,。;]\s*)(?:(?:并且|并|但|不过)\s*)?|然后\s*)$/iu; +const ADVISORY_MATRIX_ACTION_BOUNDARY = + /(?:,\s*(?:and|but|then)|[.;]\s*(?:(?:and|but|then)\s*)?|,\s*(?:然后|并且|并|但|不过)|[。;]\s*(?:(?:然后|并且|并|但|不过)\s*)?)\s*$/iu; +const BARE_COORDINATED_ACTION_PREFIX = /(?:\band\s*|并且\s*|并\s*)$/iu; +const ADVISORY_COMPLEMENT_NOUN = + /\b(?:ways?|methods?|techniques?|approaches?|circumstances?|cases?|steps?|options?|strategies?|solutions?|patterns?|process(?:es)?|procedures?|frameworks?|workflows?|proposals?|tools?)\b|(?:方法|方式|技巧|方案|步骤|流程|框架|工作流|提案|工具|选项|策略|模式|情形|情况)/iu; +const ADVISORY_COMPLEMENT_RELATIVE = /\b(?:to|that|which|where)\b[^,.;!?]{0,80}\band\s*$/iu; +const TRAILING_QUESTION = /[??]\s*$/u; +const DELIBERATIVE_LEAD_MARKER = + /\b(?:how|why|whether|when)\b|\bin\s+which\s+cases?\b|\bif\s+(?:(?:i|we|you|they|it)\s+(?:should|could|would|can|need|must|ought)\b|it\s+is\s+necessary\b)|\b(?:what|which)\s+(?:steps?|approach|method|way|cases?)\b|\b(?:show|teach)\s+me\b|\bwalk\s+me\s+through\b|(?:想知道|想了解|是否|应否|要不要|该不该|应不应该|能不能|可不可以|为什么|如何|怎么|何时|什么时候|在什么情况|教我|讲讲)/iu; +const DIRECT_EXECUTION_PREFIX = + /^\s*(?:(?:(?:if|when)\b[^,\r\n]{1,80}?(?:,\s*|\bthen\s+|\s+(?=(?:can|could|would)\s+you\b))|如果[^,\r\n]{1,48}?(?:,\s*|然后\s*|\s+(?=请|帮我|请帮我|麻烦))))?(?:(?:can|could|would)\s+you(?:\s+(?:please|kindly))?|(?:i\s+(?:want|need|would\s+like)|we\s+need)\s+(?:you\s+)?to|let['’]s|let\s+us|please|kindly|请|帮我|请帮我|麻烦(?:你)?|我想(?:让你)?|我要(?:让你)?|我们需要(?:你)?)?\s*$/iu; +const CONDITIONAL_EXECUTION_PREFIX = + /^\s*(?:(?:if|when)\b[^??\r\n]{1,80}|如果[^??\r\n]{1,48})\s*$/iu; +const ELLIPTICAL_CONDITIONAL_PREFIX = + /^\s*(?:if|when)\s+(?:needed|required|necessary|possible|safe|appropriate|convenient|ready|available|feasible|desired|applicable|practical|advisable|permitted|complete|urgent|sensible)\s*$/iu; +const DELIBERATIVE_CONDITIONAL_PREFIX = + /^(?:(?:if|when)\s+(?:(?:the\s+)?[\p{L}][\p{L}'’-]*|[^,,??]{1,64}\b(?:should|could|would|can|need|must|ought|will|may|might|do|does|did|is|are|was|were|has|have|had))|如果(?:(?:我|我们|你|你们|他们|她们|它们)|[^,,??]{1,32}(?:能|会|将|要|可以|应该)))\s*$|\bif\s+(?:i|we|you|they|it)\s+(?:should|could|would|can|need|must|ought)\b|\bwhen\s+(?:should|could|would|can|may|might|must|ought|will|do|does|did|is|are|was|were)\b|\bif\b[^,,??]{0,64}\b(?:think|believe|suppose|wonder|feel|guess|asked?|wanted?|preferred?)\b|\bif\b[^,,??]{0,64}\bplan\s+(?:is|was|were|would\s+be)\s+to\b|\b(?:if|when)\b[^,,]{0,64}\b(?:whether|how|why|unsure|uncertain|unclear|appropriate|advisable|wise|good\s+idea|makes?\s+sense|in\s+doubt|ask)\b|如果[^,,]{0,32}(?:是否|应否|应不应该|该不该|要不要|什么时候|不确定|不清楚|疑问|适合|合适|可行|明智|合理|(?:我|我们)?(?:让|叫|要求)你)/iu; +const POST_ACTION_DELIBERATIVE = + /\b(?:or|but|however|actually|instead)\b[^??\r\n]{0,80}\b(?:should|could|would|maybe|perhaps|not\s+sure|changed?\s+(?:my|our)\s+mind|take\s+(?:it|that)\s+back)\b|(?:^|[.;!?])\s*(?:(?:on\s+second\s+thought)\s*,?\s*)?(?:(?:maybe|perhaps)\b|(?:(?:do\s+you\s+(?:think|agree)|(?:is|are|was|were|do|does|did|can|could|should|would)\s+(?:i|we|it|that|this)|(?:are|were)\s+(?:you|they)\s+sure)\b|(?:i|we)(?:\s+(?:am|are)|['’](?:m|re))?\s+not\s+sure))|(?:还是|或者|但是|但|不过|其实|不然|[。;!?])[^??\r\n]{0,48}(?:应该|要不要|是否|也许|可能|不确定|改主意|等等|先等|搁置|明智|合适|合理|可行|可以吗|好吗|妥当)|(?:合适|合理|可行|明智|妥当|可以吗|好吗)\s*吗???\s*$/iu; +const POST_ACTION_QUESTION_ALTERNATIVE = /\bor\b|还是|或者/iu; +const POST_ACTION_QUESTION_CLAUSE_LEAD = + /^(?:what|how|why|whether|when|who|where)\b|^(?:would|could|should|can|do|does|did|is|are|was|were|will|may|might|must)\s+(?:i|we|you|they|it|that|this|the)\b|^(?:什么|如何|为什么|是否|何时|什么时候|谁|哪里|哪种|可以|应该|要不要|是不是)/iu; +const POST_ACTION_EMBEDDED_QUESTION = + /\b(?:what|how|why|whether|when|who|where)\s+(?:would|could|should|will|can|do|does|did|is|are)\b|\b(?:would|could|should|can|will|may|might|must)\s+(?:that|this|it|the)\b|(?:会怎样|会如何|怎么办|是否合适|是否可行)/iu; +const POST_ACTION_UNCERTAINTY_TAG = + /^(?:maybe|perhaps|okay|ok|right|agreed|not\s+sure|any\s+(?:concerns?|objections?)|sound\s+good)\b|^(?:可以吗|好吗|行吗|对吗|没问题吧|有问题吗|有疑问吗)/iu; +const UNQUOTED_LITERAL_QUESTION_TARGET = + /\b(?:to|as|say(?:ing)?|answer(?:ing)?(?:\s+the\s+question)?)\s+(?:what|how|why|when|where|who)\b/iu; +const POSITIVE_CONTRAST = /而是|\b(?:but|instead)\b/iu; +const HARD_CLAUSE_BOUNDARY = /[.!?;。!?;]/u; +const SOFT_CLAUSE_BOUNDARY = /[,,]/u; +const ACTION_TARGET_SCAFFOLDING = + /^(?:(?:\s+\b(?:and|then|also|please|kindly|really|actually|absolutely|just|simply|can|could|would|you)\b)|(?:并且|并|且|然后|随后|请|麻烦|你|真的|确实|务必|千万))+\s*$/iu; +const CORRECTION_CUE = + /^\s*(?:(?:不是|不要再继续)\s*(?:这个|那个|当前这个|刚才那个)(?:工作|任务|Session|会话)?|(?:这个|那个|当前这个|刚才那个)(?:工作|任务|Session|会话)?\s*(?:不对|搞错了|弄错了)|(?:不对|错了|搞错了|弄错了)|(?:no|nope)\b(?=\s*[,.;:!?—–-])|not\s+(?:this|that|the\s+current)(?:\s+(?:one|session|work|task))?|wrong\s+(?:one|session|work|task))/iu; +const CORRECTION_RETARGET_ACTION = + /(?:应该(?:是|用|改成|改为|切到|转到)|而是|改成|改为|换成|换到|切到|转到|用)|\b(?:use|switch(?:\s+(?:it|this|that))?\s+to|change(?:\s+(?:it|this|that))?\s+to|move(?:\s+(?:it|this|that))?\s+to|send(?:\s+(?:it|this|that))?\s+to)\b/iu; +const CORRECTION_CLAUSE_BOUNDARY = /[\r\n,.;!?,。;!?—–]/u; +const CORRECTION_TRAILING_WITHDRAWAL = + /(?:(?:\b(?:but|however|actually|and(?:\s+then)?)\b|[,.;!?—–])[^\r\n]{0,80}\b(?:(?:do\s+not|don't)\s+(?:(?:want|intend|plan)\s+to\s+)?(?:move|switch|change|send|use|do)|never(?:\s+(?:move|switch|change|send|use|do|again|mind))?|no\s+longer\s+(?:move|switch|change|send|use|do))\b|(?:但|不过|其实|然后|随后|[,。;!?])[^\r\n]{0,48}(?:不想|不要|别|不再|不用)[^\r\n]{0,12}(?:转|换|切|用|做|了))/iu; +const TERMINAL_WITHDRAWAL_CLAUSE = + /(?:^|[\r\n,.;!?—–-]|\b(?:and|then|but|or|however|actually|instead)\b)\s*(?:abort|cancel|stop|halt|cease|quit|pause|terminate|withdraw|revoke|abandon|retract|forget)(?:\s+(?:(?:it|this|that|my|our|your|the)(?:\s+(?:operation|work|request|job|task|session|creation|change|action))?|everything|all|(?:the\s+)?new\s+session))?(?:\s+(?:immediately|now|here|for\s+now))?[.!?]?\s*$|(?:^|[\r\n,.;!?—–-]|\b(?:and|then|but|or|however|actually|instead)\b)\s*(?:(?:on\s+second\s+thought)\s*,?\s*)?(?:(?:maybe|perhaps)\s+)?(?:(?:(?:should|could|would)\s+(?:we|i)|(?:we|i)\s+(?:should|could|would))\s+)?(?:wait|hold\s+off|leave\s+(?:it|this|that)(?:\s+(?:here|alone|for\s+now))?|keep\s+(?:it|this|that)\s+here|scratch\s+(?:it|this|that)|stand\s+down|back\s+out|call\s+it\s+off|do\s+nothing|take\s+(?:it|that)\s+back)[.!?]?\s*$|(?:^|[\r\n,.;!?—–-]|\b(?:and|then|but|or|however|actually|instead)\b)\s*(?:(?:on\s+second\s+thought)\s*,?\s*)?(?:(?:i|we)(?:\s+(?:have|had))?\s+changed?\s+(?:my|our)\s+mind|(?:i|we)(?:\s+(?:am|are)|['’](?:m|re))?\s+not\s+sure|(?:i\s+)?take\s+(?:it|that)\s+back)[.!?]?\s*$/iu; +// Extends the compact terminal-state grammar above with polite speech acts, +// qualified objects, and unambiguous command synonyms. +const TERMINAL_QUALIFIED_WITHDRAWAL_COMMAND = + /(?:^|[\r\n,.;!?,。!?;—–-]|\b(?:and|then|but|or|however|actually|instead)\b|然后|随后|但是|但|不过|其实|还是|或者)\s*(?:(?:(?:please|kindly|let['’]s|let\s+us|(?:can|could|would)\s+you|(?:i|we)\s+(?:(?:would\s+)?prefer|want|need|would\s+like)\s+to)\s+)(?:abort|cancel|stop|halt|cease|quit|pause|terminate|withdraw|revoke|abandon|retract|forget|rescind|drop|scrap|undo|discontinue|ditch|discard)(?:\s+(?:(?:it|this|that)(?:\s+(?:operation|work|request|job|task|session|creation|change|action))?|(?:(?:my|our|your|the)\s+)?(?:(?:current|pending|active|existing|new)\s+)?(?:operation|work|request|job|task|session|creation|change|action)|everything|all))?|(?:abort|cancel|stop|halt|cease|quit|pause|terminate|withdraw|revoke|abandon|retract|forget)\s+(?:(?:my|our|your|the)\s+)?(?:current|pending|active|existing)\s+(?:operation|work|request|job|task|session|creation|change|action)|(?:rescind|drop|scrap|undo|discontinue|ditch|discard)(?:\s+(?:(?:it|this|that)(?:\s+(?:operation|work|request|job|task|session|creation|change|action))?|(?:(?:my|our|your|the)\s+)?(?:(?:current|pending|active|existing|new)\s+)?(?:operation|work|request|job|task|session|creation|change|action)|everything|all))?)(?:\s+(?:immediately|now|here|for\s+now))?[.!?]?\s*$/iu; +const TERMINAL_NEGATED_CONTINUATION = + /(?:^|[\r\n,.;!?,。!?;::—–-]|\b(?:and|then|but|or|however|actually|instead)\b|然后|随后|但是|但|不过|其实|还是|或者)\s*(?:please\s+)?(?:(?:(?:i|we)\s+)?(?:do\s+not|don't|never)\s+(?:(?:wish|want|prefer|intend|plan)\s+to\s+)?(?:proceed|continue|go\s+ahead)(?:\s+(?:with\s+)?(?:(?:it|this|that)|(?:(?:my|our|your|the)\s+)?(?:current\s+)?(?:operation|work|request|job|task|session|creation|change|action)))?|never\s+mind)[.!?]?\s*$/iu; +const TERMINAL_CHINESE_WITHDRAWAL = + /(?:^|[\r\n,。;!?—–-]|然后|随后|但是|但|不过|其实|还是|或者)\s*(?:(?:不要|别|不想|不再|不用)(?:再)?(?:继续|执行|推进|转|换|切|用|做)|(?:作罢|取消|停止|停下|中止|终止|放弃|暂停|算了|撤回)(?:吧|它|这个|这项(?:操作|工作|请求)|全部|现在|执行|创建(?:新(?:的)?(?:会话|工作|任务))?)?|(?:(?:也许|可能)?(?:还是)?(?:应该|可以)?\s*)?(?:等等|先等|搁置|推迟|我(?:改主意了?|不确定|再想想))|当我没说|维持原样|保持原样|还是算了)[。!?]?\s*$/iu; +const CREATION_REQUEST_PREFIX = + /^(?:(?:please|kindly|(?:can|could|would)\s+you|i\s+(?:want|need|would\s+like)\s+to|we\s+need\s+to|let['’]s)|(?:请|帮我|请帮我|麻烦(?:你)?|我想|我要|我们需要))?\s*$/iu; +const NAMED_CREATION_TITLE_INTRODUCER = + /\b(?:new|brand[- ]new)\s+(?:session|work|task)[\s,,::-]+(?:(?:called|named|titled)|with\s+(?:the\s+)?title)\s+|(?:新的?|全新的?)?\s*(?:Session|会话|工作|任务)[\s,,::-]*(?:叫做?|名叫|名为|命名为|标题为|名称为|名字为)\s*/iu; +const LEADING_CORRECTION_SEPARATOR = /^[\s,.;:!?,。;:!?—–-]+/u; + +/** How much authority trusted user text carries for starting work. */ +export type WorkHubExecutionIntent = 'imperative' | 'ambiguous' | 'non_executable'; + +/** Naming syntax is total: absent, parsed, or present but unsafe to use. */ +export type WorkHubCreationNaming = + | { readonly kind: 'none' } + | { readonly kind: 'unusable' } + | { readonly kind: 'named'; readonly title: string }; + +export interface WorkHubRequestIntent { + readonly execution: WorkHubExecutionIntent; + readonly creation: { + readonly explicit: boolean; + readonly naming: WorkHubCreationNaming; + }; + readonly correction: { + readonly cue: boolean; + readonly existingTarget?: string; + }; +} + +/** + * Whether trusted user text affirmatively asks WorkHub to create a new Session. + * + * Routing is advisory, so both renderer policy and the Runtime Action Gate use + * this conservative predicate. Weak punctuation never separates a negator from + * its creation verb; only an explicit contrast can introduce a later positive + * creation clause. + */ +function isExplicitWorkHubCreationRequest(value: string): boolean { + const normalized = value.replace(/[’‘]/gu, "'"); + const creations = allMatches(normalized, EXPLICIT_CREATION); + if ( + creations.length === 0 || + isDeliberative(normalized) || + hasUnquotedTerminalWithdrawal(normalized) || + hasUnsafeUnquotedNamedCreationTail(normalized) || + !creations.some((creation) => hasAffirmativeCreationGrammar(normalized, creation)) + ) { + return false; + } + return !hasNegatedWorkHubCreationRequest(normalized); +} + +/** The explicit title when an affirmative creation request names its new Session. */ +function affirmativeWorkHubNamedCreationTitle(value: string): string | undefined { + if (!isExplicitWorkHubCreationRequest(value)) return undefined; + const normalized = value.replace(/[’‘]/gu, "'"); + const lastIntroducer = affirmativeNamedCreationIntroducer(normalized); + if (lastIntroducer?.index === undefined) return undefined; + return parseNamedCreationTitle(normalized.slice(lastIntroducer.index + lastIntroducer[0].length)); +} + +/** Whether the request contains syntax that explicitly names its new Session. */ +function hasWorkHubNamedCreationClause(value: string): boolean { + return NAMED_CREATION_TITLE_INTRODUCER.test(value.replace(/[’‘]/gu, "'")); +} + +function affirmativeNamedCreationIntroducer(value: string): RegExpMatchArray | undefined { + const affirmativeCreation = allMatches(value, EXPLICIT_CREATION) + .filter( + (creation) => + creation.index !== undefined && + hasAffirmativeCreationGrammar(value, creation) && + !isMatchNegated(value, creation), + ) + .at(-1); + if (affirmativeCreation?.index === undefined) return undefined; + const creationStart = affirmativeCreation.index; + const creationEnd = creationStart + affirmativeCreation[0].length; + return allMatches(value, NAMED_CREATION_TITLE_INTRODUCER).find( + (introducer) => + introducer.index !== undefined && + introducer.index >= creationStart && + introducer.index < creationEnd, + ); +} + +function hasAffirmativeCreationGrammar(value: string, creation: RegExpMatchArray): boolean { + if (creation.index === undefined) return false; + const correctionCue = CORRECTION_CUE.exec(value); + if (correctionCue?.index !== undefined) { + const correctionClause = value + .slice(correctionCue.index + correctionCue[0].length) + .replace(LEADING_CORRECTION_SEPARATOR, ''); + if (isDeliberative(correctionClause)) return false; + const prefix = afterLastDecisionReset(value.slice(0, creation.index)) + .replace(CORRECTION_CUE, '') + .replace(LEADING_CORRECTION_SEPARATOR, '') + .trim(); + return CREATION_REQUEST_PREFIX.test(prefix); + } + return CREATION_REQUEST_PREFIX.test( + afterLastDecisionReset(value.slice(0, creation.index)).trim(), + ); +} + +/** Whether trusted user text negates creation rather than authorizing it. */ +function hasNegatedWorkHubCreationRequest(value: string): boolean { + const normalized = value.replace(/[’‘]/gu, "'"); + const lastCreation = lastMatch(normalized, EXPLICIT_CREATION); + const trailing = + lastCreation?.index === undefined + ? '' + : normalized.slice(lastCreation.index + lastCreation[0].length); + if ( + lastCreation?.index !== undefined && + (ANAPHORIC_CANCELLATION.test(trailing) || + hasBareTrailingCancellation(normalized) || + hasNegatedAnaphoricCreation(normalized, lastCreation)) + ) { + return true; + } + let lastDecision: boolean | undefined; + for (const segment of normalized.split(POSITIVE_CONTRAST)) { + const creation = lastMatch(segment, EXPLICIT_CREATION); + if (!creation || creation.index === undefined) continue; + lastDecision = NEGATOR.test(segment.slice(0, creation.index)); + } + return lastDecision ?? false; +} + +/** Whether already-normalized, literal-masked text is an executable instruction. */ +function isImperativeWorkHubNewTopicRequest(normalized: string): boolean { + const actions = allMatches(normalized, EXECUTION_ACTION); + if (isDeliberative(normalized) || hasUnquotedTerminalWithdrawal(normalized)) { + return false; + } + if ( + hasWorkHubNamedCreationClause(normalized) && + !affirmativeWorkHubNamedCreationTitle(normalized) + ) { + return false; + } + const explicitCreation = isExplicitWorkHubCreationRequest(normalized); + if ( + EXPLICIT_CREATION.test(normalized) && + !isExplicitWorkHubCreationRequest(normalized) && + actions.length > 0 && + actions.every((action) => CREATION_ACTION.test(action[0])) + ) { + return false; + } + if (hasNegatedWorkHubCreationRequest(normalized)) { + const lastCreation = lastMatch(normalized, EXPLICIT_CREATION); + if (!lastCreation || lastCreation.index === undefined) return false; + if (isMatchNegated(normalized, lastCreation)) return false; + const trailing = normalized.slice(lastCreation.index + lastCreation[0].length); + return [...executionActionDecisions(trailing).values()].some(Boolean); + } + return ( + (explicitCreation || hasAffirmativeExecutableGrammar(normalized, actions)) && + [...executionActionDecisions(normalized).values()].some(Boolean) + ); +} + +/** + * Read trusted user text once at the WorkHub intent boundary. + * + * Heuristics may demote toward doing nothing; they must never promote an + * advisory or malformed phrase into authority to create work. + */ +export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { + const literalMask = maskLiteralSpans(value); + const source = value.replace(/[’‘]/gu, "'"); + const masked = literalMask.value.replace(/[’‘]/gu, "'"); + const explicit = isExplicitWorkHubCreationRequest(masked); + const hasNamingClause = hasWorkHubNamedCreationClause(source); + const namedTitle = explicit ? affirmativeWorkHubNamedCreationTitle(source) : undefined; + const naming: WorkHubCreationNaming = !hasNamingClause + ? { kind: 'none' } + : namedTitle + ? { kind: 'named', title: namedTitle } + : { kind: 'unusable' }; + const correctionCue = hasWorkHubCorrectionCue(source); + const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source); + const actions = allMatches(masked, EXECUTION_ACTION); + const execution: WorkHubExecutionIntent = + literalMask.malformed || naming.kind === 'unusable' || hasDominatingDeliberation(masked) + ? 'non_executable' + : hasAmbiguousAdvisoryCommand(masked, actions) + ? 'ambiguous' + : isImperativeWorkHubNewTopicRequest(masked) + ? 'imperative' + : 'non_executable'; + return { + execution, + creation: { explicit, naming }, + correction: { + cue: correctionCue, + ...(existingTarget ? { existingTarget } : {}), + }, + }; +} + +/** Whether a parsed correction names exactly this Session. */ +export function workHubCorrectionTargetsSession( + intent: WorkHubRequestIntent, + sessionName: string, +): boolean { + return Boolean( + intent.correction.existingTarget && + correctionTargetMatchesSession(intent.correction.existingTarget, sessionName), + ); +} + +/** Whether parsed trusted text authorizes the title proposed for new work. */ +export function workHubCreationAuthorizesTitle( + intent: WorkHubRequestIntent, + title: string, +): boolean { + if (intent.execution !== 'imperative') return false; + if (intent.creation.naming.kind === 'unusable') return false; + if (intent.creation.naming.kind === 'none') return true; + return ( + normalizeCorrectionIdentity(intent.creation.naming.title) === normalizeCorrectionIdentity(title) + ); +} + +/** Whether trusted user text affirmatively redirects an existing WorkHub delegation. */ +function isAffirmativeWorkHubExistingTargetCorrectionRequest( + value: string, + expectedTargetName?: string, +): boolean { + const target = affirmativeWorkHubExistingCorrectionTarget(value); + return Boolean( + target && (!expectedTargetName || correctionTargetMatchesSession(target, expectedTargetName)), + ); +} + +/** The bounded target phrase from an affirmative existing-Session correction. */ +function affirmativeWorkHubExistingCorrectionTarget(value: string): string | undefined { + const normalized = value.replace(/[’‘]/gu, "'"); + if (!hasWorkHubCorrectionCue(normalized) || isDeliberative(normalized)) return undefined; + const actions = allMatches(normalized, CORRECTION_RETARGET_ACTION); + let lastDecision = false; + let lastAction: RegExpMatchArray | undefined; + let lastTarget = ''; + for (const action of actions) { + if (action.index === undefined) continue; + const prefix = normalized.slice(0, action.index); + const clausePrefix = prefix.slice(lastBoundaryEnd(prefix, CORRECTION_CLAUSE_BOUNDARY)); + const target = normalized.slice(action.index + action[0].length).trim(); + if (!target) continue; + lastDecision = !NEGATOR.test(clausePrefix); + lastAction = action; + lastTarget = target; + } + if (!lastDecision || lastAction?.index === undefined) return undefined; + const trailing = normalized.slice(lastAction.index + lastAction[0].length); + if ( + ANAPHORIC_CANCELLATION.test(trailing) || + CORRECTION_TRAILING_WITHDRAWAL.test(trailing) || + hasBareTrailingCancellation(normalized) + ) { + return undefined; + } + return lastTarget; +} + +function correctionTargetMatchesSession(target: string, sessionName: string): boolean { + const normalizedTarget = normalizeCorrectionIdentity(target); + const normalizedName = normalizeCorrectionIdentity(sessionName); + if (!normalizedName) return false; + const quotedNames = [ + `"${normalizedName}"`, + `“${normalizedName}”`, + `'${normalizedName}'`, + `‘${normalizedName}’`, + ]; + const matchedName = [normalizedName, ...quotedNames] + .sort((left, right) => right.length - left.length) + .find( + (candidate) => + normalizedTarget.startsWith(candidate) && + !/[\p{L}\p{N}]/u.test(normalizedTarget[candidate.length] ?? ''), + ); + if (!matchedName) return false; + if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) { + return false; + } + if (hasUnquotedTerminalWithdrawal(target)) { + return false; + } + const remainder = normalizedTarget.slice(matchedName.length).trim(); + if (!remainder || /^(?:instead\s*)?[.!?。!?]?$/iu.test(remainder)) return true; + const supplemental = remainder.match(/^[,;,;]\s*(.+)$/u)?.[1]?.trim(); + const supplementalBody = supplemental?.replace(/[.!?。!?]+\s*$/u, '').trim(); + return Boolean( + supplementalBody && + !/[\r\n,.!?;,。!?;—–]|\b(?:and|then|but|however|actually)\b|(?:然后|随后|但|不过|其实)/iu.test( + supplementalBody, + ) && + readWorkHubRequestIntent(supplementalBody).execution === 'imperative', + ); +} + +function normalizeCorrectionIdentity(value: string): string { + return value.normalize('NFKC').toLocaleLowerCase().replace(/\s+/gu, ' ').trim(); +} + +function hasUnquotedTerminalWithdrawal(value: string): boolean { + const masked = maskLiteralSpans(value).value; + const withdrawal = + TERMINAL_WITHDRAWAL_CLAUSE.exec(masked) ?? + TERMINAL_QUALIFIED_WITHDRAWAL_COMMAND.exec(masked) ?? + TERMINAL_NEGATED_CONTINUATION.exec(masked) ?? + TERMINAL_CHINESE_WITHDRAWAL.exec(masked); + return withdrawal?.index !== undefined; +} + +function hasUnsafeUnquotedNamedCreationTail(value: string): boolean { + const introducer = affirmativeNamedCreationIntroducer(value); + if (introducer?.index === undefined) return false; + const trailing = value.slice(introducer.index + introducer[0].length).trim(); + if (!trailing || new Set(['"', "'", '“', '‘']).has(trailing[0] ?? '')) return false; + const boundary = unquotedCreationTitleBoundary(trailing); + if (boundary >= trailing.length) return false; + const separator = trailing[boundary] ?? ''; + const suffix = trailing.slice(boundary + 1).trim(); + if (!suffix) return false; + const actionableSuffix = stripExecutionScaffolding(suffix); + if (isDeliberative(actionableSuffix) || hasUnquotedTerminalWithdrawal(actionableSuffix)) { + return true; + } + if (/[.!?;。!?;]/u.test(separator)) { + return !isScaffoldedDirectExecutableClause(actionableSuffix); + } + return !/^(?:(?:and|then|but)\b|然后|随后|但是|但|只)/iu.test(suffix); +} + +function isDirectExecutableClause(value: string): boolean { + const action = EXECUTION_ACTION.exec(value); + if (action?.index === undefined || !isDirectExecutionPrefix(value.slice(0, action.index))) { + return false; + } + return [...executionActionDecisions(value).values()].some(Boolean); +} + +function isScaffoldedDirectExecutableClause(value: string): boolean { + return isDirectExecutableClause(stripExecutionScaffolding(value)); +} + +function stripExecutionScaffolding(value: string): string { + let current = value.trim(); + while (current) { + const next = current + .replace( + /^(?:(?:and\s+then|then|next|first|also|now|finally|afterwards?|thereafter|subsequently|eventually|immediately|urgently|promptly|directly|and|but)\b[\s,]*|(?:please|kindly|proceed\s+to|go\s+ahead\s+(?:and|to)|continue\s+to|at\s+that\s+point)\b[\s,]*|(?:然后|随后|接着|接下来|下一步|最后|并且|并|但是|但)[\s,,]*)/iu, + '', + ) + .trimStart(); + if (next === current) break; + current = next; + } + return current; +} + +function parseNamedCreationTitle(value: string): string | undefined { + const trailing = value.trim(); + const quotePair = new Map([ + ['"', '"'], + ["'", "'"], + ['“', '”'], + ['‘', '’'], + ]).get(trailing[0] ?? ''); + if (quotePair) { + const closing = trailing.indexOf(quotePair, 1); + return closing > 1 ? trailing.slice(1, closing).trim() || undefined : undefined; + } + const boundary = unquotedCreationTitleBoundary(trailing); + return ( + trailing + .slice(0, boundary) + .trim() + .replace(/[.!?。!?]+\s*$/u, '') + .replace(/\s+instead\s*$/iu, '') + .trim() || undefined + ); +} + +function unquotedCreationTitleBoundary(value: string): number { + let boundary = /[\r\n,!?;,。!?;—–]/u.exec(value)?.index ?? value.length; + for ( + let index = value.indexOf('.'); + index >= 0 && index < boundary; + index = value.indexOf('.', index + 1) + ) { + const prefix = value.slice(0, index); + const after = value.slice(index + 1); + if (isTitleAbbreviation(prefix, after)) continue; + if (!after || /^\s/u.test(after) || /^\p{Lu}/u.test(after)) boundary = index; + } + return boundary; +} + +function hasUnsafeUnquotedHardClauseBoundary(value: string): boolean { + const trimmed = value.trim(); + const hardBoundary = /[!?;。!?;]/u.exec(trimmed); + if (hardBoundary?.index !== undefined && trimmed.slice(hardBoundary.index + 1).trim()) + return true; + for (let index = trimmed.indexOf('.'); index >= 0; index = trimmed.indexOf('.', index + 1)) { + const after = trimmed.slice(index + 1); + if (!after.trim() || isTitleAbbreviation(trimmed.slice(0, index), after)) continue; + if (/^\s|^\p{Lu}/u.test(after)) return true; + } + return false; +} + +function isTitleAbbreviation(prefix: string, after: string): boolean { + const token = prefix.match(/[A-Za-z.]+$/u)?.[0] ?? ''; + if (isScaffoldedDirectExecutableClause(after.trimStart())) return false; + if (/\d$/u.test(prefix) && /^\d/u.test(after)) return true; + if (/^[A-Z]$/u.test(token) && /^[A-Z]\./u.test(after)) return true; + if (/^[A-Z][a-z]{0,2}$/u.test(token) && /^[A-Z]\./u.test(after)) return true; + if (/^(?:[A-Za-z]\.)+[A-Za-z]$/u.test(token)) return true; + if (/^[A-Z][a-z]{0,2}(?:\.[A-Z])+$/u.test(token)) return true; + return /^(?:mr|mrs|ms|dr|prof|sr|jr|st|vs|etc|inc|no|ltd|co|corp)$/iu.test(token); +} + +/** Whether trusted user text affirmatively corrects to an existing or new target. */ +function isAffirmativeWorkHubCorrectionRequest(value: string): boolean { + const normalized = value.replace(/[’‘]/gu, "'"); + if (!hasWorkHubCorrectionCue(normalized)) return false; + const affirmativeCreation = + isExplicitWorkHubCreationRequest(normalized) && + (!hasWorkHubNamedCreationClause(normalized) || + Boolean(affirmativeWorkHubNamedCreationTitle(normalized))); + return affirmativeCreation || isAffirmativeWorkHubExistingTargetCorrectionRequest(normalized); +} + +/** Whether trusted user text contains an explicit WorkHub route-correction cue. */ +function hasWorkHubCorrectionCue(value: string): boolean { + return CORRECTION_CUE.test(value.replace(/[’‘]/gu, "'")); +} + +function hasAffirmativeExecutableGrammar( + value: string, + actions: readonly RegExpMatchArray[], +): boolean { + const decisions = executionActionDecisions(value); + return ( + hasLaterCoordinatedDirectExecutableAction(value, actions, decisions) || + actions.some((action, index) => { + if (action.index === undefined || decisions.get(String(index)) !== true) return false; + const clausePrefix = afterLastDecisionReset(value.slice(0, action.index)); + const directPrefix = clausePrefix + .replace(CORRECTION_CUE, '') + .replace(LEADING_CORRECTION_SEPARATOR, ''); + return ( + isDirectExecutionPrefix(directPrefix) || + (ADVISORY_SPEECH_ACT.test(value) && hasCoordinatedDirectAction(clausePrefix)) || + hasImperativeCoordinatedLead(directPrefix, value.slice(action.index + action[0].length)) + ); + }) + ); +} + +function hasLaterCoordinatedDirectExecutableAction( + value: string, + actions: readonly RegExpMatchArray[], + decisions = executionActionDecisions(value), +): boolean { + return actions.slice(1).some((action, offset) => { + if (action.index === undefined || decisions.get(String(offset + 1)) !== true) return false; + const previous = actions[offset]; + if (previous?.index === undefined) return false; + const between = value.slice(previous.index + previous[0].length, action.index); + return COORDINATED_DIRECT_ACTION_PREFIX.test(between); + }); +} + +function hasDominatingDeliberation(value: string): boolean { + if (hasUnquotedTerminalWithdrawal(value)) return true; + const firstAction = EXECUTION_ACTION.exec(value); + if (firstAction?.index === undefined) return false; + const actionTail = value.slice(firstAction.index + firstAction[0].length); + return POST_ACTION_DELIBERATIVE.test(actionTail) || hasPostActionDeliberativeQuestion(actionTail); +} + +/** + * Advisory `how to` complements have an attachment ambiguity at a later + * coordinator. This check can only demote to clarification; it never grants + * execution authority. + */ +function hasAmbiguousAdvisoryCommand(value: string, actions: readonly RegExpMatchArray[]): boolean { + if (!ADVISORY_SPEECH_ACT.test(value) || actions.length < 2) return false; + const complementAction = actions[0]; + if (complementAction?.index === undefined) return false; + const complementPrefix = value.slice(0, complementAction.index); + if (!/(?:\bhow\s+to\s*|(?:如何|怎么)\s*)$/iu.test(complementPrefix)) return false; + const decisions = executionActionDecisions(value); + let sawBareComma = false; + for (let index = 1; index < actions.length; index += 1) { + const previous = actions[index - 1]; + const directAction = actions[index]; + if (previous?.index === undefined || directAction?.index === undefined) continue; + if (decisions.get(String(index)) !== true) continue; + const between = value.slice(previous.index + previous[0].length, directAction.index); + const boundary = ADVISORY_MATRIX_ACTION_BOUNDARY.exec(between); + if (!boundary) { + sawBareComma ||= /[,,]/u.test(between); + continue; + } + const hardBoundary = /[.;。;]/u.test(boundary[0]); + const priorBareComma = sawBareComma || /[,,]/u.test(between.slice(0, boundary.index)); + if (priorBareComma && !hardBoundary) { + sawBareComma = true; + continue; + } + const directTail = value.slice(directAction.index + directAction[0].length); + if (isLikelyDeclarativeActionTail(directTail)) continue; + return true; + } + return false; +} + +function hasImperativeCoordinatedLead(value: string, actionTail: string): boolean { + const normalized = value.trim(); + if (!isBoundedPreparatoryActionTail(actionTail)) return false; + return /^(?:(?:(?:please|kindly|first|next)\s+)|(?:先|接下来)\s*)*(?:(?:investigate|analy[sz]e|debug|review|inspect|audit|research|triage|assess|examine)(?:\s+[^,.;!?,。;!?]{0,80})?(?:(?:,\s*)?(?:and\s+then|then|and))|(?:调查|分析|排查|审查|评估|研究)(?:[^,.;!?,。;!?]{0,48})?(?:(?:,\s*)?(?:并且|并|然后)))\s*$/iu.test( + normalized, + ); +} + +function isLikelyDeclarativeActionTail(value: string): boolean { + const unquoted = maskLiteralSpans(value).value; + const words = unquoted + .replace(/[.!?。!?]+\s*$/u, '') + .trim() + .split(/\s+/u) + .filter(Boolean); + if ( + /(?:仍然|都很|都已|很重要|附上|可用)/u.test(unquoted) || + (/(?:已经|正在)/u.test(unquoted) && !/^\s*(?:已经|正在).{1,24}的/u.test(unquoted)) + ) { + return true; + } + if (words.length < 2) return false; + const startsWithDeterminer = /^(?:the|a|an|both|this|that|these|those|my|our|your)\b/iu.test( + words[0] ?? '', + ); + if ( + words + .slice(1) + .some((word) => + /^(?:is|are|was|were|has|have|will|would|can|could|should|must|remain|remains|seem|seems|look|looks)$/iu.test( + word, + ), + ) + ) { + return true; + } + return ( + /^(?:matter|matters|changed|changes|improved|improves|increased|increases|failed|fails|succeeded|succeeds|exists|exist)$/iu.test( + words.at(-1) ?? '', + ) || + (!startsWithDeterminer && words.slice(1).some((word) => /ed$/iu.test(word))) + ); +} + +function isBoundedPreparatoryActionTail(value: string): boolean { + const normalized = value + .trim() + .replace(/[.!?。!?]+\s*$/u, '') + .trim(); + if (!normalized) return false; + if (/^[\p{Script=Han}][\p{Script=Han}\p{L}\p{N}_-]{0,48}$/u.test(normalized)) { + return !isLikelyDeclarativeActionTail(normalized); + } + if (isLikelyDeclarativeActionTail(normalized)) return false; + return /^(?:(?:the|a|an|both|this|that|these|those|my|our|your)\s+)?[\p{L}\p{N}_.:/-]+(?:\s+[\p{L}\p{N}_.:/-]+){0,5}(?:\s+(?:for|in|on|with|without|before|after|under)\s+[\p{L}\p{N}_.:/-]+(?:\s+[\p{L}\p{N}_.:/-]+){0,3})?$/iu.test( + normalized, + ); +} + +function maskLiteralSpans(value: string): { readonly value: string; readonly malformed: boolean } { + const scope = buildLiteralScope(value); + return { + value: value + .split('') + .map((character, index) => (scope.contains(index) ? ' ' : character)) + .join(''), + malformed: scope.malformed, + }; +} + +function buildLiteralScope(value: string): { + readonly contains: (index: number) => boolean; + readonly malformed: boolean; +} { + const covered = new Uint8Array(value.length); + const bracketClosers = new Map([ + ['(', ')'], + ['(', ')'], + ['[', ']'], + ['【', '】'], + ]); + const quoteClosers = new Map([ + ['"', '"'], + ['“', '”'], + ['‘', '’'], + ['`', '`'], + ]); + const bracketClosingCharacters = new Set(bracketClosers.values()); + const brackets: string[] = []; + let quote: string | undefined; + let malformed = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index] ?? ''; + if (quote) { + covered[index] = 1; + if (character === quote) quote = undefined; + continue; + } + if (brackets.length > 0) { + covered[index] = 1; + const nested = bracketClosers.get(character); + if (nested) brackets.push(nested); + else if (character === brackets.at(-1)) brackets.pop(); + else if (bracketClosingCharacters.has(character)) malformed = true; + continue; + } + const quoteCloser = quoteClosers.get(character); + if (quoteCloser) { + covered[index] = 1; + quote = quoteCloser; + continue; + } + const bracketCloser = bracketClosers.get(character); + if (bracketCloser) { + covered[index] = 1; + brackets.push(bracketCloser); + continue; + } + if (bracketClosingCharacters.has(character) || character === '”') { + malformed = true; + } + } + malformed ||= Boolean(quote || brackets.length > 0); + return { + contains: (index) => covered[index] === 1, + malformed, + }; +} + +function isDeliberative(value: string): boolean { + const firstAction = EXECUTION_ACTION.exec(value); + const actionPrefix = firstAction?.index === undefined ? '' : value.slice(0, firstAction.index); + const directActionPrefix = actionPrefix + .replace(CORRECTION_CUE, '') + .replace(LEADING_CORRECTION_SEPARATOR, ''); + const actionTail = + firstAction?.index === undefined ? '' : value.slice(firstAction.index + firstAction[0].length); + const hasDeliberativeLead = + firstAction?.index !== undefined && + firstAction.index > 0 && + !isDirectExecutionPrefix(directActionPrefix) && + !hasCoordinatedDirectAction(actionPrefix) && + (DELIBERATIVE_LEAD_MARKER.test(actionPrefix) || + ADVISORY_COMPLEMENT_NOUN.test(actionPrefix) || + (/(?:告诉我|给我)/u.test(actionPrefix) && + /(?:的?(?:步骤|方法|方式)|怎么做)/u.test(actionTail))); + const hasAdvisoryComplement = + firstAction?.index !== undefined && + ADVISORY_COMPLEMENT_NOUN.test(actionPrefix) && + ADVISORY_COMPLEMENT_RELATIVE.test(actionPrefix); + return ( + DELIBERATIVE_REQUEST.test(value) || + hasAdvisoryComplement || + (ADVISORY_SPEECH_ACT.test(value) && !hasCoordinatedDirectAction(actionPrefix)) || + hasDeliberativeLead || + (firstAction?.index !== undefined && POST_ACTION_DELIBERATIVE.test(actionTail)) || + (firstAction?.index !== undefined && hasPostActionDeliberativeQuestion(actionTail)) || + (TRAILING_QUESTION.test(value) && + (firstAction?.index === undefined || + !isDirectExecutionPrefix(directActionPrefix) || + POST_ACTION_QUESTION_ALTERNATIVE.test(actionTail))) + ); +} + +function hasCoordinatedDirectAction(value: string): boolean { + if (COORDINATED_DIRECT_ACTION_PREFIX.test(value)) return true; + return BARE_COORDINATED_ACTION_PREFIX.test(value); +} + +function hasPostActionDeliberativeQuestion(value: string): boolean { + if (!TRAILING_QUESTION.test(value)) return false; + if (POST_ACTION_EMBEDDED_QUESTION.test(value) && !UNQUOTED_LITERAL_QUESTION_TARGET.test(value)) { + return true; + } + const body = value.replace(/[??]\s*$/u, ''); + const boundary = allMatches(body, /[,.!?;:—–,。!?;:]/u).at(-1); + if (boundary?.index === undefined) return false; + const question = body.slice(boundary.index + boundary[0].length).trim(); + const requiresQuestionLead = /[,,]/u.test(boundary[0]); + return Boolean( + question && + (!requiresQuestionLead || + POST_ACTION_QUESTION_CLAUSE_LEAD.test(question) || + POST_ACTION_UNCERTAINTY_TAG.test(question)) && + !isScaffoldedDirectExecutableClause(question), + ); +} + +function isDirectExecutionPrefix(value: string): boolean { + return ( + ELLIPTICAL_CONDITIONAL_PREFIX.test(value) || + DIRECT_EXECUTION_PREFIX.test(value) || + (CONDITIONAL_EXECUTION_PREFIX.test(value) && !DELIBERATIVE_CONDITIONAL_PREFIX.test(value)) + ); +} + +function lastMatch(value: string, pattern: RegExp): RegExpMatchArray | undefined { + const matches = allMatches(value, pattern); + return matches.at(-1); +} + +function allMatches(value: string, pattern: RegExp): RegExpMatchArray[] { + return [...value.matchAll(new RegExp(pattern.source, `${pattern.flags}g`))]; +} + +function lastBoundaryEnd(value: string, pattern: RegExp): number { + return allMatches(value, pattern).reduce( + (latest, match) => + match.index === undefined ? latest : Math.max(latest, match.index + match[0].length), + 0, + ); +} + +function executionActionDecisions(value: string): ReadonlyMap { + const decisions = new Map(); + const actions = allMatches(value, EXECUTION_ACTION); + const priorActions: Array<{ id: string; key: string; target: string }> = []; + let previous: RegExpMatchArray | undefined; + let previousDecision: boolean | undefined; + for (const [index, action] of actions.entries()) { + if (action.index === undefined) continue; + const previousEnd = previous?.index === undefined ? 0 : previous.index + previous[0].length; + const prefix = value.slice(previousEnd, action.index); + if (ANAPHORIC_CANCELLATION.test(prefix)) { + withdrawAll(decisions); + previousDecision = false; + } + const reset = hasDecisionReset(prefix); + const decision = NEGATOR.test(afterLastDecisionReset(prefix)) + ? false + : previousDecision === false && !reset + ? false + : true; + const id = String(index); + const key = action[0].toLocaleLowerCase(); + const target = actionObject(value, action, actions[index + 1]); + if (decision === false) { + if (hasAnaphoricObjectAfter(value, action, actions[index + 1])) { + withdrawAll(decisions); + } else if (target) { + for (const prior of priorActions) { + if (prior.key === key && sameActionTarget(prior.target, target)) { + decisions.set(prior.id, false); + } + } + } + } + decisions.set(id, decision); + priorActions.push({ id, key, target }); + previous = action; + previousDecision = decision; + } + if (previous?.index !== undefined) { + const trailing = value.slice(previous.index + previous[0].length); + if (ANAPHORIC_CANCELLATION.test(trailing) || hasBareTrailingCancellation(value)) { + withdrawAll(decisions); + } + } + return decisions; +} + +function hasBareTrailingCancellation(value: string): boolean { + const match = BARE_TRAILING_CANCELLATION.exec(value); + if (!match || match.index === undefined) return false; + const boundary = match[1] ?? ''; + const horizontalWhitespace = match[2] ?? ''; + const prefix = value.slice(0, match.index); + if (isMultilineLiteral(prefix, boundary, horizontalWhitespace)) return false; + const prefixWithBoundary = prefix + boundary; + if (!ABBREVIATION_BEFORE_BOUNDARY.test(prefixWithBoundary)) return true; + return boundary === '.' && /^[A-Z]/u.test(match[3] ?? ''); +} + +function isMultilineLiteral( + prefix: string, + boundary: string, + horizontalWhitespace: string, +): boolean { + const currentLine = prefix.slice( + Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1, + ); + if (/[\r\n]/u.test(boundary) && /[\r\n]$/u.test(prefix)) return false; + const lines = prefix.split(/\r\n|[\r\n]/u); + if (!/[\r\n]/u.test(boundary) && !lines.at(-1)) lines.pop(); + if (boundary === '-' && !/\s$/u.test(prefix)) return true; + const structuralLiteral = + (boundary === '-' && !currentLine.trim()) || + (boundary === '.' && /^\s*\d+$/u.test(currentLine)) || + (/[\r\n]/u.test(boundary) && + (/\t/u.test(horizontalWhitespace) || horizontalWhitespace.length >= 4)); + if (structuralLiteral) { + if (boundary === '.' && lines.at(-1)?.trim() === currentLine.trim()) lines.pop(); + return hasValidatedStructuralLiteralContext(lines); + } + if (!/[\r\n]/u.test(boundary)) return false; + return hasValidatedColonLiteralContext(lines); +} + +function hasValidatedStructuralLiteralContext(lines: readonly string[]): boolean { + for (let index = lines.length - 1; index >= 0; index -= 1) { + const rawLine = lines[index] ?? ''; + if (!rawLine.trim()) return false; + if (PRIOR_STRUCTURED_LITERAL_ITEM.test(rawLine)) continue; + const line = normalizeMarkdownLine(rawLine); + if (!line) return false; + if (STRUCTURED_LITERAL_LINE.test(line)) continue; + if (MULTILINE_LITERAL_INTRODUCER.test(line)) return isExecutableLiteralIntroducer(line); + return isExecutableLiteralContext(line); + } + return false; +} + +function hasValidatedColonLiteralContext(lines: readonly string[]): boolean { + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = normalizeMarkdownLine(lines[index] ?? ''); + if (!line) return false; + if (MULTILINE_LITERAL_INTRODUCER.test(line)) return isExecutableLiteralIntroducer(line); + } + return false; +} + +function normalizeMarkdownLine(line: string): string { + return line + .trim() + .replace(/^#{1,6}\s+/u, '') + .replace(/^(\*{1,3}|_{1,3})(.*)\1$/u, '$2') + .trim(); +} + +function isExecutableLiteralIntroducer(line: string): boolean { + const content = line.replace(MULTILINE_LITERAL_INTRODUCER, '').trim(); + return isExecutableLiteralContext(content); +} + +function isExecutableLiteralContext(content: string): boolean { + const action = EXECUTION_ACTION.exec(content); + const actionPrefix = action?.index === undefined ? '' : content.slice(0, action.index).trim(); + const target = + action?.index === undefined ? '' : content.slice(action.index + action[0].length).trim(); + if (ACTION_INTRODUCER_PREFIX.test(actionPrefix) && target && !/^(?:to|为)$/iu.test(target)) { + return true; + } + return EXACT_LITERAL_HEADER.test(content); +} + +function hasNegatedAnaphoricCreation(value: string, explicitCreation: RegExpMatchArray): boolean { + if (explicitCreation.index === undefined) return false; + const offset = explicitCreation.index + explicitCreation[0].length; + const trailing = value.slice(offset); + let previousEnd = 0; + for (const action of allMatches(trailing, CREATION_ACTION)) { + if (action.index === undefined) continue; + const prefix = trailing.slice(previousEnd, action.index); + const after = trailing.slice(action.index + action[0].length); + if (NEGATOR.test(prefix) && ANAPHORIC_OBJECT.test(after)) return true; + previousEnd = action.index + action[0].length; + } + return false; +} + +function hasAnaphoricObjectAfter( + value: string, + action: RegExpMatchArray, + next: RegExpMatchArray | undefined, +): boolean { + if (action.index === undefined) return false; + const end = next?.index ?? value.length; + return ANAPHORIC_OBJECT.test(value.slice(action.index + action[0].length, end)); +} + +function actionObject( + value: string, + action: RegExpMatchArray, + next: RegExpMatchArray | undefined, +): string { + if (action.index === undefined) return ''; + const end = next?.index ?? value.length; + const trailing = value.slice(action.index + action[0].length, end); + const cutters = [ + ...allMatches(trailing, HARD_CLAUSE_BOUNDARY), + ...allMatches(trailing, SOFT_CLAUSE_BOUNDARY), + ...allMatches(trailing, POSITIVE_CONTRAST), + ...allMatches(trailing, NEGATOR), + ]; + const cut = cutters.reduce( + (earliest, match) => (match.index === undefined ? earliest : Math.min(earliest, match.index)), + trailing.length, + ); + return trailing + .slice(0, cut) + .trim() + .replace(/^(?:a|an|the)\s+/iu, '') + .replace( + /(?:(?:\s+\b(?:and|then|also|please|kindly)\b)|(?:并且|并|且|然后|随后|请|麻烦))+\s*$/iu, + '', + ) + .toLocaleLowerCase(); +} + +function isMatchNegated(value: string, match: RegExpMatchArray): boolean { + if (match.index === undefined) return false; + return NEGATOR.test(afterLastDecisionReset(value.slice(0, match.index))); +} + +function sameActionTarget(left: string, right: string): boolean { + if (!left || !right) return false; + if (left === right) return true; + if (!left.startsWith(right)) return false; + const remainder = left.slice(right.length); + return ACTION_TARGET_SCAFFOLDING.test(remainder); +} + +function withdrawAll(decisions: Map): void { + for (const key of decisions.keys()) decisions.set(key, false); +} + +function hasDecisionReset(value: string): boolean { + return HARD_CLAUSE_BOUNDARY.test(value) || POSITIVE_CONTRAST.test(value); +} + +function afterLastDecisionReset(value: string): string { + const resets = [ + ...allMatches(value, HARD_CLAUSE_BOUNDARY), + ...allMatches(value, POSITIVE_CONTRAST), + ]; + const lastReset = resets.reduce( + (latest, match) => + match.index === undefined ? latest : Math.max(latest, match.index + match[0].length), + 0, + ); + return value.slice(lastReset); +} diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 775e918cc4..b43bafeccb 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -30,7 +30,11 @@ import type { AgentGraphIntentClaimRequest, } from '@maka/core/agent-graph-control'; import type { ShellRunRecord } from '@maka/core/shell-run'; -import { FAKE_ASK_USER_QUESTION_PROMPT, FakeBackend } from '@maka/runtime/test-only/fake-backend'; +import { + FAKE_ASK_USER_QUESTION_PROMPT, + FAKE_HOLD_OPEN_PROMPT, + FakeBackend, +} from '@maka/runtime/test-only/fake-backend'; import { LOCAL_READ_AGENT_DEFINITION } from '@maka/runtime/agent-catalog'; import { SessionManager } from '@maka/runtime/session-manager'; import { fingerprintAgentGraphRunnableIntent } from '@maka/runtime/stream-graph-admission'; @@ -421,6 +425,208 @@ test('production composition commits automatic titles through Host-owned Session }); }); +test('WorkHub creates new work through the production assignment composition', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-create-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + try { + const resolved = await composition.handlers['workhub.coordination.resolve']({}, context); + assert.equal(resolved.ok, true); + const created = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-create-action', + userText: 'Fix login stability', + proposal: { disposition: 'create_new', title: 'Login stability' }, + create: { workspace: { kind: 'host_path', path: root } }, + }, + context, + ); + + assert.equal(created.ok, true, JSON.stringify(created)); + if (!created.ok || created.result.disposition !== 'create_new') return; + const targetSessionId = created.result.targetSessionId; + const session = (await manager.listSessions()).find(({ id }) => id === targetSessionId); + assert.equal(session?.name, 'Login stability'); + assert.equal(session?.llmConnectionId, connectionId); + } finally { + await composition.close(); + } + }); +}); + +test('WorkHub correction replaces its link without stopping a shared manual Turn', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-shared-turn-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + let activeRunId: string | undefined; + let sourceId: string | undefined; + try { + const source = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + sourceId = source.id; + const destination = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const started = await composition.handlers['turn.start']( + { + sessionId: source.id, + turnId: 'manual-active-turn', + content: { text: FAKE_HOLD_OPEN_PROMPT }, + }, + context, + ); + assert.equal(started.ok, true); + if (!started.ok || started.result.kind !== 'started') return; + activeRunId = started.result.turn.runId; + + const resolved = await composition.handlers['workhub.coordination.resolve']({}, context); + assert.equal(resolved.ok, true); + const candidates = await composition.handlers['workhub.coordination.candidates']({}, context); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const sourceCandidate = candidates.result.candidates.find( + (candidate) => candidate.sessionId === source.id, + ); + const destinationCandidate = candidates.result.candidates.find( + (candidate) => candidate.sessionId === destination.id, + ); + assert.ok(sourceCandidate); + assert.ok(destinationCandidate); + if (!sourceCandidate || !destinationCandidate) return; + + const delegated = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-steering-action', + userText: 'Continue this manual work from WorkHub', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: sourceCandidate.candidateRef, + }, + }, + context, + ); + assert.equal(delegated.ok, true); + if (!delegated.ok) return; + assert.equal(delegated.result.disposition, 'delegate_existing'); + if (delegated.result.disposition !== 'delegate_existing') return; + assert.equal(delegated.result.steered, true); + + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const assignment = await stores.sessionStore.readWorkHubAssignment('workhub-steering-action'); + assert.ok(assignment); + if (!assignment) return; + await waitFor(async () => { + const proof = await composition.handlers['turn.message.execution.query']( + { sessionId: source.id, messageIds: [assignment.targetMessageId] }, + context, + ); + return proof.ok && proof.result.resolutions[0]?.state === 'owned'; + }); + + const unrelated = await composition.handlers['turn.message.submit']( + { + originHostEpoch: context.hostEpoch, + sessionId: source.id, + messageId: 'unrelated-followup-message', + content: { text: 'Keep this unrelated follow-up queued' }, + placement: 'next_turn', + }, + context, + ); + assert.equal(unrelated.ok, true); + if (!unrelated.ok) return; + assert.equal(unrelated.result.disposition, 'followup'); + + const correctionCandidates = await composition.handlers['workhub.coordination.candidates']( + {}, + context, + ); + assert.equal(correctionCandidates.ok, true); + if (!correctionCandidates.ok) return; + const correctionDestination = correctionCandidates.result.candidates.find( + (candidate) => candidate.sessionId === destination.id, + ); + assert.ok(correctionDestination); + if (!correctionDestination) return; + + const correction = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-correction-action', + userText: `No, move this to ${correctionDestination.sessionName} instead`, + candidateSetId: correctionCandidates.result.candidateSetId, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: assignment.actionId, + target: { + disposition: 'delegate_existing', + candidateRef: correctionDestination.candidateRef, + }, + }, + }, + context, + ); + assert.equal(correction.ok, true, JSON.stringify(correction)); + if (!correction.ok) return; + assert.equal(correction.result.disposition, 'replace'); + if (correction.result.disposition === 'replace') { + assert.equal(correction.result.targetSessionId, destination.id); + } + + const supersession = await stores.sessionStore.readWorkHubSupersession( + assignment.delegationId, + ); + assert.equal(supersession?.replacementDelegationId.startsWith('whd_'), true); + + const active = await composition.handlers['turn.query']( + { sessionId: source.id, turnId: 'manual-active-turn' }, + context, + ); + assert.equal(active.ok, true); + if (active.ok) { + assert.equal(active.result.status, 'running'); + assert.equal(active.result.runId, activeRunId); + } + const queued = await composition.handlers['turn.message.execution.query']( + { sessionId: source.id, messageIds: ['unrelated-followup-message'] }, + context, + ); + assert.equal(queued.ok, true); + if (queued.ok) assert.equal(queued.result.resolutions[0]?.state, 'pending'); + } finally { + if (sourceId && activeRunId) { + await composition.handlers['turn.stop']( + { sessionId: sourceId, turnId: 'manual-active-turn', runId: activeRunId }, + context, + ); + } + await composition.close(); + } + }); +}); + test('a legacy fake-backend session is refused with the product reason, not a registry error', async () => { await withCompositionRoot(async ({ root, owner }) => { // Written by an older build: no creation path here can produce `fake`, so @@ -997,6 +1203,42 @@ function compositionContext(owner: InteractiveRootOwner) { }; } +async function configureFakeDefaultTarget(owner: InteractiveRootOwner): Promise { + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: 'fake', + name: 'Fake', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') throw new Error('Fake connection was not committed'); + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) throw new Error('Fake connection is unavailable'); + const fetch = await policy.operations.beginModelFetch(connection.connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') throw new Error('Fake model fetch did not start'); + const fetched = await policy.operations.completeModelFetch(fetch.ticket, { + models: [{ id: 'fake-model' }], + source: 'fetched', + fetchedAt: Date.now(), + }); + assert.equal(fetched.kind, 'committed'); + if (fetched.kind !== 'committed') throw new Error('Fake model catalog was not committed'); + const selected = await policy.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: fetched.snapshot.revision, + target: { connectionId: connection.connectionId, modelId: 'fake-model' }, + }); + assert.equal(selected.kind, 'committed'); + if (selected.kind !== 'committed') throw new Error('Fake default target was not committed'); + return connection.connectionId; +} + function shellRunRecord( sessionId: string, shellRunId: string, diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index acfe0373b9..ae6c2e168f 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -482,6 +482,80 @@ test('message query reports only durable cancellation proof', async () => { }); }); +test('exact pending cancellation removes only the linked Message', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'linked-message', 'wrong delegation', 'next_turn'); + await submit(fixture, 'unrelated-message', 'keep this queued', 'next_turn'); + + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + { kind: 'cancelled' }, + ); + assert.deepEqual( + fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), + ['unrelated-message'], + ); + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + { kind: 'cancelled' }, + ); +}); + +test('a consumed steering Message cannot claim ownership of its pre-existing root Turn', async () => { + const fixture = createFixture(); + fixture.events.push(steeringEvent('linked-message', 'wrong delegation')); + + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + { + kind: 'shared_turn', + turnId: ROOT.turnId, + runId: ROOT.runId, + }, + ); +}); + +test('a root source Message owns only the root Turn it created', async () => { + const fixture = createFixture(); + fixture.receipts.set( + 'linked-message', + sourceReceipt('linked-message', 'wrong delegation', 'current_turn', 'turn_started'), + ); + + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + { + kind: 'owned_root', + turnId: 'durable-turn', + runId: 'durable-run', + }, + ); +}); + +test('a recovered multi-source successor remains shared by every source Message', async () => { + const fixture = createFixture(); + const first = sourceReceipt('linked-message', 'wrong delegation', 'current_turn', 'steering'); + const second = sourceReceipt('other-message', 'other delegation', 'current_turn', 'steering'); + const sourceMessages = [first.sourceMessage, second.sourceMessage]; + fixture.receipts.set('linked-message', { + ...first, + admission: { ...first.admission, userMessageId: null, sourceMessages }, + }); + fixture.receipts.set('other-message', { + ...second, + admission: { ...second.admission, userMessageId: null, sourceMessages }, + }); + + for (const messageId of ['linked-message', 'other-message']) { + assert.deepEqual(await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, messageId), { + kind: 'shared_turn', + turnId: 'durable-turn', + runId: 'durable-run', + }); + } +}); + test('message execution query reports the Turn that durably owns each Message', async () => { const fixture = createFixture(); const pendingContent = { text: 'not handed off yet' }; diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 095bea5ffd..a1bed88c73 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -19,13 +19,22 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import type { + WorkHubDelegationAssignedMessage, + WorkHubDelegationReplacementAbortedMessage, + WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationSupersededMessage, +} from '@maka/core/session'; import { WorkHubActionEffectFailure, WorkHubActionGateFailure, WorkHubCoordinationActionGate, + isExplicitWorkHubCorrectionText, type WorkHubActionGateEffects, type WorkHubActionGateSession, type WorkHubDelegationAssignmentInput, + type WorkHubDelegationReplacementAbortInput, + type WorkHubDelegationReplacementInput, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -37,6 +46,188 @@ const CONTEXT: ConnectionContext = { }; describe('WorkHub Coordination Action Gate', () => { + test('trusted correction text binds complete target and title identities', () => { + for (const [text, targetName] of [ + ['No, use "Payments"', 'Payments'], + ['不是这个,换成“支付任务”', '支付任务'], + ['No, use Research and Development', 'Research and Development'], + ['No, use Payments, Retry', 'Payments, Retry'], + ] as const) { + assert.equal(isExplicitWorkHubCorrectionText(text, 'delegate_existing', targetName), true); + } + for (const text of [ + "No, use Payments and don't proceed", + "No, use Payments and don't go ahead with that", + 'No, use Payments, forget it', + 'No, use Payments, on second thought leave it', + '不是这个,转到支付任务然后不要继续', + '不是这个,换成支付任务,当我没说', + '不是这个,换成支付任务,还是维持原样', + 'No, use Payments, fix login. Forget it', + 'No, use Payments — on second thought leave it', + '不是这个,换成支付任务,修复登录。当我没说', + ]) { + assert.equal(isExplicitWorkHubCorrectionText(text, 'delegate_existing', 'Payments'), false); + assert.equal(isExplicitWorkHubCorrectionText(text, 'delegate_existing', '支付任务'), false); + } + assert.equal( + isExplicitWorkHubCorrectionText( + "No, use Payments and don't proceed", + 'delegate_existing', + "Payments and don't proceed", + ), + false, + ); + assert.equal( + isExplicitWorkHubCorrectionText( + '不是这个,转到支付任务然后不要继续', + 'delegate_existing', + '支付任务然后不要继续', + ), + false, + ); + assert.equal( + isExplicitWorkHubCorrectionText( + 'No, use Payments and stop.', + 'delegate_existing', + 'Payments and stop', + ), + false, + ); + assert.equal( + isExplicitWorkHubCorrectionText( + '不是这个,转到支付任务然后停止。', + 'delegate_existing', + '支付任务然后停止', + ), + false, + ); + for (const [text, targetName] of [ + ['No, use Payments and abort.', 'Payments and abort'], + ['No, use Payments and cancel.', 'Payments and cancel'], + ['No, use Payments and stop now.', 'Payments and stop now'], + ['No, use Payments and halt this.', 'Payments and halt this'], + ['No, use Payments and ABORT.', 'Payments and ABORT'], + ['No, use Payments but abort.', 'Payments but abort'], + ['No, use Payments; stop now.', 'Payments; stop now'], + ['No, use Payments. Abort.', 'Payments. Abort'], + ['No, use Payments, cancel.', 'Payments, cancel'], + ['不是这个,转到支付任务然后作罢。', '支付任务然后作罢'], + ['不是这个,转到支付任务然后停止执行。', '支付任务然后停止执行'], + ['不是这个,转到支付任务但是作罢。', '支付任务但是作罢'], + ['不是这个,转到支付任务。作罢。', '支付任务。作罢'], + ['No, use Payments. I changed my mind.', 'Payments. I changed my mind'], + [ + 'No, use Payments. On second thought, keep it here.', + 'Payments. On second thought, keep it here', + ], + ['不是这个,转到支付任务。我改主意了。', '支付任务。我改主意了'], + ] as const) { + assert.equal(isExplicitWorkHubCorrectionText(text, 'delegate_existing', targetName), false); + } + for (const text of [ + 'No, create a new Session titled Login instead', + 'No, create a new Session with title Login', + '不对,请创建一个新的 Session 标题为登录稳定性', + '错了,新建一个会话名称为登录稳定性', + ]) { + assert.equal(isExplicitWorkHubCorrectionText(text, 'create_new', 'Payments'), false, text); + } + assert.equal( + isExplicitWorkHubCorrectionText( + "No, don't create a new session called Login; instead create a new session called Payments.", + 'create_new', + 'Payments', + ), + true, + ); + const incidentalNamedExample = + 'No, create a new Session called Payments, and add documentation containing the example new Session called Fraud.'; + assert.equal( + isExplicitWorkHubCorrectionText(incidentalNamedExample, 'create_new', 'Payments'), + true, + ); + assert.equal( + isExplicitWorkHubCorrectionText(incidentalNamedExample, 'create_new', 'Fraud'), + false, + ); + for (const [text, targetName] of [ + ['No, create a new Session called U.S. Payments', 'U.S. Payments'], + ['No, create a new Session called Dr. Login', 'Dr. Login'], + ['No, create a new Session called Acme Inc. Payments', 'Acme Inc. Payments'], + ['No, create a new Session called No. 5 Login', 'No. 5 Login'], + ['No, create a new Session called Ph.D. Research', 'Ph.D. Research'], + ['No, create a new Session called Payments. Fix login', 'Payments'], + ['No, create a new Session called App. Fix login', 'App'], + ['No, create a new Session called Fix. Add documentation.', 'Fix'], + ['No, create a new Session called Go. Then add tests.', 'Go'], + ['No, create a new Session called Acme Inc. Fix login.', 'Acme Inc'], + ['No, create a new Session called U.S. Fix login.', 'U.S'], + ['No, create a new Session called Ph.D. Fix login.', 'Ph.D'], + ['No, create a new Session called No. Fix login.', 'No'], + ['No, create a new Session called St. Fix login.', 'St'], + ['No, create a new Session called Acme Inc. Then fix login.', 'Acme Inc'], + ['No, create a new Session called Acme Inc. Please fix login.', 'Acme Inc'], + ['No, create a new Session called Acme Inc. Please then fix login.', 'Acme Inc'], + ['No, create a new Session called Acme Inc. Then, please fix login.', 'Acme Inc'], + ['No, create a new Session called Acme Inc. Finally fix login.', 'Acme Inc'], + ['No, create a new Session called Acme Inc. Afterwards fix login.', 'Acme Inc'], + ['No, create a new Session called U.S. Can you fix login?', 'U.S'], + ['No, create a new Session called U.S. Next, fix login.', 'U.S'], + ['No, create a new Session called U.S. Also fix login.', 'U.S'], + ['No, create a new Session called U.S. Immediately fix login.', 'U.S'], + ['No, create a new Session called U.S. Proceed to fix login.', 'U.S'], + ['No, create a new Session called U.S. At that point fix login.', 'U.S'], + ['No, create a new Session called U.S. Daily Fix', 'U.S. Daily Fix'], + ['No, create a new Session called U.S. Monthly Update', 'U.S. Monthly Update'], + ['No, create a new Session called U.S. Monthly update', 'U.S. Monthly update'], + ['No, create a new Session called U.S. customer update', 'U.S. customer update'], + ['No, create a new Session called Ph.D. Could you fix login?', 'Ph.D'], + ['No, create a new Session called Ph.D. Now fix login.', 'Ph.D'], + ['No, create a new Session called Ph.D. Finally fix login.', 'Ph.D'], + ['No, create a new Session called Ph.D. 接下来修复登录。', 'Ph.D'], + ['No, create a new Session called Ph.D. 最后修复登录。', 'Ph.D'], + ['No, create a new Session called Ph.D. Friendly Fix', 'Ph.D. Friendly Fix'], + ['No, create a new Session called Ph.D. Friendly fix', 'Ph.D. Friendly fix'], + ] as const) { + assert.equal(isExplicitWorkHubCorrectionText(text, 'create_new', targetName), true, text); + } + for (const text of [ + "No, don't create a new session called Login; instead create a new session for Payments", + 'No, create a new Session called "Payments', + '不对,创建一个新会话叫“支付任务', + "No, create a new Session called Payments and don't proceed.", + '不对,创建一个新会话叫支付任务然后不要继续。', + 'No, create a new Session called Payments and stop.', + 'No, create a new Session called Payments and abort.', + 'No, create a new Session called Payments and cancel.', + 'No, create a new Session called Payments and stop now.', + 'No, create a new Session called Payments and halt this operation immediately.', + 'No, create a new Session called Payments and ABORT.', + 'No, create a new Session called Payments but abort.', + 'No, create a new Session called Payments; stop now.', + 'No, create a new Session called Payments. Abort.', + 'No, create a new Session called Payments, cancel.', + '不对,创建一个新会话叫支付任务然后作罢。', + '不对,创建一个新会话叫支付任务然后停止执行。', + '不对,创建一个新会话叫支付任务但是作罢。', + '不对,创建一个新会话叫支付任务。作罢。', + 'No, create a new Session called Payments. Example: create a new Session called Fraud.', + 'No, create a new Session called Payments.Example: create a new Session called Fraud.', + 'No, create a new Session called App. Example: create a new Session called Fraud.', + 'No, create a new Session called Payments. Fix login, cancel this task.', + 'No, create a new Session called Payments. Cancel the current task.', + 'No, create a new Session called Payments. Rescind my request.', + 'No, create a new Session called Payments. Drop this task.', + 'No, create a new Session called Payments. Fix login. Please cancel the task.', + "No, create a new Session called Payments. Fix login. Let's cancel the task.", + 'No, create a new Session called Payments. I do not wish to proceed.', + ]) { + assert.equal(isExplicitWorkHubCorrectionText(text, 'create_new', 'Login'), false, text); + assert.equal(isExplicitWorkHubCorrectionText(text, 'create_new', 'Payments'), false, text); + } + }); + test('exposes only bounded ordinary candidates and opaque refs', async () => { const effects = fakeEffects([ session('ordinary'), @@ -188,6 +379,29 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.assignments[0]!.userText, 'Continue payments'); }); + test('rejects ambiguous advisory text before delegating to an existing Session', async () => { + const effects = fakeEffects([session('login', { name: 'Login' })]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + + await assert.rejects( + gate.act( + { + actionId: 'ambiguous-delegate', + userText: 'Explain how to diagnose Login, then fix it.', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.assignments.length, 0); + }); + test('create_new carries creation context into the same assignment', async () => { const effects = fakeEffects([]); const gate = new WorkHubCoordinationActionGate(effects); @@ -228,6 +442,829 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.assignments.length, 2); }); + test('initial creation rejects negated executable intent at the host gate', async () => { + const negatedCreationCases = [ + '请勿创建一个新的 Session', + 'Don’t ever create a new session', + 'Do not, under any circumstances, create a new session', + '我不想创建一个新的 Session', + '我不打算创建一个新的 Session', + '我不是要创建一个新的 Session,只是讨论', + '我并非要创建一个新的 Session,只是讨论', + '不是想创建一个新的 Session,只是问问', + '我不是让你创建一个新的 Session,只是讨论', + '我不是说要创建一个新的 Session,只是讨论', + '并非让你创建一个新的 Session,只是讨论', + '我没让你创建一个新的 Session,只是讨论', + '我没有让你创建一个新的 Session,只是讨论', + '我不希望你创建一个新的 Session,只是讨论', + '我不是请你创建一个新的 Session,只是讨论', + '我没说要创建一个新的 Session,只是讨论', + '我没有说要创建一个新的 Session,只是讨论', + '我没有打算创建一个新的 Session,只是讨论', + '我没准备创建一个新的 Session,只是讨论', + 'Please refuse to create a new Session', + 'I decline to create a new Session; just discuss', + '我未打算创建一个新的 Session,只是讨论', + 'I will not create a new session', + 'not create a new session; just discuss', + 'Under no circumstances create a new session', + 'Create a new Session; do not create a new Session', + '创建一个新的 Session;不要创建一个新的 Session', + "Create a new Session, but don't create it", + "Create a new Session for login — actually, don't", + '创建一个新的 Session 处理登录,还是别了', + "Create a new Session to fix login, logout, etc. Don't.", + "Create a new Session for login\nDon't.", + "Create a new Session for login - don't", + "Create a new Session for parser tokens:\ndon't\n\nactually, don't", + "Create a new Session for login\nCorrection:\ndon't", + "Create a new Session for login\nFinal correction:\ndon't", + "Create a new Session for login\nCorrection:\n- don't", + "Create a new Session for login\nCorrection:\n1. don't", + "Create a new Session for login\nCorrection:\n don't", + '创建一个新的 Session 处理登录\n更正:\n- 还是别了', + "Create a new Session for login\nCorrection note:\n- don't", + "Create a new Session for login\nOn second thought:\n1. don't", + '创建一个新的 Session 处理登录\n想了想:\n 还是别了', + "Create a new Session for login\n## Correction:\n- don't", + "Create a new Session for login\n**Correction:**\n- don't", + '创建一个新的 Session 处理登录\n## 更正:\n- 还是别了', + "Create a new Session for login\nChange to:\n- don't", + "Create a new Session for login\nUpdate to:\ndon't", + "Create a new Session for login\nCorrection to:\n1. don't", + '创建一个新的 Session 处理登录\n改为:\n- 还是别了', + "Create a new Session for login\nIn any case:\ndon't", + "Create a new Session for parser examples:\n- do\n \n- don't", + "Create a new Session for parser examples:\n1. do\n\t\n2. don't", + "Create a new Session for login\nFor this parser case:\ndon't", + "Create a new Session, but don't create one after all", + '创建一个新的 Session,不过不要创建它', + '创建一个新的 Session;不过不要这样做', + "Fix login stability, actually don't fix it", + "Fix login stability — actually, don't", + '修复登录稳定性,还是别了', + "Fix login, logout, etc. Don't.", + "Fix login\nDon't.", + '修复登录稳定性\n还是别了', + "Fix login stability - don't", + '修复登录稳定性 - 还是别了', + "Fix parser tokens:\ndon't\n\nactually, don't", + "Fix login\nCorrection:\ndon't", + '修复登录\n更正:\n还是别了', + "Fix login\nWait:\ndon't", + '修复登录\n不对:\n还是别了', + "Fix login\nCorrection note:\n- don't", + '修复登录\n想了想:\n 还是别了', + "Fix login\nIn that case:\ndon't", + "Fix login\nFor example:\ndon't", + "Fix login\nIn this test case:\ndon't", + "Fix login\nParser in this case:\ndon't", + "Fix login\nWith this config value:\ndon't", + "Create a new Session for login\nConfig in this case:\ndon't", + "Create a new Session for login\nTesting, for example:\ndon't", + "Fix login stability, but don't fix it", + "Fix login stability, but don't do that", + "Fix login stability, but don't implement it", + "Implement login stability, but don't fix it", + "Fix login stability, actually don't fix login stability", + 'Fix login stability, but do not fix login stability', + 'Fix login stability and do not fix login stability', + "Fix login stability then don't fix login stability", + 'Fix login stability and please do not fix login stability', + "Fix login stability then kindly don't fix login stability", + 'Fix login stability and could you please not fix login stability', + '修复登录稳定性,但不要修改它', + '修复登录稳定性,不过不要修复登录稳定性', + '修复登录稳定性并且不要修复登录稳定性', + '修复登录稳定性然后请不要修复登录稳定性', + '修复登录稳定性然后麻烦你不要修复登录稳定性', + '修复登录稳定性然后真的不要修复登录稳定性', + 'Fix login stability and just do not fix login stability', + "Fix login stability and simply don't fix login stability", + '修复登录稳定性然后千万不要修复登录稳定性', + 'Do not create a new Session to fix login stability', + '不要创建一个新的 Session 来修复登录稳定性', + 'Do not create a new Session. Fix login stability', + '我想知道如何修复登录问题', + '我们应该如何修复登录问题', + 'Please explain how to fix login', + 'Tell me how to fix login', + 'I would like to understand how to fix login', + '麻烦解释如何修复登录问题', + '请告诉我如何修复登录问题', + 'Can you tell me if we should fix login?', + 'Could you evaluate if we should implement payment retry?', + '请告诉我该不该修复登录问题?', + '请告诉我应不应该实现支付重试?', + 'Can you give me steps to fix login', + 'Please give me a way to fix login', + 'Could you outline the steps to fix login', + '告诉我修复登录的步骤', + '给我一个修复登录的方法', + 'Create a new Session called Login', + 'Create a new Session called "Payments', + '创建一个新会话叫“支付任务', + "Create a new Session called Payments and don't proceed.", + '创建一个新会话叫支付任务然后不要继续。', + 'Create a new Session called Payments and stop.', + 'Create a new Session called Payments and abort.', + 'Create a new Session called Payments and cancel.', + 'Create a new Session called Payments and stop now.', + 'Create a new Session called Payments and halt this operation immediately.', + 'Create a new Session called Payments and ABORT.', + 'Create a new Session called Payments but abort.', + 'Create a new Session called Payments; stop now.', + 'Create a new Session called Payments. Abort.', + 'Create a new Session called Payments, cancel.', + '创建一个新会话叫支付任务然后作罢。', + '创建一个新会话叫支付任务然后停止执行。', + '创建一个新会话叫支付任务但是作罢。', + '创建一个新会话叫支付任务。作罢。', + 'Can you tell me: should I fix login?', + 'Can you recommend I fix login?', + '请告诉我:我应该修复登录吗?', + '请告诉我应否修复登录?', + 'Can you tell me if I need to fix login', + 'Could you tell me if I must implement retry', + 'I need to know if I need to fix login', + '请告诉我我应否修复登录', + 'Can you fix login, or should we wait?', + 'Can you fix login? Actually, should we?', + 'Can you fix login, or should we wait', + 'Can you fix login. Actually, should we', + '请修复登录,还是应该先等等?', + '请修复登录,还是应该先等等', + 'Can you fix login, or leave it for now?', + '请修复登录,还是等等吧?', + 'Fix login. Maybe we should wait', + 'Fix login. On second thought, maybe wait', + 'Can you tell me if it is necessary to fix login', + 'Explain when to fix login', + 'Tell me in which cases to fix login', + '请告诉我在什么情况下修复登录', + 'Fix login, but maybe we should wait', + 'Fix login; perhaps we should wait', + '请修复登录,不过也许应该等等', + 'Fix login. Actually, I am not sure.', + 'Fix login. Do you think we should?', + "Fix login. On second thought, I'm not sure", + '请修复登录。我不确定。', + 'Can you recommend I fix login', + 'Could you suggest I implement retry', + 'Explain the circumstances in which to fix login', + 'Tell me the best time to fix login', + 'When should I fix login?', + 'When do we fix login?', + '如果什么时候修复登录?', + 'If unsure whether to fix login?', + 'When in doubt, ask whether to fix login?', + 'If it is unclear how to implement retry?', + 'When is it appropriate to fix login?', + '如果适合修复登录?', + 'Fix login. Is that wise?', + 'Fix login, cancel this task.', + 'Fix login, I take that back.', + 'Create a new Session for Payments; cancel the creation.', + 'Create a new Session for Payments. Cancel the new session.', + '创建一个新会话用于支付然后停止创建。', + 'Fix login, cancel this job.', + 'Fix login, withdraw the request.', + 'Fix login, retract that.', + 'Fix login, forget the request.', + 'Create a new Session for Payments; cancel my request.', + 'Create a new Session for Payments; withdraw that request.', + 'Create a new Session for Payments. Revoke that request.', + 'When might we fix login?', + 'When may we fix login?', + 'When will we fix login?', + 'If it makes sense to fix login?', + '如果现在修复登录合适吗?', + 'Fix login. Are we sure?', + 'Fix login. Are you sure?', + 'Fix login. Are they sure?', + 'Fix login. Should we really?', + 'Fix login, cancel the current task.', + 'Fix login, rescind my request.', + 'Fix login, drop this task.', + 'Fix login. Please cancel the task.', + 'Fix login. I want to cancel the task.', + 'Fix login. Could you cancel the task?', + "Fix login. Let's cancel the task.", + 'Fix login. I would prefer to cancel the task.', + 'Fix login. Please do not proceed with the task.', + 'Fix login. I do not wish to proceed.', + 'When should the service fix login?', + 'When will Alice fix login?', + 'When will the patch fix login?', + 'When must the service fix login?', + 'When ought we fix login?', + 'If you think we should fix login?', + 'If you believe we ought to implement retry?', + 'If it is advisable to fix login?', + 'If I wanted you to fix login, what would happen?', + 'If I asked you to fix login, how would you approach it?', + 'If the plan were to fix login, would that be wise?', + 'If I asked you to fix login?', + 'If I wanted you to fix login?', + 'If the plan were to fix login?', + 'If I wanted you to fix login what would happen?', + 'If I asked you to fix login how would you approach it?', + 'If the plan were to fix login would that be wise?', + '如果现在修复登录可以吗?', + '如果现在修复登录可行吗?', + '如果我让你修复登录会怎样?', + 'Fix login. Do you agree?', + 'Fix login. Are you certain?', + 'Fix login. Do you still want that?', + 'Fix login — do you agree?', + 'Fix login: are you sure?', + 'Fix login, okay?', + 'Fix login, sound good?', + 'Fix login, maybe?', + 'Fix login, perhaps?', + 'Fix login, not sure?', + 'Fix login, any concerns?', + '修复登录,没问题吧?', + 'Could you suggest ways to monitor and fix login', + 'Explain techniques that diagnose and fix login errors.', + 'Discuss approaches that prevent and fix login errors.', + 'Explain the steps to diagnose and fix login.', + 'Explain strategies that diagnose and fix login.', + 'Recommend patterns that detect and fix login.', + 'Could you suggest practical options to monitor and fix login', + 'Tell me possible solutions to identify and fix login', + 'Describe techniques that diagnose and fix login.', + 'Analyze strategies that diagnose and fix login.', + 'Explain a process where we diagnose and fix login.', + 'Describe a framework that diagnoses and fix login.', + 'Outline a workflow that detects and fix login.', + 'Summarize a proposal where we diagnose and fix login.', + 'Compare tools that detect and fix login.', + 'I plan to fix login myself.', + 'The team will fix login.', + 'Suppose we fix login.', + 'If we fix login, users will be happier.', + 'When we fix login, users will be happier.', + '如果我们修复登录,用户会更满意。', + 'If the team can fix login, users will be happier.', + 'If Alice can fix login, users will be happier.', + '如果团队能修复登录,用户会更满意。', + 'Should we fix login and then update docs?', + 'Can we diagnose login and then fix it?', + 'What if we fix login and then update docs?', + 'Maybe investigate and fix login.', + 'Perhaps review and update docs.', + 'Potentially debug and fix login.', + 'Our goal is to investigate and fix login.', + 'The requirement is to investigate and fix login.', + 'The service must diagnose and fix login.', + 'Should we diagnose, then fix login?', + 'How should we fix login, then update docs?', + 'Explain how to fix login and then update docs.', + 'Tell me how to diagnose and then fix login.', + 'Can you explain how to diagnose login and then fix it?', + 'Explain whether we should diagnose then fix login.', + 'Discuss whether to diagnose then fix login.', + 'Tell me how we should diagnose then fix login.', + 'Explain how to diagnose, fix, and test login.', + 'Recommend ways to diagnose, fix, and test login.', + 'Explain how to diagnose login, fix it, and update docs.', + 'Explain whether we should diagnose, then fix login.', + 'Explain the workflow: diagnose, then fix login.', + 'Discuss the sequence: diagnose, then fix login.', + 'Review notes and fix status are attached.', + 'Audit results and fix plans are attached.', + 'Research findings and fix recommendations are attached.', + 'Explain how to diagnose login; then fix it. Is that wise?', + 'Explain how to diagnose login, then fix it—but is that wise?', + 'Explain how to diagnose the text "login, then fix it".', + 'Explain how to diagnose a phrase saying "login; then fix it".', + 'Explain how to diagnose login, and test results are attached.', + 'Explain how to diagnose login; then test results are available.', + 'Audit findings and fix recommendations both matter.', + 'Research findings and fix recommendations changed yesterday.', + '分析报告并修复建议已经附上。', + '调查结果并修复建议都很重要。', + 'Explain how to diagnose the text `login, then fix it`.', + 'Explain how to diagnose the sequence (login, then fix it).', + 'Explain how to diagnose login; then fix it, any concerns?', + 'Explain how to diagnose login; then fix it, do you agree?', + 'Audit findings and fix recommendations matter.', + 'Review notes and fix status matters.', + 'Research findings and fix recommendations changed.', + 'Explain how to diagnose login; then test results matter.', + 'Explain how to diagnose login; then test coverage improved.', + 'Explain how to diagnose login; then update metrics increased.', + 'Explain how to diagnose the text "login, then fix it.', + 'Explain how to diagnose the text `login, then fix it.', + 'Explain how to diagnose the sequence (login (primary), then fix it).', + 'Explain how to diagnose the sequence [login, then fix it].', + ]; + for (const [index, userText] of negatedCreationCases.entries()) { + const effects = fakeEffects([]); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: `negated-initial-create-${index}`, + userText, + proposal: { disposition: 'create_new', title: 'New Session' }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.assignments.length, 0); + } + }); + + test('initial creation accepts polite executable requests and file-level constraints', async () => { + const cases = [ + 'Can you fix login stability?', + 'Can you please fix login?', + 'Could you kindly implement payment retry?', + 'If retries fail, can you fix login?', + 'If retries fail can you fix login?', + 'If retries fail then can you fix login?', + 'When retries fail, can you fix login?', + 'If retries fail then fix login?', + 'When retries fail, fix login?', + 'When retries fail fix login?', + 'Tell me the options and fix login', + 'Recommend options and fix login', + 'Can you fix login, but leave documentation unchanged?', + 'Please try to reproduce and fix login', + 'Try to reproduce and fix login', + 'Work to diagnose and fix login', + 'Explain that issue and fix login', + 'Update the label to How can I help?', + 'Fix copy to say What should I do?', + 'Implement an FAQ answering How can I recover?', + 'Update the prompt to How can I help?', + 'Fix the heading to What should I do?', + 'Update the tooltip to Where can I find files?', + 'Update the message to Why did this fail?', + 'Investigate and fix login', + 'Analyze and fix login', + 'Debug and fix login', + 'Review and update docs', + 'First investigate, then fix login', + 'Assess and fix login', + 'Examine and fix login', + '调查并修复登录', + '先分析,然后修复登录', + 'Investigate the issue and fix both login and logout.', + 'Review the failure and fix the affected user accounts.', + 'Analyze the suite and update the generated docs.', + '先分析,然后修复已经失败的测试。', + 'Investigate and fix login stability.', + 'Review and update API docs.', + 'Analyze and fix payment retry logic.', + 'Audit and update generated API docs.', + 'Investigate issue and fix login for mobile.', + 'Assess logs and update docs for operators.', + 'Review issue and fix login in production.', + 'If tests fail, fix login.', + 'If needed fix login.', + 'If necessary implement retries.', + 'When ready fix login.', + 'If possible fix login.', + 'If required fix login.', + 'If safe fix login.', + 'If appropriate implement retries.', + 'When convenient update docs.', + 'When available fix login.', + 'When feasible fix login.', + 'If desired fix login.', + 'If applicable fix login.', + 'When practical update docs.', + 'If advisable implement retries.', + 'If permitted fix login.', + 'When complete update docs.', + 'If urgent fix login.', + 'When sensible implement retries.', + '如果重试失败就请修复登录?', + 'Fix login, but leave documentation unchanged', + 'Fix login, but hold API behavior constant', + 'Fix login, but wait for tests before merging', + 'Explain the issue, then fix login', + 'Tell me the cause and fix login', + 'Discuss the approach, then implement retry', + 'Consider the options, but fix login now', + '请修复支付回调重复投递?', + 'Fix login stability, but do not create any files', + '修复登录稳定性,但不要创建任何文件', + 'Create a new Session for login, but do not create files', + 'Implement docs to explain how retries work', + 'Update the guide to discuss why login fails', + '修复帮助页以解释如何恢复失败任务', + "Fix login, but don't do that; instead implement payment retry", + '修复登录,但不要这样做;而是实现支付重试', + "Fix login, but don't do that. Implement payment retry", + "Create a new Session for login, but don't do that; instead implement payment retry", + '创建一个新的 Session 处理登录,不过不要这样做;而是实现支付重试', + 'Fix login and do not fix login documentation', + 'Fix checkout, but do not fix checkout tests', + '修复登录,但不要修复登录文档', + 'Update API documentation, but do not update API', + 'Fix checkout tests, but do not fix checkout', + '修复登录文档,但不要修复登录', + ]; + for (const [index, userText] of cases.entries()) { + const effects = fakeEffects([]); + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: `affirmative-initial-create-${index}`, + userText, + proposal: { disposition: 'create_new', title: 'New Work' }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + }, + CONTEXT, + ); + assert.equal(result.disposition, 'create_new', userText); + assert.equal(effects.assignments.length, 1, userText); + } + }); + + test('initial creation rejects advisory how-to ambiguity at the host gate', async () => { + for (const [index, userText] of [ + 'Explain how to fix login, then update the docs.', + 'Tell me how to diagnose login, and fix the bug.', + '解释如何修复登录,然后更新文档。', + 'Explain how to diagnose login; then fix it.', + 'Explain how to diagnose and reproduce login, then fix it.', + 'Explain how to diagnose the text "do not fix", then update docs.', + 'Explain how to diagnose the text `do not fix`, then update docs.', + 'Explain how to diagnose the text (do not fix), then update docs.', + ].entries()) { + const effects = fakeEffects([]); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: `ambiguous-initial-create-${index}`, + userText, + proposal: { disposition: 'create_new', title: 'New Work' }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.assignments.length, 0, userText); + } + }); + + test('initial creation accepts literal negator targets', async () => { + for (const [index, userText] of [ + "Create a new Session for parsing don't", + "Fix parsing of don't", + 'Update the button label to do not', + '修改按钮文案为不要了', + "Create a new Session for parsing contractions, e.g. don't", + 'Fix parsing examples, i.e. do not', + "Create a new Session for parsing contractions, e.g., don't", + 'Fix parsing examples, i.e., do not', + 'Update the button label to:\ndo not', + "Fix parser support for this token:\ndon't", + "Fix parser for these literals:\ndon't\ndo not", + '修改按钮文案为:\n不要了', + "Create a new Session for parsing this token:\ndon't", + "Create a new Session to test cases\n1. don't", + "Fix parser for cases\n1. don't", + "Create a new Session to test this code\n don't", + "Fix parser for this code\n\tdon't", + "Create a new Session to test list items\n- don't", + "Fix parser for list items\n- don't", + "Update parser examples:\n- do\n- don't", + "Update parser examples:\n1. do\n2. don't", + "Update parser examples:\n do\n don't", + "Create a new Session for parser examples:\n- do\n- don't", + "Create a new Session to test parser\n*Examples:*\n- don't", + "Create a new Session to test parser\n_Examples:_\n- don't", + 'Create a new Session to update copy\n帮我修改按钮文案为:\n不要了', + '请帮我修改按钮文案为:\n不要了', + "Fix parser support for foo-don't", + "Create a new Session for parsing foo-don't", + ].entries()) { + const effects = fakeEffects([]); + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: `literal-initial-create-${index}`, + userText, + proposal: { disposition: 'create_new', title: 'New Work' }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + }, + CONTEXT, + ); + assert.equal(result.disposition, 'create_new', userText); + assert.equal(effects.assignments.length, 1, userText); + } + }); + + test('replacement creation requires an affirmative correction at the host gate', async () => { + const effects = fakeEffects([session('source')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Original work', + }, + 'source-turn', + ), + ); + + const negatedCreationCases = [ + 'Create a new Session for login', + 'No examples create a new Session.', + 'This note says no, create a new Session called Payments', + "No, create a new Session for login but don't", + 'No, please explain how to create a new Session', + 'No, tell me how to create a new Session', + '不对,请解释如何创建一个新的 Session', + '错了,请告诉我怎么创建一个新的 Session', + '不是这个,而是不要真的创建一个新的 Session', + '不是这个,而是不要在没有我确认的情况下创建一个新的 Session', + 'Wrong session; do not under any circumstances whatsoever ever create a new session', + 'Wrong session; create a note and do not ever create a new session', + '不是这个,而是请勿创建一个新的 Session', + '我不想创建一个新的 Session', + '我不打算创建一个新的 Session', + '我不是要创建一个新的 Session,只是讨论', + '我并非要创建一个新的 Session,只是讨论', + '不是想创建一个新的 Session,只是问问', + '我不是让你创建一个新的 Session,只是讨论', + '我不是说要创建一个新的 Session,只是讨论', + '并非让你创建一个新的 Session,只是讨论', + '我没让你创建一个新的 Session,只是讨论', + '我没有让你创建一个新的 Session,只是讨论', + '我不希望你创建一个新的 Session,只是讨论', + '我不是请你创建一个新的 Session,只是讨论', + '我没说要创建一个新的 Session,只是讨论', + '我没有说要创建一个新的 Session,只是讨论', + '我没有打算创建一个新的 Session,只是讨论', + '我没准备创建一个新的 Session,只是讨论', + 'Please refuse to create a new Session', + 'I decline to create a new Session; just discuss', + '我未打算创建一个新的 Session,只是讨论', + 'I will not create a new session', + 'not create a new session; just discuss', + 'Under no circumstances create a new session', + 'Create a new Session; do not create a new Session', + '创建一个新的 Session;不要创建一个新的 Session', + "Create a new Session, but don't create it", + "Create a new Session for login — actually, don't", + '创建一个新的 Session 处理登录,还是别了', + "Create a new Session to fix login, logout, etc. Don't.", + "Create a new Session for login\nDon't.", + "Create a new Session for login - don't", + "Create a new Session for parser tokens:\ndon't\n\nactually, don't", + "Create a new Session for login\nCorrection:\ndon't", + "Create a new Session for login\nFinal correction:\ndon't", + "Create a new Session for login\nCorrection:\n- don't", + "Create a new Session for login\nCorrection:\n1. don't", + "Create a new Session for login\nCorrection:\n don't", + '创建一个新的 Session 处理登录\n更正:\n- 还是别了', + "Create a new Session for login\nCorrection note:\n- don't", + "Create a new Session for login\nOn second thought:\n1. don't", + '创建一个新的 Session 处理登录\n想了想:\n 还是别了', + "Create a new Session for login\n## Correction:\n- don't", + "Create a new Session for login\n**Correction:**\n- don't", + '创建一个新的 Session 处理登录\n## 更正:\n- 还是别了', + "Create a new Session for login\nChange to:\n- don't", + "Create a new Session for login\nUpdate to:\ndon't", + "Create a new Session for login\nCorrection to:\n1. don't", + '创建一个新的 Session 处理登录\n改为:\n- 还是别了', + "Create a new Session for login\nIn any case:\ndon't", + "Create a new Session for parser examples:\n- do\n \n- don't", + "Create a new Session for parser examples:\n1. do\n\t\n2. don't", + "Create a new Session for login\nFor this parser case:\ndon't", + "Create a new Session for login\nConfig in this case:\ndon't", + "Create a new Session for login\nTesting, for example:\ndon't", + "Create a new Session, but don't create one after all", + '创建一个新的 Session,不过不要创建它', + '不是这个,而是创建一个新的 Session;不要创建一个新的 Session', + 'Wrong session; don’t ever create a new session', + 'Wrong session; do not, under any circumstances, create a new session', + '不是这个,而是创建一个新的 Session;不过不要这样做', + ]; + for (const [index, userText] of negatedCreationCases.entries()) { + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: `negated-replacement-create-${index}`, + userText, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'source-action', + target: { disposition: 'create_new', title: 'New Session' }, + }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + } + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'mismatched-named-replacement-create', + userText: 'No, create a new Session called Login instead', + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'source-action', + target: { disposition: 'create_new', title: 'Payments' }, + }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.replacements.size, 0); + assert.equal(effects.retirements.length, 0); + }); + + test('replacement rejects a negated existing-target action before retiring the source', async () => { + const effects = fakeEffects([session('source'), session('payments')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Original work', + }, + 'source-turn', + ), + ); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const candidateRef = snapshot.candidates.find( + (candidate) => candidate.sessionId === 'payments', + )!.candidateRef; + + for (const [index, userText] of [ + "Not this session; don't move it to Payments", + '不是这个会话,但不要转到支付任务', + "Not this session; move to Payments, but I don't want to move anymore", + '不是这个会话,转到支付任务,不过我不想转了', + 'No examples use Payments.', + 'This note says no, use Payments', + "No, use Payments but don't", + "No, use Payments and actually don't", + "No, use Payments and don't want to move it", + "No, use Payments and don't proceed", + "No, use Payments and don't go ahead with that", + 'No, use Payments, forget it', + 'No, use Payments, on second thought leave it', + '不是这个,转到支付任务然后不想转了', + '不是这个,转到支付任务然后不要继续', + '不是这个,转到支付任务,当我没说', + '不是这个,转到支付任务,还是维持原样', + 'No, use red instead', + ].entries()) { + await assert.rejects( + gate.act( + { + actionId: `negated-existing-replacement-${index}`, + userText, + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'source-action', + target: { disposition: 'delegate_existing', candidateRef }, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + } + assert.equal(effects.replacements.size, 0); + assert.equal(effects.retirements.length, 0); + assert.equal(effects.assignments.length, 0); + }); + + test('replacement requires the complete affirmed target identity', async () => { + const effects = fakeEffects([ + session('source'), + session('api', { name: 'API' }), + session('payment', { name: 'Payment' }), + session('login', { name: 'Login' }), + session('login-backend', { name: 'Login backend' }), + session('api-client', { name: 'API client' }), + session('payment-callback', { name: '支付回调' }), + session('login-stability', { name: '登录稳定性' }), + session('payment-task', { name: '支付任务' }), + ]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Original work', + }, + 'source-turn', + ), + ); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + for (const [index, { userText, targetId }] of [ + { userText: 'No, use rapid instead', targetId: 'api' }, + { userText: 'No, use Repayment instead', targetId: 'payment' }, + { userText: 'No, use Payments, not Login', targetId: 'login' }, + { userText: 'No, use Payments instead of Login', targetId: 'login' }, + { userText: 'No, move it to Login frontend', targetId: 'login-backend' }, + { userText: 'No, move it to API docs', targetId: 'api-client' }, + { userText: '不是这个,换成支付页面', targetId: 'payment-callback' }, + { userText: '不是这个,换成登录文档', targetId: 'login-stability' }, + { userText: '不是这个,转到支付回调', targetId: 'payment-task' }, + ].entries()) { + const candidateRef = snapshot.candidates.find( + (candidate) => candidate.sessionId === targetId, + )!.candidateRef; + await assert.rejects( + gate.act( + { + actionId: `mismatched-existing-replacement-${index}`, + userText, + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'source-action', + target: { disposition: 'delegate_existing', candidateRef }, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + } + assert.equal(effects.retirements.length, 0); + assert.equal(effects.assignments.length, 0); + }); + + test('replacement creation records a terminal abort when admission fails after retirement', async () => { + const effects = fakeEffects([session('source')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Original work', + }, + 'source-turn', + ), + ); + effects.assign = async () => { + throw new WorkHubActionEffectFailure('internal_failure', 'creation admission failed'); + }; + const input = { + actionId: 'failed-replacement-create', + userText: 'No, create a new Session for Payments instead', + confirmation: { kind: 'user_correction' as const }, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { disposition: 'create_new' as const, title: 'Payments' }, + }, + create: { workspace: { kind: 'host_path' as const, path: '/workspace' } }, + }; + + await assert.rejects(new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)); + assert.equal(effects.retirements.length, 1); + assert.equal( + effects.replacementAborts.get('delegation-source-action')?.reason, + 'target_unavailable', + ); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + }); + test('one in-memory action identity cannot change payload', async () => { const effects = fakeEffects([session('payments'), session('login', { lastMessageAt: 1 })]); const gate = new WorkHubCoordinationActionGate(effects); @@ -306,6 +1343,462 @@ describe('WorkHub Coordination Action Gate', () => { assert.deepEqual(replay, first); assert.equal(effects.assignments.length, 1); }); + + test('rejects a changed candidate when replaying an action after restart', async () => { + const effects = fakeEffects([session('payments'), session('login')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const payments = snapshot.candidates.find((candidate) => candidate.sessionId === 'payments')!; + const login = snapshot.candidates.find((candidate) => candidate.sessionId === 'login')!; + const input = { + actionId: 'delegate-restart-conflict', + userText: 'Continue the work', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: payments.candidateRef, + }, + }; + + await gate.act(input, CONTEXT); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + ...input, + proposal: { ...input.proposal, candidateRef: login.candidateRef }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.assignments.length, 1); + }); + + test('replaces only an explicitly confirmed durable delegation', async () => { + const effects = fakeEffects([session('source'), session('destination')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Wrong target', + }, + 'source-turn', + ), + ); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const destination = snapshot.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!; + const input = { + actionId: 'replacement-action', + userText: 'No, send this to destination', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing' as const, + candidateRef: destination.candidateRef, + }, + }, + }; + + await assert.rejects( + gate.act(input, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + await assert.rejects( + gate.act( + { + ...input, + userText: 'Send this to destination', + confirmation: { kind: 'user_correction' as const }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + await assert.rejects( + gate.act( + { + ...input, + userText: 'No, keep going with the current work', + confirmation: { kind: 'user_correction' as const }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + const result = await gate.act( + { ...input, confirmation: { kind: 'user_correction' as const } }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: 'destination', + targetTurnId: 'turn-replacement-action', + }); + assert.equal(effects.replacements.size, 1); + assert.equal(effects.retirements[0]?.actionId, 'source-action'); + assert.equal(effects.assignments[0]?.replacesActionId, 'source-action'); + assert.equal( + effects.supersessions.get('delegation-source-action')?.actionId, + 'replacement-action', + ); + }); + + test('recovers a prepared replacement after retirement and before assignment', async () => { + const effects = fakeEffects([session('source'), session('destination'), session('other')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Wrong target', + }, + 'source-turn', + ), + ); + const snapshot = await new WorkHubCoordinationActionGate(effects).candidates(); + const input = { + actionId: 'recover-replacement', + userText: 'Not this session; move it to destination', + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' as const }, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!.candidateRef, + }, + }, + }; + const assign = effects.assign; + effects.assign = async () => { + throw new WorkHubActionEffectFailure('internal_failure', 'simulated crash seam'); + }; + + await assert.rejects(new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)); + assert.equal(effects.replacements.has('delegation-source-action'), true); + assert.equal(effects.retirements.length, 1); + assert.equal(effects.assignmentRecords.has(input.actionId), false); + + effects.sessions = effects.sessions.map((candidate) => + candidate.id === 'destination' ? { ...candidate, name: 'Renamed destination' } : candidate, + ); + const refreshed = await new WorkHubCoordinationActionGate(effects).candidates(); + const refreshedDestination = refreshed.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!; + effects.assign = assign; + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + ...input, + candidateSetId: refreshed.candidateSetId, + proposal: { + ...input.proposal, + target: { + ...input.proposal.target, + candidateRef: refreshed.candidates.find( + (candidate) => candidate.sessionId === 'other', + )!.candidateRef, + }, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.retirements.length, 1); + const recovered = await new WorkHubCoordinationActionGate(effects).act( + { + ...input, + candidateSetId: refreshed.candidateSetId, + proposal: { + ...input.proposal, + target: { + ...input.proposal.target, + candidateRef: refreshedDestination.candidateRef, + }, + }, + }, + CONTEXT, + ); + assert.equal(recovered.disposition, 'replace'); + assert.equal(effects.retirements.length, 1); + assert.equal(effects.assignmentRecords.has(input.actionId), true); + assert.equal(effects.supersessions.has('delegation-source-action'), true); + }); + + test('refreshes replacement target display identity after retiring the source', async () => { + const effects = fakeEffects([ + session('source'), + session('destination', { name: 'Destination' }), + ]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Wrong target', + }, + 'source-turn', + ), + ); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const destination = snapshot.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!; + const retireDelegation = effects.retireDelegation; + effects.retireDelegation = async (assignment) => { + await retireDelegation.call(effects, assignment); + effects.sessions = effects.sessions.map((candidate) => + candidate.id === 'destination' ? { ...candidate, name: 'Renamed destination' } : candidate, + ); + }; + const assign = effects.assign; + effects.assign = async (input) => { + const current = effects.sessions.find((candidate) => candidate.id === input.targetSessionId); + if (current?.name !== input.targetSessionName) { + throw new WorkHubActionEffectFailure( + 'internal_failure', + 'Target Session changed before replacement assignment', + ); + } + return assign.call(effects, input); + }; + + const result = await gate.act( + { + actionId: 'rename-race', + userText: 'No, move this to destination', + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing', + candidateRef: destination.candidateRef, + }, + }, + }, + CONTEXT, + ); + assert.equal(result.disposition, 'replace'); + assert.equal(effects.retirements.length, 1); + assert.equal(effects.assignments[0]?.targetSessionName, 'Renamed destination'); + }); + + for (const lifecycle of ['archived', 'waiting'] as const) { + test(`records a terminal abort when the replacement target becomes ${lifecycle} after retirement`, async () => { + const effects = fakeEffects([session('source'), session('destination')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'e'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Wrong target', + }, + 'source-turn', + ), + ); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const destination = snapshot.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!; + const retireDelegation = effects.retireDelegation; + effects.retireDelegation = async (assignment) => { + await retireDelegation.call(effects, assignment); + effects.sessions = effects.sessions.map((candidate) => + candidate.id !== 'destination' + ? candidate + : lifecycle === 'archived' + ? { ...candidate, isArchived: true } + : { ...candidate, status: 'waiting_for_user' }, + ); + }; + const input = { + actionId: `target-became-${lifecycle}`, + userText: 'No, move this to destination', + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' as const }, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing' as const, + candidateRef: destination.candidateRef, + }, + }, + }; + + await assert.rejects( + gate.act(input, CONTEXT), + (error) => + error instanceof WorkHubActionGateFailure && + error.code === + (lifecycle === 'archived' ? 'candidate_unavailable' : 'target_waiting_for_user'), + ); + assert.equal(effects.retirements.length, 1); + assert.equal(effects.assignments.length, 0); + assert.equal( + effects.replacementAborts.get('delegation-source-action')?.reason, + lifecycle === 'archived' ? 'target_unavailable' : 'target_waiting_for_user', + ); + + effects.sessions = effects.sessions.map((candidate) => + candidate.id === 'destination' + ? { ...candidate, isArchived: false, status: 'active' } + : candidate, + ); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.retirements.length, 1); + }); + } + + test('retry records an abort when the process crashed after source retirement', async () => { + const effects = fakeEffects([session('source'), session('destination')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'e'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Wrong target', + }, + 'source-turn', + ), + ); + const snapshot = await new WorkHubCoordinationActionGate(effects).candidates(); + const destination = snapshot.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!; + const input = { + actionId: 'crashed-after-retirement', + userText: 'No, move this to destination', + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' as const }, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing' as const, + candidateRef: destination.candidateRef, + }, + }, + }; + const retireDelegation = effects.retireDelegation; + effects.retireDelegation = async (assignment) => { + await retireDelegation.call(effects, assignment); + throw new Error('simulated process exit after retirement'); + }; + + await assert.rejects(new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)); + effects.sessions = effects.sessions.map((candidate) => + candidate.id === 'destination' ? { ...candidate, isArchived: true } : candidate, + ); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'candidate_unavailable', + ); + assert.equal(effects.retirements.length, 1); + assert.equal( + effects.replacementAborts.get('delegation-source-action')?.reason, + 'target_unavailable', + ); + }); + + test('the first durable correction intent owns a delegation', async () => { + const effects = fakeEffects([session('source'), session('first'), session('second')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'c'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Start source work', + }, + 'source-turn', + ), + ); + const snapshot = await new WorkHubCoordinationActionGate(effects).candidates(); + const inputFor = (actionId: string, targetId: string) => ({ + actionId, + userText: `No, use ${targetId} instead`, + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' as const }, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates.find((candidate) => candidate.sessionId === targetId)! + .candidateRef, + }, + }, + }); + const assign = effects.assign; + effects.assign = async () => { + throw new WorkHubActionEffectFailure('internal_failure', 'hold after durable intent'); + }; + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + inputFor('first-correction', 'first'), + CONTEXT, + ), + ); + effects.assign = assign; + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + inputFor('second-correction', 'second'), + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal( + effects.replacements.get('delegation-source-action')?.actionId, + 'first-correction', + ); + }); }); function session( @@ -332,6 +1825,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { string, { input: WorkHubDelegationAssignmentInput; result: { turnId: string } } >(); + const assignmentRecords = new Map(); + const replacements = new Map(); + const replacementAborts = new Map(); + const supersessions = new Map(); return { sessions: [...initialSessions], answers: [] as Array<{ turnId: string; text: string }>, @@ -341,11 +1838,25 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { assistantText: string; }>, assignments: [] as WorkHubDelegationAssignmentInput[], + assignmentRecords, + replacements, + replacementAborts, + supersessions, + retirements: [] as WorkHubDelegationAssignedMessage[], async listSessions() { return this.sessions; }, - async readAssignment() { - return undefined; + async readAssignment(actionId: string) { + return assignmentRecords.get(actionId); + }, + async readReplacement(delegationId: string) { + return replacements.get(delegationId); + }, + async readReplacementAbort(delegationId: string) { + return replacementAborts.get(delegationId); + }, + async readSupersession(delegationId: string) { + return supersessions.get(delegationId); }, async answer(input: { turnId: string; text: string }) { this.answers.push(input); @@ -362,12 +1873,118 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { } const result = { turnId: `turn-${input.actionId}` }; durable.set(input.actionId, { input, result }); + const record = assignmentRecord(input, result.turnId); + assignmentRecords.set(input.actionId, record); + if (input.replacesDelegationId) { + supersessions.set(input.replacesDelegationId, { + type: 'workhub_coordination', + id: `superseded-${input.actionId}`, + turnId: input.actionId, + ts: 3, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + supersededActionId: input.replacesActionId!, + supersededDelegationId: input.replacesDelegationId, + replacementDelegationId: record.delegationId, + }); + } return result; }, + async prepareReplacement(input: WorkHubDelegationReplacementInput) { + const existing = replacements.get(input.replacesDelegationId); + if (existing) return existing; + const replacement: WorkHubDelegationReplacementRequestedMessage = { + type: 'workhub_coordination', + id: `replacement-${input.actionId}`, + turnId: input.actionId, + ts: 2, + schemaVersion: 2, + kind: 'delegation_replacement_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + targetSessionId: input.targetSessionId, + targetSessionName: input.targetSessionName, + disposition: input.disposition, + userText: input.userText, + ...(input.create ? { create: input.create } : {}), + replacesActionId: input.replacesActionId, + replacesDelegationId: input.replacesDelegationId, + replacedTargetSessionId: input.replacedTargetSessionId, + replacedTargetMessageId: input.replacedTargetMessageId, + }; + replacements.set(input.replacesDelegationId, replacement); + return replacement; + }, + async abortReplacement(input: WorkHubDelegationReplacementAbortInput) { + const replacement = input.replacement; + const existing = replacementAborts.get(replacement.replacesDelegationId); + if (existing) return existing; + const aborted: WorkHubDelegationReplacementAbortedMessage = { + type: 'workhub_coordination', + id: `aborted-${replacement.actionId}`, + turnId: replacement.actionId, + ts: 4, + schemaVersion: 2, + kind: 'delegation_replacement_aborted', + actionId: replacement.actionId, + actionFingerprint: replacement.actionFingerprint, + coordinationTurnId: replacement.actionId, + abortedActionId: replacement.replacesActionId, + abortedDelegationId: replacement.replacesDelegationId, + targetSessionId: replacement.targetSessionId, + reason: input.reason, + }; + replacementAborts.set(replacement.replacesDelegationId, aborted); + return aborted; + }, + async readDelegationRetirement(assignment: WorkHubDelegationAssignedMessage) { + return this.retirements.some((retired) => retired.delegationId === assignment.delegationId) + ? ('retired' as const) + : ('not_retired' as const); + }, + async retireDelegation(assignment: WorkHubDelegationAssignedMessage) { + this.retirements.push(assignment); + }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; answers: Array<{ turnId: string; text: string }>; clarifications: Array<{ turnId: string; userText: string; assistantText: string }>; assignments: WorkHubDelegationAssignmentInput[]; + assignmentRecords: Map; + replacements: Map; + replacementAborts: Map; + supersessions: Map; + retirements: WorkHubDelegationAssignedMessage[]; + }; +} + +function assignmentRecord( + input: WorkHubDelegationAssignmentInput, + targetTurnId: string, +): WorkHubDelegationAssignedMessage { + return { + type: 'workhub_coordination', + id: `assignment-${input.actionId}`, + turnId: input.actionId, + ts: 1, + schemaVersion: input.replacesDelegationId ? 2 : 1, + kind: 'delegation_assigned', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + targetSessionId: input.targetSessionId, + targetSessionName: input.targetSessionName, + targetTurnId, + targetMessageId: `message-${input.actionId}`, + delegationId: `delegation-${input.actionId}`, + disposition: input.disposition, + userText: input.userText, + ...(input.create ? { create: input.create } : {}), + ...(input.replacesActionId ? { replacesActionId: input.replacesActionId } : {}), + ...(input.replacesDelegationId ? { replacesDelegationId: input.replacesDelegationId } : {}), }; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index baa8e2d292..598f476da0 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -529,7 +529,10 @@ describe('Host WorkHub Coordination coordinator', () => { }); assert.deepEqual( (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) - .filter((message) => message.type === 'workhub_coordination') + .filter( + (message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_assigned', + ) .map(({ kind, actionId, targetSessionId }) => ({ kind, actionId, targetSessionId })), [ { @@ -711,9 +714,9 @@ function coordinator( hasRootTurnAdmission: async () => false, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), - sessionActions: Pick = { - assign: async ({ targetSessionId }) => ({ turnId: `turn-${targetSessionId}` }), - }, + sessionActions: Partial< + Pick + > = {}, ) { return new HostWorkHubCoordinationCoordinator({ stateRoot: root, @@ -721,7 +724,12 @@ function coordinator( admission, continuity: { refreshCanonical: async () => undefined }, executions, - sessionActions, + sessionActions: { + assign: async ({ targetSessionId }) => ({ turnId: `turn-${targetSessionId}` }), + readDelegationRetirement: async () => 'not_retired', + retireDelegation: async () => undefined, + ...sessionActions, + }, resolveCreateTarget: resolveCreateTarget ?? (async () => ({ diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index b188f97284..b6cbfbf3fa 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -67,6 +67,38 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () assistantText: 'Submitted to Payment', }, ); + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-correction', + userText: 'No, use login instead', + candidateSetId: `sha256:${'e'.repeat(64)}`, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: 'action-payments', + target: { disposition: 'delegate_existing', candidateRef: 'candidate_login' }, + }, + }).proposal, + { + disposition: 'replace', + replacesActionId: 'action-payments', + target: { disposition: 'delegate_existing', candidateRef: 'candidate_login' }, + }, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-unconfirmed-correction', + userText: 'Use login instead', + candidateSetId: `sha256:${'f'.repeat(64)}`, + proposal: { + disposition: 'replace', + replacesActionId: 'action-payments', + target: { disposition: 'delegate_existing', candidateRef: 'candidate_login' }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.answer'].mode, 'command'); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.record'].mode, 'command'); assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.answer'), true); @@ -248,4 +280,18 @@ test('WorkHub Coordination action results preserve the admitted disposition', () }), (error) => error instanceof RuntimeHostProtocolError, ); + assert.deepEqual( + decodeWorkHubCoordinationActResult({ + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: 'login', + targetTurnId: 'turn-login', + }), + { + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: 'login', + targetTurnId: 'turn-login', + }, + ); }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d81039d8e6..9de1adca70 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 82 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 83 as const; +// 83: WorkHub Coordination actions add linked replacement proposals, +// destructive user confirmation, and replacement results. Older peers reject +// these closed action and result shapes. // 82: Session removal reports how many linked subtasks it archived, and adds a // `session.remove.preview` query for that count before the delete. Older peers // reject the extra removed-result field and the unknown operation. diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 1af2559f70..f3859f04b1 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -124,7 +124,20 @@ export type WorkHubCoordinationProposal = readonly disposition: 'delegate_existing'; readonly candidateRef: string; } - | { readonly disposition: 'create_new'; readonly title: string }; + | { readonly disposition: 'create_new'; readonly title: string } + | { + readonly disposition: 'replace'; + /** Action identity of the exact durable delegation link being corrected. */ + readonly replacesActionId: string; + readonly target: + | { readonly disposition: 'delegate_existing'; readonly candidateRef: string } + | { readonly disposition: 'create_new'; readonly title: string }; + }; + +export interface WorkHubCoordinationDestructiveConfirmation { + /** Kept outside strategy output so a model proposal cannot authorize Stop. */ + readonly kind: 'user_correction'; +} export interface WorkHubCoordinationCreateContext { /** Trusted desktop context. Model/strategy output never contains a workspace or identity. */ @@ -137,6 +150,7 @@ export interface WorkHubCoordinationActInput { readonly proposal: WorkHubCoordinationProposal; readonly candidateSetId?: string; readonly create?: WorkHubCoordinationCreateContext; + readonly confirmation?: WorkHubCoordinationDestructiveConfirmation; } export type WorkHubCoordinationActResult = @@ -153,6 +167,13 @@ export type WorkHubCoordinationActResult = readonly targetSessionId: string; readonly targetTurnId: string; readonly steered?: true; + } + | { + readonly disposition: 'replace'; + readonly replacementDisposition: 'delegate_existing' | 'create_new'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -304,7 +325,7 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi value, 'WorkHub Coordination action input', ['actionId', 'userText', 'proposal'], - ['candidateSetId', 'create'], + ['candidateSetId', 'create', 'confirmation'], ); const proposal = decodeWorkHubCoordinationProposal(input.proposal); const base = { @@ -317,18 +338,51 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi proposal, }; if (proposal.disposition === 'delegate_existing') { - if (input.create !== undefined || input.candidateSetId === undefined) { + if ( + input.create !== undefined || + input.candidateSetId === undefined || + input.confirmation !== undefined + ) { throw invalidProtocolFrame('Invalid WorkHub delegation context'); } return { ...base, candidateSetId: candidateSetId(input.candidateSetId) }; } if (proposal.disposition === 'create_new') { - if (input.candidateSetId !== undefined || input.create === undefined) { + if ( + input.candidateSetId !== undefined || + input.create === undefined || + input.confirmation !== undefined + ) { throw invalidProtocolFrame('Invalid WorkHub creation context'); } return { ...base, create: decodeWorkHubCoordinationCreateContext(input.create) }; } - if (input.candidateSetId !== undefined || input.create !== undefined) { + if (proposal.disposition === 'replace') { + const confirmation = decodeWorkHubCoordinationDestructiveConfirmation(input.confirmation); + if (proposal.target.disposition === 'delegate_existing') { + if (input.candidateSetId === undefined || input.create !== undefined) { + throw invalidProtocolFrame('Invalid WorkHub replacement context'); + } + return { + ...base, + candidateSetId: candidateSetId(input.candidateSetId), + confirmation, + }; + } + if (input.candidateSetId !== undefined || input.create === undefined) { + throw invalidProtocolFrame('Invalid WorkHub replacement creation context'); + } + return { + ...base, + create: decodeWorkHubCoordinationCreateContext(input.create), + confirmation, + }; + } + if ( + input.candidateSetId !== undefined || + input.create !== undefined || + input.confirmation !== undefined + ) { throw invalidProtocolFrame('Unexpected WorkHub action context'); } return base; @@ -363,6 +417,30 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord ...(exact.steered === true ? { steered: true as const } : {}), }; } + if (result.disposition === 'replace') { + const exact = requireShapedRecord( + result, + 'WorkHub Coordination replacement result', + ['disposition', 'replacementDisposition', 'targetSessionId', 'targetTurnId'], + ['steered'], + ); + if ( + exact.replacementDisposition !== 'delegate_existing' && + exact.replacementDisposition !== 'create_new' + ) { + throw invalidProtocolFrame('Invalid WorkHub replacement disposition'); + } + if (exact.steered !== undefined && exact.steered !== true) { + throw invalidProtocolFrame('Invalid WorkHub Coordination steering result'); + } + return { + disposition: 'replace', + replacementDisposition: exact.replacementDisposition, + targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), + targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), + ...(exact.steered === true ? { steered: true as const } : {}), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); } @@ -425,6 +503,47 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP title: requireUtf8String(exact.title, 'WorkHub Session title', COORDINATION_TITLE_MAX_BYTES), }; } + if (proposal.disposition === 'replace') { + const exact = requireExactRecord(proposal, 'WorkHub replacement proposal', [ + 'disposition', + 'replacesActionId', + 'target', + ]); + const target = requireRecord(exact.target, 'WorkHub replacement target'); + if (target.disposition === 'delegate_existing') { + const targetExact = requireExactRecord(target, 'WorkHub replacement delegation target', [ + 'disposition', + 'candidateRef', + ]); + return { + disposition: 'replace', + replacesActionId: requireEntityId(exact.replacesActionId, 'WorkHub replaced action id'), + target: { + disposition: 'delegate_existing', + candidateRef: requireEntityId(targetExact.candidateRef, 'WorkHub candidate ref'), + }, + }; + } + if (target.disposition === 'create_new') { + const targetExact = requireExactRecord(target, 'WorkHub replacement creation target', [ + 'disposition', + 'title', + ]); + return { + disposition: 'replace', + replacesActionId: requireEntityId(exact.replacesActionId, 'WorkHub replaced action id'), + target: { + disposition: 'create_new', + title: requireUtf8String( + targetExact.title, + 'WorkHub Session title', + COORDINATION_TITLE_MAX_BYTES, + ), + }, + }; + } + throw invalidProtocolFrame('Invalid WorkHub replacement target'); + } throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } @@ -435,6 +554,16 @@ function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordina }; } +function decodeWorkHubCoordinationDestructiveConfirmation( + value: unknown, +): WorkHubCoordinationDestructiveConfirmation { + const confirmation = requireExactRecord(value, 'WorkHub destructive confirmation', ['kind']); + if (confirmation.kind !== 'user_correction') { + throw invalidProtocolFrame('Invalid WorkHub destructive confirmation'); + } + return { kind: 'user_correction' }; +} + function candidateSetId(value: unknown): string { const id = requireUtf8String(value, 'WorkHub candidate set id', CANDIDATE_SET_ID_MAX_BYTES); if (!/^sha256:[a-f0-9]{64}$/u.test(id)) { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 66fb16892e..03c9097e3c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -33,6 +33,7 @@ import { isDeepResearchSession, type SessionHeader, WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, } from '@maka/core/session'; import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-supervisor-wake'; @@ -1281,6 +1282,51 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, executions: coordinator, sessionActions: { + readDelegationRetirement: async (assignment) => { + const disposition = await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') return 'recovering'; + if (disposition.kind === 'pending') return 'not_retired'; + if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') { + return 'retired'; + } + const rootState = coordinator.readRootState(assignment.targetSessionId); + return rootState.kind === 'active' && + rootState.turnId === disposition.turnId && + rootState.runId === disposition.runId + ? 'not_retired' + : 'retired'; + }, + retireDelegation: async (assignment) => { + const disposition = await messages.cancelMessageIfPending( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') { + throw new WorkHubActionEffectFailure( + 'operation_unavailable', + 'WorkHub is still resolving the delegated Message owner', + ); + } + if (disposition.kind === 'shared_turn') return; + if (disposition.kind === 'owned_root') { + const rootState = coordinator.readRootState(assignment.targetSessionId); + if ( + rootState.kind !== 'active' || + rootState.turnId !== disposition.turnId || + rootState.runId !== disposition.runId + ) { + return; + } + await coordinator.stopRoot({ + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }); + } + }, assign: async (input) => { const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); if (durable) { @@ -1319,13 +1365,36 @@ export async function createExecutionRuntimeHostComposition( const turnId = steered ? rootState.turnId : `wht_${suffix}`; const runId = steered ? rootState.runId : `whr_${suffix}`; const assignedAt = Date.now(); + const delegationId = `whd_${suffix}`; + const supersession = + input.replacesActionId && input.replacesDelegationId + ? { + type: 'workhub_coordination' as const, + id: `whx_${createHash('sha256') + .update(input.replacesDelegationId, 'utf8') + .digest('hex') + .slice(0, 48)}`, + turnId: input.actionId, + ts: assignedAt, + schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + kind: 'delegation_superseded' as const, + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + supersededActionId: input.replacesActionId, + supersededDelegationId: input.replacesDelegationId, + replacementDelegationId: delegationId, + } + : undefined; const result = await stores.sessionStore.assignWorkHubMessage({ assignment: { type: 'workhub_coordination', id: `wha_${suffix}`, turnId: input.actionId, ts: assignedAt, - schemaVersion: 1, + schemaVersion: supersession + ? WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION + : 1, kind: 'delegation_assigned', actionId: input.actionId, actionFingerprint: input.actionFingerprint, @@ -1334,11 +1403,17 @@ export async function createExecutionRuntimeHostComposition( targetSessionName: input.targetSessionName, targetTurnId: turnId, targetMessageId: messageId, - delegationId: `whd_${suffix}`, + delegationId, disposition: input.disposition, userText: input.userText, ...(steered ? { steered: true as const } : {}), ...(input.create ? { create: input.create } : {}), + ...(input.replacesActionId && input.replacesDelegationId + ? { + replacesActionId: input.replacesActionId, + replacesDelegationId: input.replacesDelegationId, + } + : {}), }, admission: { sessionId: input.targetSessionId, @@ -1354,6 +1429,7 @@ export async function createExecutionRuntimeHostComposition( admittedAt: assignedAt, }, ...(create ? { create } : {}), + ...(supersession ? { supersession } : {}), }); // Keep the durable steering identity and its live queue owner // under one Session admission. A terminal transition must not diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 02dc46e7a2..2f483a7ee1 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -178,6 +178,16 @@ export interface HostMessageStopFence { deliverStop(): Promise; } +export type HostMessageCancellationDisposition = + | { readonly kind: 'cancelled' } + | { readonly kind: 'owned_root'; readonly turnId: string; readonly runId: string } + | { readonly kind: 'shared_turn'; readonly turnId: string; readonly runId: string } + | { readonly kind: 'recovering' }; + +export type HostMessageExecutionDisposition = + | HostMessageCancellationDisposition + | { readonly kind: 'pending' }; + /** Root execution operations that must share the message coordinator's Session gate. */ export interface HostMessageRootPort { readSessionHeader(sessionId: string): Promise; @@ -454,53 +464,133 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { | { messageId: string; state: 'owned'; turnId: string; runId: string } > = []; for (const messageId of input.messageIds) { - const receipt = await this.#durableProof.readRootTurnSourceMessageReceipt( - input.sessionId, - messageId, - ); - if ( - receipt?.admission.sessionId === input.sessionId && - receipt.sourceMessage.messageId === messageId - ) { - // A root source receipt is the latest durable ownership proof and - // therefore outranks the steering location from which a Message may - // have been folded into this successor. + const disposition = await this.#resolveMessageExecution(input.sessionId, messageId); + if (disposition.kind === 'owned_root' || disposition.kind === 'shared_turn') { resolutions.push({ messageId, state: 'owned', - turnId: receipt.admission.turnId, - runId: receipt.admission.runId, + turnId: disposition.turnId, + runId: disposition.runId, }); continue; } - const steering = await this.#durableProof.readImmutableSteeringMessageProof( - input.sessionId, - messageId, - ); - if ( - steering?.event.sessionId === input.sessionId && - steering.event.refs?.providerEventId === messageId - ) { - resolutions.push({ - messageId, - state: 'owned', - turnId: steering.event.turnId, - runId: steering.event.runId, - }); - continue; - } - if (await this.#admissions.hasCancelledMessageAdmission(input.sessionId, messageId)) { + if (disposition.kind === 'cancelled') { resolutions.push({ messageId, state: 'cancelled' }); continue; } - const pending = await this.#admissions.readMessageAdmission(input.sessionId, messageId); - if (pending?.sessionId === input.sessionId && pending.messageId === messageId) { + if (disposition.kind === 'pending') { resolutions.push({ messageId, state: 'pending' }); } } return success({ resolutions }); } + /** + * Cancels exactly one durable pending Message, or returns the Turn that has + * already consumed it. This is the target Session's ordinary Message + * authority; WorkHub never edits the queue or admission tables directly. + */ + cancelMessageIfPending( + sessionId: string, + messageId: string, + ): Promise { + return this.#sessionAdmission.run(sessionId, async () => { + const disposition = await this.#resolveMessageExecution(sessionId, messageId); + if (disposition.kind !== 'pending') return disposition; + + const state = this.#sessions.get(sessionId); + const inFlight = + state && [...state.inFlight.values()].some((entry) => entry.messageId === messageId); + if (inFlight) return { kind: 'recovering' }; + const steeringIndex = + state?.steering.findIndex((entry) => entry.messageId === messageId) ?? -1; + const followupIndex = + state?.followup.findIndex((entry) => entry.messageId === messageId) ?? -1; + if ( + state?.transition && + state.transition.entries.some((entry) => entry.messageId === messageId) + ) { + return { kind: 'recovering' }; + } + + await this.#admissions.cancelMessageAdmissions(sessionId, [messageId]); + if (state && steeringIndex >= 0) { + const [entry] = state.steering.splice(steeringIndex, 1); + if (entry) this.#releaseEntry(entry); + this.#mutated(state); + this.#maybeReclaim(sessionId, state); + } else if (state && followupIndex >= 0) { + const [entry] = state.followup.splice(followupIndex, 1); + if (entry) this.#releaseEntry(entry); + this.#mutated(state); + this.#maybeReclaim(sessionId, state); + } else { + this.#onProjectionChanged(sessionId); + } + return { kind: 'cancelled' }; + }); + } + + readMessageExecutionDisposition( + sessionId: string, + messageId: string, + ): Promise { + return this.#sessionAdmission.run(sessionId, () => + this.#resolveMessageExecution(sessionId, messageId), + ); + } + + async #resolveMessageExecution( + sessionId: string, + messageId: string, + ): Promise { + const receipt = await this.#durableProof.readRootTurnSourceMessageReceipt(sessionId, messageId); + if ( + receipt?.admission.sessionId === sessionId && + receipt.sourceMessage.messageId === messageId + ) { + // Only a single-source admission proves that this Message created the + // root. Recovery may fold several steering Messages into one successor; + // every source in that batch shares the Turn and none may stop it alone. + if ( + receipt.admission.sourceMessages.length !== 1 || + receipt.admission.userMessageId === null + ) { + return { + kind: 'shared_turn', + turnId: receipt.admission.turnId, + runId: receipt.admission.runId, + }; + } + return { + kind: 'owned_root', + turnId: receipt.admission.turnId, + runId: receipt.admission.runId, + }; + } + const steering = await this.#durableProof.readImmutableSteeringMessageProof( + sessionId, + messageId, + ); + if ( + steering?.event.sessionId === sessionId && + steering.event.refs?.providerEventId === messageId + ) { + return { + kind: 'shared_turn', + turnId: steering.event.turnId, + runId: steering.event.runId, + }; + } + if (await this.#admissions.hasCancelledMessageAdmission(sessionId, messageId)) { + return { kind: 'cancelled' }; + } + const pending = await this.#admissions.readMessageAdmission(sessionId, messageId); + return pending?.sessionId === sessionId && pending.messageId === messageId + ? { kind: 'pending' } + : { kind: 'recovering' }; + } + retireSessions(sessionIds: readonly string[]): void { for (const sessionId of new Set(sessionIds)) { const state = this.#sessions.get(sessionId); diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 4069522ce2..18e8d05e56 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -247,6 +247,7 @@ export class HostSessionCatalogCoordinator { ...(workspace.projectId === null ? {} : { projectId: workspace.projectId }), name: prepared.name, labels: [...prepared.labels], + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, ...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }), diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index dfab6e260a..181f422d3c 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -24,11 +24,19 @@ import type { WorkHubDelegationAssignedMessage, WorkHubDelegationCreateSpec, WorkHubDelegationDisposition, + WorkHubDelegationReplacementAbortedMessage, + WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { WORKHUB_COORDINATION_SESSION_ID, isWorkHubCoordinationSessionTarget, } from '@maka/core/session'; +import { + readWorkHubRequestIntent, + workHubCorrectionTargetsSession, + workHubCreationAuthorizesTitle, +} from '@maka/core/workhub-creation-intent'; import type { WorkHubCoordinationActInput, WorkHubCoordinationActResult, @@ -62,6 +70,13 @@ export type WorkHubActionGateSession = Pick< export interface WorkHubActionGateEffects { listSessions(): Promise; readAssignment(actionId: string): Promise; + readReplacement( + delegationId: string, + ): Promise; + readReplacementAbort( + delegationId: string, + ): Promise; + readSupersession(delegationId: string): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -75,6 +90,16 @@ export interface WorkHubActionGateEffects { input: WorkHubDelegationAssignmentInput, context: ConnectionContext, ): Promise<{ readonly turnId: string; readonly steered?: true }>; + prepareReplacement( + input: WorkHubDelegationReplacementInput, + ): Promise; + abortReplacement( + input: WorkHubDelegationReplacementAbortInput, + ): Promise; + readDelegationRetirement( + assignment: WorkHubDelegationAssignedMessage, + ): Promise<'not_retired' | 'retired' | 'recovering'>; + retireDelegation(assignment: WorkHubDelegationAssignedMessage): Promise; } export interface WorkHubDelegationAssignmentInput { @@ -85,6 +110,20 @@ export interface WorkHubDelegationAssignmentInput { readonly disposition: WorkHubDelegationDisposition; readonly userText: string; readonly create?: WorkHubDelegationCreateSpec; + readonly replacesActionId?: string; + readonly replacesDelegationId?: string; +} + +export interface WorkHubDelegationReplacementInput extends WorkHubDelegationAssignmentInput { + readonly replacesActionId: string; + readonly replacesDelegationId: string; + readonly replacedTargetSessionId: string; + readonly replacedTargetMessageId: string; +} + +export interface WorkHubDelegationReplacementAbortInput { + readonly replacement: WorkHubDelegationReplacementRequestedMessage; + readonly reason: WorkHubDelegationReplacementAbortedMessage['reason']; } export type WorkHubActionEffectFailureCode = @@ -201,14 +240,31 @@ export class WorkHubCoordinationActionGate { context: ConnectionContext, ): Promise { const proposal = input.proposal; + const requestIntent = readWorkHubRequestIntent(input.userText); + if ( + requestIntent.execution === 'ambiguous' && + proposal.disposition !== 'answer_here' && + proposal.disposition !== 'clarify' + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub cannot write from an ambiguous instruction', + ); + } const durable = await this.#effects.readAssignment(input.actionId); if (durable) { - if (durable.actionFingerprint !== fingerprint) { + const replayFingerprint = durable.replacesActionId + ? replacementActionFingerprint(input, durable.targetSessionId) + : fingerprint; + if (durable.actionFingerprint !== replayFingerprint) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub action identity belongs to a different proposal', ); } + if (durable.replacesActionId) { + await this.#assertReplacementReplayTarget(input, durable.targetSessionId); + } return this.#assign(assignmentInputFromRecord(durable), context); } if (proposal.disposition === 'answer_here') { @@ -226,10 +282,10 @@ export class WorkHubCoordinationActionGate { return { disposition: 'clarify', coordinationTurnId: turnId }; } if (proposal.disposition === 'create_new') { - if (!input.create) { + if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub creation context is unavailable', + 'WorkHub creation requires an unambiguous instruction and creation context', ); } const sessionId = workHubCreatedSessionId(input.actionId); @@ -239,6 +295,58 @@ export class WorkHubCoordinationActionGate { ); } + if (proposal.disposition === 'replace') { + if (input.confirmation?.kind !== 'user_correction' || !requestIntent.correction.cue) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement requires explicit correction in trusted user text', + ); + } + const replaced = await this.#effects.readAssignment(proposal.replacesActionId); + if (!replaced) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub can replace only a durable delegation it owns', + ); + } + if (await this.#effects.readSupersession(replaced.delegationId)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation has already been superseded', + ); + } + const prepared = await this.#effects.readReplacement(replaced.delegationId); + if (prepared) { + if ( + !isExplicitWorkHubCorrectionText( + input.userText, + prepared.disposition, + prepared.targetSessionName, + ) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement target is not affirmed in trusted user text', + ); + } + if ( + prepared.actionId !== input.actionId || + prepared.actionFingerprint !== + replacementActionFingerprint(input, prepared.targetSessionId) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation already has a different replacement intent', + ); + } + await this.#assertReplacementReplayTarget(input, prepared.targetSessionId); + return this.#replace(prepared, context); + } + const replacement = await this.#replacementAssignment(input, replaced); + const intent = await this.#effects.prepareReplacement(replacement); + return this.#replace(intent, context); + } + const candidates = await this.candidates(); if (candidates.candidateSetId !== input.candidateSetId) { throw new WorkHubActionGateFailure( @@ -263,11 +371,242 @@ export class WorkHubCoordinationActionGate { ); } + async #replacementAssignment( + input: WorkHubCoordinationActInput, + replaced: WorkHubDelegationAssignedMessage, + ): Promise { + if (input.proposal.disposition !== 'replace') { + throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub replacement'); + } + const target = input.proposal.target; + if (target.disposition === 'create_new') { + if (!isExplicitWorkHubCorrectionText(input.userText, 'create_new', target.title)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement creation requires explicit non-negated user intent', + ); + } + if (!input.create) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement creation context is unavailable', + ); + } + const targetSessionId = workHubCreatedSessionId(input.actionId); + return { + actionId: input.actionId, + actionFingerprint: replacementActionFingerprint(input, targetSessionId), + targetSessionId, + targetSessionName: target.title, + disposition: 'create_new', + userText: input.userText, + create: { title: target.title, workspace: input.create.workspace }, + replacesActionId: replaced.actionId, + replacesDelegationId: replaced.delegationId, + replacedTargetSessionId: replaced.targetSessionId, + replacedTargetMessageId: replaced.targetMessageId, + }; + } + const candidates = await this.candidates(); + if (candidates.candidateSetId !== input.candidateSetId) { + throw new WorkHubActionGateFailure( + 'candidate_set_stale', + 'WorkHub Session candidates changed; refresh before replacing', + ); + } + const destination = candidates.candidates.find( + (candidate) => candidate.candidateRef === target.candidateRef, + ); + if (!destination) { + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub replacement target is not in the admitted candidate set', + ); + } + this.#assertTarget(destination); + if ( + !isExplicitWorkHubCorrectionText(input.userText, 'delegate_existing', destination.sessionName) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement target is not affirmed in trusted user text', + ); + } + if (destination.sessionId === replaced.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement must choose a different Session', + ); + } + return { + actionId: input.actionId, + actionFingerprint: replacementActionFingerprint(input, destination.sessionId), + targetSessionId: destination.sessionId, + targetSessionName: destination.sessionName, + disposition: 'delegate_existing', + userText: input.userText, + replacesActionId: replaced.actionId, + replacesDelegationId: replaced.delegationId, + replacedTargetSessionId: replaced.targetSessionId, + replacedTargetMessageId: replaced.targetMessageId, + }; + } + + async #replace( + replacement: WorkHubDelegationReplacementRequestedMessage, + context: ConnectionContext, + ): Promise { + const aborted = await this.#effects.readReplacementAbort(replacement.replacesDelegationId); + if (aborted) { + if ( + aborted.actionId !== replacement.actionId || + aborted.actionFingerprint !== replacement.actionFingerprint + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation replacement has a different terminal outcome', + ); + } + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement was aborted after its source delegation retired', + ); + } + const superseded = await this.#effects.readSupersession(replacement.replacesDelegationId); + if (superseded) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation has already been superseded', + ); + } + const source = await this.#effects.readAssignment(replacement.replacesActionId); + if ( + !source || + source.delegationId !== replacement.replacesDelegationId || + source.targetSessionId !== replacement.replacedTargetSessionId || + source.targetMessageId !== replacement.replacedTargetMessageId + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement source identity changed', + ); + } + let assignment = assignmentInputFromReplacement(replacement); + const retirement = await this.#effects.readDelegationRetirement(source); + if (retirement === 'recovering') { + throw new WorkHubActionEffectFailure( + 'operation_unavailable', + 'WorkHub is still resolving the delegated Message owner', + ); + } + if (retirement === 'not_retired' && replacement.disposition === 'delegate_existing') { + await this.#replacementTarget(replacement); + } + if (retirement === 'not_retired') await this.#effects.retireDelegation(source); + try { + if (replacement.disposition === 'delegate_existing') { + // Retirement can await cancellation or Stop long enough for display + // metadata or lifecycle state to change. Re-read after that destructive + // boundary and carry the current name into SQLite's identity guard. + const target = await this.#replacementTarget(replacement); + assignment = { ...assignment, targetSessionName: target.name }; + } + return await this.#assign(assignment, context); + } catch (error) { + // An assignment may have committed before its caller observed a transport + // or projection failure. Never append an abort beside a real supersession. + const [durable, supersession] = await Promise.all([ + this.#effects.readAssignment(replacement.actionId), + this.#effects.readSupersession(replacement.replacesDelegationId), + ]); + if (durable && supersession?.replacementDelegationId === durable.delegationId) { + return this.#assign(assignmentInputFromRecord(durable), context); + } + const reason = + replacement.disposition === 'delegate_existing' + ? await this.#replacementAbortReason(replacement) + : 'target_unavailable'; + if (reason) await this.#effects.abortReplacement({ replacement, reason }); + throw error; + } + } + + async #assertReplacementReplayTarget( + input: WorkHubCoordinationActInput, + targetSessionId: string, + ): Promise { + if ( + input.proposal.disposition !== 'replace' || + input.proposal.target.disposition !== 'delegate_existing' || + input.candidateSetId === undefined + ) { + return; + } + const candidateRef = input.proposal.target.candidateRef; + const candidates = await this.candidates(); + if (candidates.candidateSetId !== input.candidateSetId) return; + const proposed = candidates.candidates.find( + (candidate) => candidate.candidateRef === candidateRef, + ); + if (proposed && proposed.sessionId !== targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement replay cannot change its durable target Session', + ); + } + } + + async #replacementTarget( + replacement: WorkHubDelegationReplacementRequestedMessage, + ): Promise { + const target = (await this.#effects.listSessions()).find( + (session) => session.id === replacement.targetSessionId, + ); + if (!target || !isCandidateSession(target)) { + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub replacement target is unavailable', + ); + } + this.#assertTarget({ + candidateRef: 'prepared', + sessionId: target.id, + sessionName: target.name, + workspace: workspaceProjection(target), + state: candidateState(target.status), + updatedAt: updatedAt(target), + }); + return target; + } + + async #replacementAbortReason( + replacement: WorkHubDelegationReplacementRequestedMessage, + ): Promise { + try { + await this.#replacementTarget(replacement); + return undefined; + } catch (error) { + if (!(error instanceof WorkHubActionGateFailure)) return undefined; + if (error.code === 'candidate_unavailable') return 'target_unavailable'; + if (error.code === 'target_waiting_for_user') return 'target_waiting_for_user'; + return undefined; + } + } + async #assign( assignment: WorkHubDelegationAssignmentInput, context: ConnectionContext, ): Promise { const admitted = await this.#effects.assign(assignment, context); + if (assignment.replacesDelegationId) { + return { + disposition: 'replace', + replacementDisposition: assignment.disposition, + targetSessionId: assignment.targetSessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } return { disposition: assignment.disposition, targetSessionId: assignment.targetSessionId, @@ -414,6 +753,9 @@ function actionFingerprint(input: WorkHubCoordinationActInput): `sha256:${string return digest({ userText: input.userText, disposition: input.proposal.disposition, + ...(input.proposal.disposition === 'delegate_existing' + ? { candidateRef: input.proposal.candidateRef } + : {}), ...(input.proposal.disposition === 'create_new' ? { title: input.proposal.title, @@ -423,6 +765,36 @@ function actionFingerprint(input: WorkHubCoordinationActInput): `sha256:${string ...(input.proposal.disposition === 'clarify' ? { assistantText: input.proposal.assistantText } : {}), + ...(input.proposal.disposition === 'replace' + ? { + replacesActionId: input.proposal.replacesActionId, + target: input.proposal.target, + ...(input.proposal.target.disposition === 'create_new' + ? { workspace: input.create?.workspace } + : {}), + } + : {}), + }); +} + +function replacementActionFingerprint( + input: WorkHubCoordinationActInput, + targetSessionId: string, +): `sha256:${string}` { + if (input.proposal.disposition !== 'replace') { + throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub replacement replay'); + } + return digest({ + userText: input.userText, + disposition: input.proposal.disposition, + replacesActionId: input.proposal.replacesActionId, + target: { + disposition: input.proposal.target.disposition, + targetSessionId, + ...(input.proposal.target.disposition === 'create_new' + ? { title: input.proposal.target.title, workspace: input.create?.workspace } + : {}), + }, }); } @@ -437,9 +809,57 @@ function assignmentInputFromRecord( disposition: assignment.disposition, userText: assignment.userText, ...(assignment.create ? { create: assignment.create } : {}), + ...(assignment.replacesActionId && assignment.replacesDelegationId + ? { + replacesActionId: assignment.replacesActionId, + replacesDelegationId: assignment.replacesDelegationId, + } + : {}), + }; +} + +function assignmentInputFromReplacement( + replacement: WorkHubDelegationReplacementRequestedMessage, +): WorkHubDelegationAssignmentInput { + return { + actionId: replacement.actionId, + actionFingerprint: replacement.actionFingerprint, + targetSessionId: replacement.targetSessionId, + targetSessionName: replacement.targetSessionName, + disposition: replacement.disposition, + userText: replacement.userText, + ...(replacement.create ? { create: replacement.create } : {}), + replacesActionId: replacement.replacesActionId, + replacesDelegationId: replacement.replacesDelegationId, }; } function hash(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex'); } + +/** + * Destructive replacement needs evidence in the user-originated text itself; + * a strategy-provided replacement proposal and confirmation marker are not + * sufficient authority. Keep this deliberately narrower than route inference. + */ +export function isExplicitWorkHubCorrectionText( + value: string, + targetDisposition: WorkHubDelegationDisposition, + targetSessionName?: string, +): boolean { + const intent = readWorkHubRequestIntent(value); + if (targetDisposition === 'create_new') { + return Boolean( + intent.correction.cue && + intent.creation.explicit && + targetSessionName && + workHubCreationAuthorizesTitle(intent, targetSessionName), + ); + } + return Boolean( + targetSessionName && + intent.correction.cue && + workHubCorrectionTargetsSession(intent, targetSessionName), + ); +} diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 065b1806bb..8e66215953 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -20,15 +20,19 @@ import { createHash } from 'node:crypto'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import { normalizeMessageContent } from '@maka/core/events'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, + WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, isWorkHubCoordinationSession, isWorkHubCoordinationSessionId, type SessionHeader, type StoredMessage, + type WorkHubDelegationReplacementAbortedMessage, + type WorkHubDelegationReplacementRequestedMessage, } from '@maka/core/session'; import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { @@ -83,6 +87,9 @@ type CoordinationStores = Pick< | 'probeStableSessionCreate' | 'readHeaderSnapshot' | 'readWorkHubAssignment' + | 'readWorkHubReplacement' + | 'readWorkHubReplacementAbort' + | 'readWorkHubSupersession' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'updateHeaderVersioned' @@ -101,7 +108,10 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick; + readonly sessionActions: Pick< + WorkHubActionGateEffects, + 'assign' | 'readDelegationRetirement' | 'retireDelegation' + >; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -136,6 +146,10 @@ export class HostWorkHubCoordinationCoordinator { this.#actionGate = new WorkHubCoordinationActionGate({ listSessions: () => this.#stores.listHeaders(), readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId), + readReplacement: (delegationId) => this.#stores.readWorkHubReplacement(delegationId), + readReplacementAbort: (delegationId) => + this.#stores.readWorkHubReplacementAbort(delegationId), + readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -153,6 +167,121 @@ export class HostWorkHubCoordinationCoordinator { } }, assign: options.sessionActions.assign, + prepareReplacement: (input) => this.#prepareReplacement(input), + abortReplacement: (input) => this.#abortReplacement(input), + readDelegationRetirement: options.sessionActions.readDelegationRetirement, + retireDelegation: options.sessionActions.retireDelegation, + }); + } + + #prepareReplacement( + input: Parameters[0], + ): Promise { + const suffix = workHubReplacementIdentitySuffix(input.replacesDelegationId); + return this.#commitReplacementFact({ + read: () => this.#stores.readWorkHubReplacement(input.replacesDelegationId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whp_${suffix}`, + turnId: input.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + kind: 'delegation_replacement_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + targetSessionId: input.targetSessionId, + targetSessionName: input.targetSessionName, + disposition: input.disposition, + userText: input.userText, + replacesActionId: input.replacesActionId, + replacesDelegationId: input.replacesDelegationId, + replacedTargetSessionId: input.replacedTargetSessionId, + replacedTargetMessageId: input.replacedTargetMessageId, + ...(input.create ? { create: input.create } : {}), + }), + conflictMessage: 'WorkHub action identity belongs to a different replacement', + beforeAppend: async () => { + const header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); + if (!validCoordinationHeader(header)) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub Coordination Session identity is unavailable', + ); + } + }, + unknownOutcomeMessage: 'WorkHub replacement intent outcome is unknown', + }); + } + + #abortReplacement( + input: Parameters[0], + ): Promise { + const replacement = input.replacement; + const suffix = workHubReplacementIdentitySuffix(replacement.replacesDelegationId); + return this.#commitReplacementFact({ + read: () => this.#stores.readWorkHubReplacementAbort(replacement.replacesDelegationId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whb_${suffix}`, + turnId: replacement.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + kind: 'delegation_replacement_aborted', + actionId: replacement.actionId, + actionFingerprint: replacement.actionFingerprint, + coordinationTurnId: replacement.actionId, + abortedActionId: replacement.replacesActionId, + abortedDelegationId: replacement.replacesDelegationId, + targetSessionId: replacement.targetSessionId, + reason: input.reason, + }), + conflictMessage: 'WorkHub replacement already has a different abort outcome', + beforeAppend: async () => { + const supersession = await this.#stores.readWorkHubSupersession( + replacement.replacesDelegationId, + ); + if (supersession) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement already committed its supersession', + ); + } + }, + unknownOutcomeMessage: 'WorkHub replacement abort outcome is unknown', + }); + } + + #commitReplacementFact(options: { + readonly read: () => Promise; + readonly build: (existing: T | undefined) => T; + readonly conflictMessage: string; + readonly beforeAppend: () => Promise; + readonly unknownOutcomeMessage: string; + }): Promise { + return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { + const existing = await options.read(); + const requested = options.build(existing); + if (existing) { + if (!isDeepStrictEqual(existing, requested)) { + throw new WorkHubActionGateFailure('action_conflict', options.conflictMessage); + } + return existing; + } + await options.beforeAppend(); + try { + await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [requested]); + await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); + return requested; + } catch { + const replay = await options.read().catch(() => undefined); + if (replay && isDeepStrictEqual(replay, requested)) return replay; + this.#requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + options.unknownOutcomeMessage, + ); + } }); } @@ -498,6 +627,10 @@ function digest(value: unknown): `sha256:${string}` { return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; } +function workHubReplacementIdentitySuffix(delegationId: string): string { + return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48); +} + function coordinationSummaryMessageId( turnId: string, kind: (typeof COORDINATION_SUMMARY_MESSAGE_KINDS)[number], diff --git a/packages/storage/src/__tests__/workhub-message-assignment.test.ts b/packages/storage/src/__tests__/workhub-message-assignment.test.ts index be317b800e..27828855ab 100644 --- a/packages/storage/src/__tests__/workhub-message-assignment.test.ts +++ b/packages/storage/src/__tests__/workhub-message-assignment.test.ts @@ -28,6 +28,8 @@ import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, type WorkHubDelegationAssignedMessage, + type WorkHubDelegationReplacementAbortedMessage, + type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { createSessionStore, isSessionNotFoundError } from '../session-store.js'; @@ -135,6 +137,201 @@ test('rolls create_new Session back when assignment validation fails', async () } }); +test('rejects a stale display identity for a new delegation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stale-assignment-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const request = assignmentRequest('stale-action', target.id, 'Old name', 'target-turn'); + + await assert.rejects(store.assignWorkHubMessage(request), /display identity changed/u); + assert.equal(await store.readWorkHubAssignment(request.assignment.actionId), undefined); + assert.equal( + await store.readMessageAdmission(target.id, request.assignment.targetMessageId), + undefined, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('atomically commits a replacement assignment with the old-link supersession', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-replacement-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const source = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const destination = await store.create({ + cwd: root, + name: 'Login before rename', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const original = assignmentRequest('original-action', source.id, 'Payments', 'source-turn'); + await store.assignWorkHubMessage(original); + + const base = assignmentRequest( + 'replacement-action', + destination.id, + 'Login before rename', + 'destination-turn', + ); + const assignment: WorkHubDelegationAssignedMessage = { + ...base.assignment, + schemaVersion: 2, + replacesActionId: original.assignment.actionId, + replacesDelegationId: original.assignment.delegationId, + }; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48)}`, + turnId: assignment.actionId, + ts: assignment.ts, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + supersededActionId: original.assignment.actionId, + supersededDelegationId: original.assignment.delegationId, + replacementDelegationId: assignment.delegationId, + }; + await store.rename(destination.id, 'Login'); + + const committed = await store.assignWorkHubMessage({ + ...base, + assignment, + supersession, + }); + const committedAssignment = { ...assignment, targetSessionName: 'Login' }; + + assert.equal(committed.kind, 'assigned'); + assert.deepEqual(committed.assignment, committedAssignment); + assert.deepEqual( + await store.readWorkHubSupersession(original.assignment.delegationId), + supersession, + ); + assert.deepEqual(await store.readWorkHubAssignment(assignment.actionId), committedAssignment); + assert.deepEqual( + await store.readMessageAdmission(destination.id, assignment.targetMessageId), + base.admission, + ); + assert.deepEqual( + (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) + .filter((message) => message.type === 'workhub_coordination') + .map((message) => message.kind), + ['delegation_assigned', 'delegation_assigned', 'delegation_superseded'], + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('an aborted replacement cannot later commit a supersession', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-aborted-replacement-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const source = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const destination = await store.create({ + cwd: root, + name: 'Login', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const original = assignmentRequest('original-aborted', source.id, 'Payments', 'source-turn'); + await store.assignWorkHubMessage(original); + const base = assignmentRequest( + 'replacement-after-abort', + destination.id, + 'Login', + 'destination-turn', + ); + const assignment: WorkHubDelegationAssignedMessage = { + ...base.assignment, + schemaVersion: 2, + replacesActionId: original.assignment.actionId, + replacesDelegationId: original.assignment.delegationId, + }; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48)}`, + turnId: assignment.actionId, + ts: assignment.ts, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + supersededActionId: original.assignment.actionId, + supersededDelegationId: original.assignment.delegationId, + replacementDelegationId: assignment.delegationId, + }; + const abort: WorkHubDelegationReplacementAbortedMessage = { + type: 'workhub_coordination', + id: `whb_${createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48)}`, + turnId: assignment.actionId, + ts: assignment.ts - 1, + schemaVersion: 2, + kind: 'delegation_replacement_aborted', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + abortedActionId: original.assignment.actionId, + abortedDelegationId: original.assignment.delegationId, + targetSessionId: destination.id, + reason: 'target_unavailable', + }; + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [abort]); + + await assert.rejects( + store.assignWorkHubMessage({ ...base, assignment, supersession }), + /replacement is aborted/u, + ); + assert.equal(await store.readWorkHubAssignment(assignment.actionId), undefined); + assert.equal(await store.readWorkHubSupersession(original.assignment.delegationId), undefined); + assert.equal( + await store.readMessageAdmission(destination.id, assignment.targetMessageId), + undefined, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + async function createCoordinationSession( store: ReturnType, root: string, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 5a9c18c4b7..dc62748e68 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -363,6 +363,12 @@ async function createExecutionStoresForWrite sessionStore.createStableSession(request, initialBoundary)), assignWorkHubMessage: (request) => run(() => sessionStore.assignWorkHubMessage(request)), readWorkHubAssignment: (actionId) => run(() => sessionStore.readWorkHubAssignment(actionId)), + readWorkHubReplacement: (delegationId) => + run(() => sessionStore.readWorkHubReplacement(delegationId)), + readWorkHubReplacementAbort: (delegationId) => + run(() => sessionStore.readWorkHubReplacementAbort(delegationId)), + readWorkHubSupersession: (delegationId) => + run(() => sessionStore.readWorkHubSupersession(delegationId)), discardStableConversationCopy: (sessionId, requestFingerprint) => run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)), createSubagent: (input, initialBoundary) => diff --git a/packages/storage/src/session-message-projection.ts b/packages/storage/src/session-message-projection.ts index 581ba6cc75..5284e5720a 100644 --- a/packages/storage/src/session-message-projection.ts +++ b/packages/storage/src/session-message-projection.ts @@ -71,7 +71,7 @@ export function lastMessagePreviewForMessages( if (text) return truncatePreview(text); } if (message.type === 'workhub_coordination') { - const text = normalizePreviewText(message.userText); + const text = 'userText' in message ? normalizePreviewText(message.userText) : ''; if (text) return truncatePreview(text); } } diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 55eed4d1d4..1831eb21b9 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -84,6 +84,9 @@ import { type TurnStateMessage, type UserMessage, type WorkHubDelegationAssignedMessage, + type WorkHubDelegationReplacementAbortedMessage, + type WorkHubDelegationReplacementRequestedMessage, + type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { MarkMessagesHandedOffInput, @@ -184,6 +187,8 @@ export interface CreateStableSessionRequest { export interface WorkHubMessageAssignmentRequest { readonly assignment: WorkHubDelegationAssignedMessage; readonly admission: PendingMessageAdmission; + /** Present exactly when this assignment atomically supersedes an earlier link. */ + readonly supersession?: WorkHubDelegationSupersededMessage; /** Present exactly when the assignment creates its target Session. */ readonly create?: CreateStableSessionRequest; } @@ -416,6 +421,15 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto request: WorkHubMessageAssignmentRequest, ): Promise; readWorkHubAssignment(actionId: string): Promise; + readWorkHubReplacement( + delegationId: string, + ): Promise; + readWorkHubReplacementAbort( + delegationId: string, + ): Promise; + readWorkHubSupersession( + delegationId: string, + ): Promise; discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise; listCatalogPage( filter: SessionListFilter | undefined, @@ -634,6 +648,7 @@ class SqliteSessionStore implements SessionAuthorityStore { assignment: request.assignment, admission: request.admission, projection: projectSessionCatalogMessages([request.assignment]), + ...(request.supersession ? { supersession: request.supersession } : {}), ...(create ? { create: { @@ -659,22 +674,64 @@ class SqliteSessionStore implements SessionAuthorityStore { async readWorkHubAssignment( actionId: string, ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `wha_${workHubIdentitySuffix(actionId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_assigned' + ? message + : undefined; + } + + async readWorkHubReplacement( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whp_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && + message.kind === 'delegation_replacement_requested' + ? message + : undefined; + } + + async readWorkHubReplacementAbort( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whb_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && + message.kind === 'delegation_replacement_aborted' + ? message + : undefined; + } + + async readWorkHubSupersession( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whx_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_superseded' + ? message + : undefined; + } + + private async readWorkHubCoordinationMessage( + messageId: string, + ): Promise { await this.ensureReady(); - const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); const throughSequence = await this.metadata.readTranscriptHighWater( WORKHUB_COORDINATION_SESSION_ID, ); if (throughSequence === null) return undefined; const messages = await this.metadata.readTranscriptMessages(WORKHUB_COORDINATION_SESSION_ID, { - messageIds: [`wha_${suffix}`], + messageIds: [messageId], throughSequence, maxMessages: 1, maxBytes: 768 * 1024, }); - const message = messages[0]; - return message?.type === 'workhub_coordination' && message.kind === 'delegation_assigned' - ? message - : undefined; + return messages[0]; } async discardStableConversationCopy( @@ -1199,6 +1256,10 @@ class SqliteSessionStore implements SessionAuthorityStore { } } +function workHubIdentitySuffix(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 48); +} + /** * The reserved identity and the reserved role are one fact, and the invariant * belongs to every creator that builds a header — subagents and Agent Graph diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 5c42ae80c0..b4e3c037a5 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 35; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 36; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1233,6 +1233,15 @@ const MIGRATIONS: ReadonlyMap = new Map([ DEFAULT '{"loaded":[],"failed":[],"receipts":[]}'; `, ], + [ + 36, + ` + -- WorkHub replacement intent and atomic supersession records require the + -- schema-v2 canonical message decoder. Prevent older builds from opening + -- a profile after either record has been committed. + SELECT 1; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index b403ae3461..770147b5d3 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -93,6 +93,7 @@ import { type StoredMessage, type SubagentSessionParent, type WorkHubDelegationAssignedMessage, + type WorkHubDelegationSupersededMessage, WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, decodeCanonicalMessage, @@ -226,6 +227,7 @@ export interface SqliteWorkHubMessageAssignmentRequest { readonly assignment: WorkHubDelegationAssignedMessage; readonly admission: PendingMessageAdmission; readonly projection: SessionCatalogMessageProjection; + readonly supersession?: WorkHubDelegationSupersededMessage; readonly create?: { readonly header: SessionHeader; readonly requestFingerprint: string; @@ -1694,6 +1696,12 @@ export class SqliteSessionMetadataStore { ): Promise { const assignmentJson = JSON.stringify(request.assignment); const assignment = decodeCanonicalMessage(JSON.parse(assignmentJson) as unknown); + const supersessionJson = request.supersession + ? JSON.stringify(request.supersession) + : undefined; + const supersession = supersessionJson + ? decodeCanonicalMessage(JSON.parse(supersessionJson) as unknown) + : undefined; const admission = normalizePendingMessageAdmission(request.admission); const suffix = createHash('sha256') .update(request.assignment.actionId) @@ -1719,6 +1727,28 @@ export class SqliteSessionMetadataStore { ) { throw new SessionMetadataConflictError('Invalid WorkHub assignment identity'); } + if ( + (assignment.replacesActionId === undefined) !== + (assignment.replacesDelegationId === undefined) || + (assignment.replacesDelegationId === undefined) !== (supersession === undefined) || + (supersession !== undefined && + (supersession.type !== 'workhub_coordination' || + supersession.kind !== 'delegation_superseded' || + supersession.actionId !== assignment.actionId || + supersession.actionFingerprint !== assignment.actionFingerprint || + supersession.coordinationTurnId !== assignment.coordinationTurnId || + supersession.turnId !== assignment.coordinationTurnId || + supersession.supersededActionId !== assignment.replacesActionId || + supersession.supersededDelegationId !== assignment.replacesDelegationId || + supersession.replacementDelegationId !== assignment.delegationId || + supersession.id !== + `whx_${createHash('sha256') + .update(supersession.supersededDelegationId) + .digest('hex') + .slice(0, 48)}`)) + ) { + throw new SessionMetadataConflictError('Invalid WorkHub supersession identity'); + } const create = request.create ? { header: normalizeSessionHeader(request.create.header), @@ -1769,6 +1799,42 @@ export class SqliteSessionMetadataStore { }; } + if (supersession && assignment.replacesActionId && assignment.replacesDelegationId) { + const replacedSuffix = createHash('sha256') + .update(assignment.replacesActionId) + .digest('hex') + .slice(0, 48); + const replaced = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `wha_${replacedSuffix}`, + ); + if ( + replaced?.type !== 'workhub_coordination' || + replaced.kind !== 'delegation_assigned' || + replaced.delegationId !== assignment.replacesDelegationId + ) { + throw new SessionMetadataConflictError('WorkHub supersession source is unavailable'); + } + const abortSuffix = createHash('sha256') + .update(assignment.replacesDelegationId) + .digest('hex') + .slice(0, 48); + const existingAbort = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whb_${abortSuffix}`, + ); + if (existingAbort) { + throw new SessionMetadataConflictError('WorkHub delegation replacement is aborted'); + } + const existingSupersession = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + supersession.id, + ); + if (existingSupersession) { + throw new SessionMetadataConflictError('WorkHub delegation is already superseded'); + } + } + let targetCreated = false; if (create) { const probe = this.probeStableSessionCreateSync( @@ -1802,8 +1868,21 @@ export class SqliteSessionMetadataStore { if (target.header.status === 'waiting_for_user') { throw new SessionMetadataConflictError('WorkHub target Session is waiting for user input'); } + let committedAssignment = assignment; + let committedAssignmentJson = assignmentJson; if (target.header.name !== assignment.targetSessionName) { - throw new SessionMetadataConflictError('WorkHub target display identity changed'); + if ( + assignment.disposition !== 'delegate_existing' || + assignment.replacesDelegationId === undefined + ) { + throw new SessionMetadataConflictError('WorkHub target display identity changed'); + } + // A durable replacement owns the target Session id before retiring the + // source. Canonicalize its display-only name at the same transaction + // boundary that validates the target so a concurrent rename cannot + // strand the already-retired delegation. + committedAssignment = { ...assignment, targetSessionName: target.header.name }; + committedAssignmentJson = JSON.stringify(committedAssignment); } if (this.readMessageAdmissionSync(admission.sessionId, admission.messageId)) { throw new SessionMetadataConflictError( @@ -1827,10 +1906,15 @@ export class SqliteSessionMetadataStore { this.insertSessionMessagesSync( WORKHUB_COORDINATION_SESSION_ID, sequenceRow.last_sequence + 1, - [{ message: assignment, json: assignmentJson }], + [ + { message: committedAssignment, json: committedAssignmentJson }, + ...(supersession && supersessionJson + ? [{ message: supersession, json: supersessionJson }] + : []), + ], ); this.updateCatalogProjectionSync(WORKHUB_COORDINATION_SESSION_ID, request.projection, false); - return { kind: 'assigned' as const, targetCreated, assignment }; + return { kind: 'assigned' as const, targetCreated, assignment: committedAssignment }; }); } @@ -6829,6 +6913,8 @@ function sameWorkHubAssignmentRequest( disposition: existing.disposition, userText: existing.userText, create: existing.create, + replacesActionId: existing.replacesActionId, + replacesDelegationId: existing.replacesDelegationId, }, { actionId: requested.actionId, @@ -6838,6 +6924,8 @@ function sameWorkHubAssignmentRequest( disposition: requested.disposition, userText: requested.userText, create: requested.create, + replacesActionId: requested.replacesActionId, + replacesDelegationId: requested.replacesDelegationId, }, ); }