diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index d2d1c38c8c..bbc6bbad36 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -247,10 +247,19 @@ describe('permission response IPC boundary', () => { it('accepts only the supported stop source', () => { assert.deepEqual(normalizeStopSessionInput(undefined), {}); - assert.deepEqual(normalizeStopSessionInput({ source: 'stop_button', extra: true }), { - source: 'stop_button', - }); + assert.deepEqual( + normalizeStopSessionInput({ + source: 'stop_button', + expectedTurnId: 'turn-workhub', + extra: true, + }), + { source: 'stop_button', expectedTurnId: 'turn-workhub' }, + ); assert.throws(() => normalizeStopSessionInput(null), /stop session input/); assert.throws(() => normalizeStopSessionInput({ source: 'toolbar' }), /stop session source/); + assert.throws( + () => normalizeStopSessionInput({ expectedTurnId: '' }), + /expectedTurnId/, + ); }); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 2a3574cbe2..4d30d0e707 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -883,7 +883,15 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn kind: "queued", }, ); - await ipc.invoke("sessions:stop", "session-1"); + await ipc.invoke("sessions:stop", "session-1", { + source: "stop_button", + expectedTurnId: "turn-unrelated", + }); + assert.deepEqual(stopLifecycle, []); + await ipc.invoke("sessions:stop", "session-1", { + source: "stop_button", + expectedTurnId: "turn-1", + }); assert.deepEqual(stopLifecycle, ["teardown", "interrupt"]); assert.deepEqual(submits, [ diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts new file mode 100644 index 0000000000..fc8122cb1e --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -0,0 +1,1089 @@ +/* + * 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 { + createWorkHubController, + WORKHUB_ROUTING_STRATEGY_ID, + type WorkHubSessionFacts, + type WorkHubSessionPort, +} from '../../renderer/workhub-controller.js'; + +test('binds the controller to the immutable WH-R2.3 strategy ID', () => { + assert.equal(WORKHUB_ROUTING_STRATEGY_ID, 'wh-r2.3-session-core-evidence'); +}); + +function session( + sessionId: string, + overrides: Partial = {}, +): WorkHubSessionFacts { + return { + target: { sessionId }, + projectName: 'maka', + sessionName: sessionId, + kind: 'ordinary', + archived: false, + state: 'active', + updatedAt: 1, + ...overrides, + }; +} + +function port(sessions: WorkHubSessionFacts[]): WorkHubSessionPort { + return { + list: async () => sessions, + routingEvidence: async () => [], + create: async () => { + throw new Error('create is not used by this read test'); + }, + submit: async () => { + throw new Error('submit is not used by this read test'); + }, + stop: async () => {}, + subscribe: () => () => {}, + }; +} + +test('read exposes existing ordinary Sessions as factual Work summaries', async () => { + const controller = createWorkHubController({ + sessions: port([ + session('login', { + sessionName: '登录刷新令牌', + state: 'running', + latestResult: '已定位到刷新竞争条件', + updatedAt: 30, + }), + session('payment', { + projectName: 'billing', + sessionName: '支付回调幂等性', + archived: true, + latestResult: '处理支付回调重复投递', + updatedAt: 20, + }), + session('hub-internal', { kind: 'internal', updatedAt: 50 }), + session('child-agent', { kind: 'subagent', updatedAt: 40 }), + ]), + }); + + const projection = await controller.read(); + + assert.deepEqual(projection.sessions, [ + { + target: { sessionId: 'login' }, + projectName: 'maka', + sessionName: '登录刷新令牌', + archived: false, + state: 'running', + latestResult: '已定位到刷新竞争条件', + updatedAt: 30, + }, + { + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + archived: true, + state: 'active', + latestResult: '处理支付回调重复投递', + updatedAt: 20, + }, + ]); +}); + +test('archived Sessions stay inspectable but are excluded from routing targets', async () => { + const evidenceTargets: string[][] = []; + const submitted: string[] = []; + const sessions = port([ + session('archived-payment', { + sessionName: '支付回调幂等性', + archived: true, + updatedAt: 30, + }), + session('active-login', { + sessionName: '登录刷新令牌', + updatedAt: 20, + }), + ]); + sessions.routingEvidence = async (targets) => { + evidenceTargets.push(targets.map((target) => target.sessionId)); + return []; + }; + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'unexpected' }; + }; + const controller = createWorkHubController({ sessions }); + + const projection = await controller.read(); + const result = await controller.submit({ + requestId: 'archived-target', + text: '支付回调幂等性现在是什么状态?', + }); + + assert.equal(projection.sessions.some((entry) => entry.archived), true); + assert.deepEqual(evidenceTargets, [['active-login']]); + assert.equal(result.kind, 'discussion'); + assert.deepEqual(submitted, []); +}); + +test('submit sends an explicitly targeted request to that Session', async () => { + const submitted: Array<{ sessionId: string; text: string }> = []; + const sessions = port([session('payment', { sessionName: '支付回调幂等性' })]); + sessions.submit = async (target, text) => { + submitted.push({ sessionId: target.sessionId, text }); + return { turnId: 'turn-payment' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-1', + text: '补充重复投递测试', + explicitTarget: { sessionId: 'payment' }, + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-1', + target: { sessionId: 'payment' }, + turnId: 'turn-payment', + evidence: 'explicit_target', + }); + assert.deepEqual(submitted, [ + { sessionId: 'payment', text: '补充重复投递测试' }, + ]); +}); + +test('submit routes a unique complete Session name without asking', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { projectName: 'billing', sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-exact' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-exact', + text: '在支付回调幂等性里补充重复投递测试', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-exact', + target: { sessionId: 'payment' }, + turnId: 'turn-exact', + evidence: 'exact_session_name', + }); + assert.deepEqual(submitted, ['payment']); +}); + +test('a unique longer Session name outranks a generic contained Session name', async () => { + const submitted: string[] = []; + const sessions = port([ + session('layout', { sessionName: '优化WorkHub移动端消息布局' }), + session('generic', { sessionName: 'WorkHub' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-layout' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'request-layout', + text: '优化WorkHub移动端消息布局:补充横屏注意点。', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(submitted, ['layout']); +}); + +test('a short Latin Session name does not match inside another word', async () => { + const submitted: string[] = []; + const created: string[] = []; + const sessions = port([ + session('ai', { sessionName: 'AI' }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-new', { sessionName: name }); + }; + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-parser' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'request-parser', + text: '修复 repair parser 的错误', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['修复 repair parser 的错误']); + assert.deepEqual(submitted, ['parser-new']); +}); + +test('a one-character Latin discriminator prevents routing to a different Session name', async () => { + for (const { existingName, requestedName } of [ + { existingName: 'GPT-4', requestedName: 'GPT-3' }, + { existingName: 'Project A', requestedName: 'Project B' }, + ]) { + const submitted: string[] = []; + const sessions = port([ + session('existing', { sessionName: existingName }), + ]); + sessions.create = async ({ name }) => session('new', { sessionName: name }); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-new' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: `request-${requestedName}`, + text: `请处理 ${requestedName} 的问题`, + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(submitted, ['new']); + } +}); + +test('submit asks the user when weak relevance matches more than one Session', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '处理刷新令牌过期造成的重复登录', + updatedAt: 20, + }), + session('payment', { + projectName: 'billing', + sessionName: '支付回调幂等性', + latestResult: '处理支付回调重复投递', + updatedAt: 30, + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'unexpected' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-ambiguous', + text: '继续处理重复问题', + }); + + assert.deepEqual(result, { + kind: 'clarification', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-ambiguous', + text: '继续处理重复问题', + options: [ + { + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + }, + { + target: { sessionId: 'login' }, + projectName: 'maka', + sessionName: '登录刷新令牌', + }, + ], + }); + assert.deepEqual(submitted, []); +}); + +test('submit keeps origin prompts as stable evidence after latest results change', async () => { + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '已经整理为检查清单', + updatedAt: 20, + }), + session('payment', { + sessionName: '支付回调幂等性', + latestResult: '已经把风险按高、中、低分组', + updatedAt: 30, + }), + ]); + sessions.routingEvidence = async () => [ + { + target: { sessionId: 'login' }, + originPrompt: '排查刷新令牌过期导致的重复登录', + }, + { + target: { sessionId: 'payment' }, + originPrompt: '检查支付回调重复投递时的幂等性', + }, + ]; + sessions.submit = async () => ({ turnId: 'turn-focus-login' }); + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-focus-login', + text: '先看登录', + explicitTarget: { sessionId: 'login' }, + }); + + const result = await controller.submit({ + requestId: 'request-origin-ambiguity', + text: '继续处理重复问题', + }); + + assert.equal(result.kind, 'clarification'); + assert.deepEqual(result.kind === 'clarification' + ? result.options.map((option) => option.target.sessionId) + : [], ['payment', 'login']); +}); + +test('submit creates a new executable topic instead of following one weak old clue', async () => { + const createdNames: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '已经整理为检查清单', + }), + ]); + sessions.routingEvidence = async () => [{ + target: { sessionId: 'login' }, + originPrompt: '排查刷新令牌过期导致的重复登录', + }]; + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('payment-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-payment-new' }); + const controller = createWorkHubController({ sessions }); + const text = '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。'; + + const result = await controller.submit({ requestId: 'request-payment-new', text }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'payment-new', + }); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(createdNames, ['检查支付回调重复投递时的幂等性']); +}); + +test('submit does not treat a project name as strong topic evidence', async () => { + const createdNames: string[] = []; + const sessions = port([ + session('login', { + projectName: 'maka-workhub-session-router', + sessionName: '登录刷新令牌', + }), + ]); + sessions.routingEvidence = async () => [{ + target: { sessionId: 'login' }, + originPrompt: '排查刷新令牌过期导致的重复登录', + }]; + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('layout-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-layout-new' }); + const controller = createWorkHubController({ sessions }); + const text = '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。'; + + const result = await controller.submit({ requestId: 'request-layout-new', text }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'layout-new', + }); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(createdNames, ['优化 WorkHub 在移动端窄屏下的消息布局']); +}); + +test('submit follows an unambiguous reference to the most recent Work', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-focus', + text: '先处理支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const result = await controller.submit({ + requestId: 'request-pronoun', + text: '继续它', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-pronoun', + target: { sessionId: 'payment' }, + turnId: 'turn-2', + evidence: 'recent_focus', + }); + assert.deepEqual(submitted, ['payment', 'payment']); +}); + +test('submit routes strong core evidence instead of reusing recent focus', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-login-focus', + text: '先看登录', + explicitTarget: { sessionId: 'login' }, + }); + + const result = await controller.submit({ + requestId: 'request-topic-shift', + text: '继续处理支付回调重复投递', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-topic-shift', + target: { sessionId: 'payment' }, + turnId: 'turn-2', + evidence: 'core_entity', + }); + assert.deepEqual(submitted, ['login', 'payment']); +}); + +test('submit routes unique strong core evidence without asking', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌过期导致的重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-core' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-core', + text: '刷新令牌过期时,重复登录的观测日志应该记录哪些字段?', + }); + + assert.equal(result.kind, 'submitted'); + if (result.kind !== 'submitted') return; + assert.deepEqual(result.target, { sessionId: 'login' }); + assert.equal(result.evidence, 'core_entity'); + assert.equal(result.strategyId, 'wh-r2.3-session-core-evidence'); + assert.deepEqual(submitted, ['login']); +}); + +test('submit ignores shared boilerplate when an executable request names a new topic', async () => { + const createdNames: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '排查登录刷新令牌,先只分析风险和测试点,不修改文件', + }), + ]); + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('payment-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-payment-new' }); + const controller = createWorkHubController({ sessions }); + const text = '请创建新任务,检查支付回调重复投递;先只分析风险和测试点,不修改文件。'; + + const result = await controller.submit({ requestId: 'request-new-topic', text }); + + assert.equal(result.kind, 'submitted'); + if (result.kind !== 'submitted') return; + assert.deepEqual(result.target, { sessionId: 'payment-new' }); + assert.equal(result.evidence, 'new_session'); + assert.equal(result.strategyId, 'wh-r2.3-session-core-evidence'); + assert.deepEqual(createdNames, ['检查支付回调重复投递']); +}); + +test('submit keeps a unique two-character clue behind clarification', async () => { + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性' }), + ]); + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-weak', + text: '继续登录', + }); + + assert.equal(result.kind, 'clarification'); + assert.equal(result.strategyId, 'wh-r2.3-session-core-evidence'); +}); + +test('submit treats explicit user uncertainty as clarification instead of a new Session', async () => { + const created: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录稳定性', updatedAt: 20 }), + session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('unexpected'); + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-uncertain', + text: '继续处理稳定性问题,但我不确定具体是哪一个。', + }); + + assert.equal(result.kind, 'clarification'); + assert.deepEqual(result.kind === 'clarification' + ? result.options.map((option) => option.target.sessionId) + : [], ['payment', 'login']); + assert.deepEqual(created, []); +}); + +test('English target uncertainty uses clarification as the routing safety valve', async () => { + const submitted: string[] = []; + const sessions = port([ + session('parser', { sessionName: 'Parser Cleanup', updatedAt: 20 }), + session('profile', { sessionName: 'Profile Settings', updatedAt: 30 }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'unexpected' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-uncertainty', + text: "I'm not sure which one this belongs to; continue the cleanup.", + }); + + assert.equal(result.kind, 'clarification'); + assert.deepEqual(result.kind === 'clarification' + ? result.options.map((option) => option.target.sessionId) + : [], ['parser', 'profile']); + assert.deepEqual(submitted, []); +}); + +test('English routing matches whole words instead of substrings in another identity', async () => { + const submitted: string[] = []; + const created: string[] = []; + const sessions = port([ + session('profile', { sessionName: 'Profile Settings' }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-new', { sessionName: name }); + }; + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-parser' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-word-boundary', + text: 'check the file parser', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['check the file parser']); + assert.deepEqual(submitted, ['parser-new']); +}); + +test('English core evidence requires a distinctive word or multiple whole-word matches', async () => { + const submitted: string[] = []; + const sessions = port([ + session('parser', { + sessionName: 'Parser Cleanup', + latestResult: 'Tokenizer regression isolated in parser recovery', + }), + session('profile', { + sessionName: 'Profile Settings', + latestResult: 'Account preferences are ready', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-parser' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-core-evidence', + text: 'fix the parser tokenizer crash', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'core_entity'); + assert.deepEqual(submitted, ['parser']); +}); + +test('route correction stops the wrong Session and teaches a similar request', async () => { + const submitted: string[] = []; + const stopped: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + sessions.stop = async (target) => { + stopped.push(target.sessionId); + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-focus-payment', + text: '先看支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const wrong = await controller.submit({ + requestId: 'request-alias', + text: '继续白鹭点,列出验收项。', + }); + assert.deepEqual(wrong.kind === 'submitted' ? wrong.target : undefined, { + sessionId: 'payment', + }); + + const corrected = await controller.submit({ + requestId: 'request-alias', + text: '继续白鹭点,列出验收项。', + explicitTarget: { sessionId: 'login' }, + correction: { from: { sessionId: 'payment' }, turnId: 'turn-2' }, + }); + assert.equal(corrected.kind, 'submitted'); + assert.equal(corrected.kind === 'submitted' ? corrected.evidence : undefined, 'route_correction'); + assert.deepEqual(corrected.kind === 'submitted' ? corrected.correctedFrom : undefined, { + sessionId: 'payment', + }); + + const learned = await controller.submit({ + requestId: 'request-alias-similar', + text: '继续白鹭点,补充失败判定。', + }); + assert.deepEqual(learned.kind === 'submitted' ? learned.target : undefined, { + sessionId: 'login', + }); + assert.equal(learned.kind === 'submitted' ? learned.evidence : undefined, 'route_correction'); + assert.deepEqual(stopped, ['payment']); + assert.deepEqual(submitted, ['payment', 'payment', 'login', 'login']); +}); + +test('route correction never stops a root Turn that WorkHub only steered into', async () => { + const stopped: string[] = []; + let submissionCount = 0; + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性', state: 'running' }), + ]); + sessions.submit = async () => { + submissionCount += 1; + return submissionCount === 1 + ? { turnId: 'turn-existing', steered: true } + : { turnId: 'turn-login' }; + }; + sessions.stop = async (target) => { + stopped.push(target.sessionId); + }; + const controller = createWorkHubController({ sessions }); + + const wrong = await controller.submit({ + requestId: 'request-steered', + text: '继续补充支付验收项', + explicitTarget: { sessionId: 'payment' }, + }); + assert.equal(wrong.kind === 'submitted' ? wrong.steered : undefined, true); + + const correction = { + from: { sessionId: 'payment' }, + turnId: 'turn-existing', + steered: true as const, + }; + const corrected = await controller.submit({ + requestId: 'request-steered', + text: '不是支付,应该补充登录验收项', + explicitTarget: { sessionId: 'login' }, + correction, + }); + + assert.equal(corrected.kind, 'submitted'); + assert.deepEqual(stopped, []); +}); + +test('latest route correction wins for the same expression family', async () => { + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性' }), + ]); + sessions.submit = async (_target) => ({ turnId: 'turn' }); + const controller = createWorkHubController({ sessions }); + + await controller.submit({ + requestId: 'correction-login', + text: '继续白鹭点,列出验收项。', + explicitTarget: { sessionId: 'login' }, + correction: { from: { sessionId: 'payment' }, turnId: 'turn' }, + }); + await controller.submit({ + requestId: 'correction-payment', + text: '继续白鹭点,列出异常项。', + explicitTarget: { sessionId: 'payment' }, + correction: { from: { sessionId: 'login' }, turnId: 'turn' }, + }); + + const result = await controller.submit({ + requestId: 'correction-latest', + text: '继续白鹭点,补充回滚条件。', + }); + + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'payment', + }); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'route_correction'); +}); + +test('waiting Session rejects a second root request without calling submit', async () => { + let submitted = false; + const sessions = port([ + session('login', { + sessionName: '排查令牌过期重复登录问题', + state: 'waiting_for_user', + }), + ]); + sessions.submit = async () => { + submitted = true; + return { turnId: 'unexpected' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-waiting', + text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', + }); + + assert.deepEqual(result, { + kind: 'waiting', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-waiting', + text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', + target: { sessionId: 'login' }, + }); + assert.equal(submitted, false); +}); + +test('submit returns to the previous focused Session', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-login', + text: '先看登录', + explicitTarget: { sessionId: 'login' }, + }); + await controller.submit({ + requestId: 'request-payment', + text: '再看支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const result = await controller.submit({ + requestId: 'request-previous', + text: '回到上一个工作', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); + assert.deepEqual(submitted, ['login', 'payment', 'login']); +}); + +test('submit lets strong foreign core evidence override a vague focus word', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌过期导致的重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-payment-focus', + text: '先看支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const result = await controller.submit({ + requestId: 'request-foreign-core', + text: '继续处理刷新令牌过期', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); + assert.deepEqual(submitted, ['payment', 'login']); +}); + +test('submit keeps unmatched non-executable conversation in WorkHub', async () => { + let created = false; + const sessions = port([]); + sessions.create = async () => { + created = true; + return session('unexpected'); + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-discussion', + text: '你觉得统一入口最重要的价值是什么?', + }); + + assert.deepEqual(result, { + kind: 'discussion', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-discussion', + text: '你觉得统一入口最重要的价值是什么?', + }); + assert.equal(created, false); +}); + +test('submit treats a design question containing an action word as discussion', async () => { + let created = false; + const sessions = port([]); + sessions.create = async () => { + created = true; + return session('unexpected'); + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-design-question', + text: '我们应该怎么实现统一入口?', + }); + + assert.equal(result.kind, 'discussion'); + assert.equal(created, false); +}); + +test('an executable English request may contain what without becoming discussion', async () => { + const created: string[] = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-fix', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-parser-fix' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-what-object', + text: 'fix what is broken in the parser', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['fix what is broken in the parser']); +}); + +test('submit creates an ordinary Session for a clear unmatched executable goal', async () => { + const createdNames: string[] = []; + const submitted: Array<{ sessionId: string; text: string }> = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('invoice-export', { sessionName: name }); + }; + sessions.submit = async (target, text) => { + submitted.push({ sessionId: target.sessionId, text }); + return { turnId: 'turn-invoice-export' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-new-work', + text: '实现导出发票 PDF 功能', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-new-work', + target: { sessionId: 'invoice-export' }, + turnId: 'turn-invoice-export', + evidence: 'new_session', + }); + assert.deepEqual(createdNames, ['实现导出发票 PDF 功能']); + assert.deepEqual(submitted, [ + { sessionId: 'invoice-export', text: '实现导出发票 PDF 功能' }, + ]); +}); + +test('explicit new-Session intent outranks generic evidence from existing work', async () => { + const created: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录稳定性测试计划' }), + session('payment', { sessionName: '支付回调测试计划' }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('new-session', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-new-session' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'request-explicit-new', + text: '创建一个全新的普通 Session,标题为 R2.3 新建工作验收,只记录测试计划。', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['R2.3 新建工作验收']); +}); + +test('English explicit creation extracts the requested Session name', async () => { + const created: string[] = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-cleanup', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-parser-cleanup' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-explicit-new', + text: 'Create a new session called Parser Cleanup.', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['Parser Cleanup']); +}); + +test('English routing boilerplate does not make an old analysis look related', async () => { + const created: string[] = []; + const sessions = port([ + session('login', { + sessionName: 'Login Refresh Token', + latestResult: 'Just analyze the risks and test cases; do not modify any files.', + }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('payment-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-payment-new' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-boilerplate', + text: "Check payment callback duplicate delivery; just analyze the risks and test cases; don't modify any files.", + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['Check payment callback duplicate delivery']); +}); + +test('negated and deliberative creation language never creates a Session', async () => { + const created: string[] = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + created.push(name); + return session('unexpected', { sessionName: name }); + }; + const controller = createWorkHubController({ sessions }); + + const negated = await controller.submit({ + requestId: 'negated-create', + text: '不要创建一个新任务,我们先讨论这个方向。', + }); + const deliberative = await controller.submit({ + requestId: 'question-create', + text: '是否应该新建一个任务?', + }); + + assert.equal(negated.kind, 'discussion'); + assert.equal(deliberative.kind, 'discussion'); + assert.deepEqual(created, []); +}); + +test('subscribe exposes Session invalidations without inventing WorkHub state', () => { + let listener: (() => void) | undefined; + let unsubscribed = false; + const sessions = port([]); + sessions.subscribe = (handler) => { + listener = handler; + return () => { + unsubscribed = true; + }; + }; + const controller = createWorkHubController({ sessions }); + let invalidations = 0; + + const unsubscribe = controller.subscribe(() => { + invalidations += 1; + }); + listener?.(); + unsubscribe(); + + assert.equal(invalidations, 1); + assert.equal(unsubscribed, true); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts new file mode 100644 index 0000000000..bb31829e9c --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -0,0 +1,266 @@ +/* + * 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 { + createDesktopWorkHubSessionPort, + type WorkHubDesktopSession, +} from '../../renderer/workhub-session-port.js'; + +function desktopSession( + id: string, + overrides: Partial = {}, +): WorkHubDesktopSession { + return { + id, + name: id, + labels: [], + isArchived: false, + status: 'active', + runningTurnIds: [], + projectId: 'project-maka', + lastMessageAt: 1, + ...overrides, + }; +} + +test('desktop adapter projects Session catalog facts without owning copies', async () => { + const source = [ + desktopSession('ordinary', { + name: '支付回调幂等性', + status: 'running', + runningTurnIds: ['turn-running'], + lastMessageAt: 30, + lastMessagePreview: '正在补充重复投递测试', + }), + desktopSession('side', { + labels: ['mode:side_conversation'], + lastMessageAt: 20, + }), + desktopSession('waiting', { + status: 'waiting_for_user', + runningTurnIds: ['turn-waiting'], + lastMessageAt: 15, + }), + desktopSession('child', { + subagent: {}, + lastMessageAt: 10, + }), + ]; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => source, + listTurns: async () => [], + create: async () => { + throw new Error('not used'); + }, + send: async () => { + throw new Error('not used'); + }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: (projectId) => projectId === 'project-maka' ? 'Maka' : undefined, + newTurnId: () => 'unused', + }); + + assert.deepEqual(await adapter.list(), [ + { + target: { sessionId: 'ordinary' }, + projectName: 'Maka', + sessionName: '支付回调幂等性', + kind: 'ordinary', + archived: false, + state: 'running', + latestResult: '正在补充重复投递测试', + updatedAt: 30, + }, + { + target: { sessionId: 'side' }, + projectName: 'Maka', + sessionName: 'side', + kind: 'internal', + archived: false, + state: 'active', + updatedAt: 20, + }, + { + target: { sessionId: 'waiting' }, + projectName: 'Maka', + sessionName: 'waiting', + kind: 'ordinary', + archived: false, + state: 'waiting_for_user', + updatedAt: 15, + }, + { + target: { sessionId: 'child' }, + projectName: 'Maka', + sessionName: 'child', + kind: 'subagent', + archived: false, + state: 'active', + updatedAt: 10, + }, + ]); +}); + +test('desktop adapter delegates create, send, and invalidation to Session APIs', async () => { + const calls: unknown[] = []; + let onChanged: (() => void) | undefined; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [desktopSession('created', { + status: 'running', + runningTurnIds: ['turn-new'], + })], + listTurns: async () => [], + create: async (input) => { + calls.push(['create', input]); + return desktopSession('created', { name: input.name }); + }, + send: async (sessionId, command) => { + calls.push(['send', sessionId, command]); + return { ok: true, turnId: command.turnId }; + }, + stop: async (sessionId, input) => { + calls.push(['stop', sessionId, input]); + }, + subscribeChanges: (handler) => { + onChanged = handler; + return () => calls.push(['unsubscribe']); + }, + }, + projectName: () => 'Maka', + newTurnId: () => 'turn-new', + }); + + const created = await adapter.create({ name: '实现导出发票 PDF 功能' }); + const turn = await adapter.submit(created.target, '实现导出发票 PDF 功能'); + await adapter.stop(created.target, 'turn-new'); + let invalidations = 0; + const unsubscribe = adapter.subscribe(() => { + invalidations += 1; + }); + onChanged?.(); + unsubscribe(); + + assert.equal(created.kind, 'ordinary'); + assert.deepEqual(turn, { turnId: 'turn-new' }); + assert.equal(invalidations, 1); + assert.deepEqual(calls, [ + ['create', { name: '实现导出发票 PDF 功能' }], + ['send', 'created', { type: 'send', turnId: 'turn-new', text: '实现导出发票 PDF 功能' }], + ['stop', 'created', { source: 'stop_button', expectedTurnId: 'turn-new' }], + ['unsubscribe'], + ]); +}); + +test('desktop adapter preserves when Session delivery steered an existing root Turn', async () => { + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async () => [], + create: async () => { + throw new Error('not used'); + }, + send: async (_sessionId, command) => ({ + ok: true, + turnId: command.turnId, + steered: true, + }), + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'turn-steered', + }); + + assert.deepEqual( + await adapter.submit({ sessionId: 'busy' }, '补充已有执行流'), + { turnId: 'turn-steered', steered: true }, + ); +}); + +test('desktop adapter binds stop to the root Turn owned by the WorkHub submission', async () => { + const stopped: unknown[] = []; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async () => [], + create: async () => { + throw new Error('not used'); + }, + send: async () => { + throw new Error('not used'); + }, + stop: async (sessionId, input) => { + stopped.push([sessionId, input]); + }, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + + await adapter.stop({ sessionId: 'payment' }, 'turn-workhub'); + + assert.deepEqual(stopped, [[ + 'payment', + { source: 'stop_button', expectedTurnId: 'turn-workhub' }, + ]]); +}); + +test('desktop adapter derives stable origin evidence from the existing Session log', async () => { + let reads = 0; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async (sessionId) => { + reads += 1; + assert.equal(sessionId, 'payment'); + return [ + { userPromptPreview: '检查支付回调重复投递时的幂等性' }, + { userPromptPreview: '把风险按高、中、低分组' }, + ]; + }, + create: async () => { + throw new Error('not used'); + }, + send: async () => { + throw new Error('not used'); + }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + + const first = await adapter.routingEvidence([{ sessionId: 'payment' }]); + const second = await adapter.routingEvidence([{ sessionId: 'payment' }]); + + assert.deepEqual(first, [{ + target: { sessionId: 'payment' }, + originPrompt: '检查支付回调重复投递时的幂等性', + }]); + assert.deepEqual(second, first); + assert.equal(reads, 1); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts b/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts new file mode 100644 index 0000000000..8a68fdae1c --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { createDefaultSettings } from '@maka/core/settings'; +import { + clientOwnedSettingsPatch, + hasRuntimeHostSettingsPatch, + projectClientOwnedSettings, +} from '../../shared/settings-ownership.js'; + +test('keeps the WorkHub opt-in client-global across Runtime Hosts', () => { + assert.deepEqual(clientOwnedSettingsPatch({ workHub: { enabled: true } }), { + workHub: { enabled: true }, + }); + assert.equal(hasRuntimeHostSettingsPatch({ workHub: { enabled: true } }), false); + + const client = createDefaultSettings(); + client.workHub.enabled = true; + const runtimeHost = createDefaultSettings(); + assert.equal(projectClientOwnedSettings(runtimeHost, client).workHub.enabled, true); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts new file mode 100644 index 0000000000..f58d44c029 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -0,0 +1,237 @@ +/* + * 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 { + WorkHubSurfaceRouteGate, + submitWorkHubSurfaceInput, + workHubSubmissionCanCorrect, + workHubSubmissionClearsDraft, +} from '../../renderer/workhub-surface.js'; +import { + createWorkHubController, + type WorkHubController, + type WorkHubSubmitInput, +} from '../../renderer/workhub-controller.js'; +import { + createDesktopWorkHubSessionPort, + type WorkHubDesktopSession, +} from '../../renderer/workhub-session-port.js'; + +test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { + const gate = new WorkHubSurfaceRouteGate(); + let release: (() => void) | undefined; + const first = gate.run(async () => { + await new Promise((resolve) => { + release = resolve; + }); + return 'first'; + }); + + assert.equal(gate.pending, true); + assert.equal(await gate.run(async () => 'duplicate'), undefined); + release?.(); + assert.equal(await first, 'first'); + assert.equal(gate.pending, false); + assert.equal(await gate.run(async () => 'next'), 'next'); +}); + +test('surface keeps the Composer draft when routing fails or the target is waiting', () => { + assert.equal(workHubSubmissionClearsDraft(undefined), false); + assert.equal(workHubSubmissionClearsDraft({ + kind: 'waiting', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'waiting', + text: '继续处理', + target: { sessionId: 'payment' }, + }), false); + assert.equal(workHubSubmissionClearsDraft({ + kind: 'discussion', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'discussion', + text: '先讨论方向', + }), true); +}); + +test('surface disables correction after a request was steered into existing work', () => { + const submission = { + kind: 'submitted' as const, + strategyId: 'wh-r2.3-session-core-evidence' as const, + requestId: 'steered', + target: { sessionId: 'payment' }, + turnId: 'turn-existing', + evidence: 'explicit_target' as const, + }; + + assert.equal(workHubSubmissionCanCorrect(submission), true); + assert.equal(workHubSubmissionCanCorrect({ ...submission, steered: true }), false); +}); + +test('surface keeps clarification and successful routing in WorkHub', async () => { + const submissions: WorkHubSubmitInput[] = []; + const controller: WorkHubController = { + read: async () => ({ sessions: [] }), + subscribe: () => () => {}, + submit: async (input) => { + submissions.push(input); + if (!input.explicitTarget) { + return { + kind: 'clarification', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: input.requestId, + text: input.text, + options: [{ + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + }], + }; + } + return { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: input.requestId, + target: input.explicitTarget, + turnId: 'turn-payment', + evidence: 'explicit_target', + }; + }, + }; + + const clarification = await submitWorkHubSurfaceInput({ + controller, + input: { requestId: 'request-1', text: '继续处理重复问题' }, + }); + assert.equal(clarification.kind, 'clarification'); + + const submitted = await submitWorkHubSurfaceInput({ + controller, + input: { + requestId: 'request-1', + text: '继续处理重复问题', + explicitTarget: { sessionId: 'payment' }, + }, + }); + assert.equal(submitted.kind, 'submitted'); + assert.deepEqual(submissions[1]?.explicitTarget, { sessionId: 'payment' }); +}); + +test('surface leaves discussion in WorkHub instead of creating a task view', async () => { + const controller: WorkHubController = { + read: async () => ({ sessions: [] }), + subscribe: () => () => {}, + submit: async (input) => ({ + kind: 'discussion', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: input.requestId, + text: input.text, + }), + }; + + const result = await submitWorkHubSurfaceInput({ + controller, + input: { requestId: 'discussion', text: '这个方向的价值是什么?' }, + }); + + assert.equal(result.kind, 'discussion'); +}); + +test('real Session projection creates new guide topics and preserves origin ambiguity', async () => { + let clock = 10; + const sessions: WorkHubDesktopSession[] = [{ + id: 'login', + name: '刷新令牌过期致重复登录的排查计划', + labels: [], + isArchived: false, + status: 'active', + projectId: 'project-router', + lastMessageAt: clock, + lastMessagePreview: '已经整理为检查清单', + }]; + const prompts = new Map([[ + 'login', + ['排查登录刷新令牌过期导致重复登录的问题,先只分析并列出计划,不修改文件。'], + ]]); + const created: string[] = []; + const port = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => sessions, + listTurns: async (sessionId) => (prompts.get(sessionId) ?? []) + .map((userPromptPreview) => ({ userPromptPreview })), + create: async ({ name }) => { + const id = name.includes('支付回调') ? 'payment' : 'layout'; + const session: WorkHubDesktopSession = { + id, + name: id === 'payment' ? '支付回调幂等性' : '移动端窄屏布局', + labels: [], + isArchived: false, + status: 'active', + projectId: 'project-maka', + lastMessageAt: ++clock, + }; + created.push(id); + sessions.push(session); + prompts.set(id, []); + return session; + }, + send: async (sessionId, command) => { + prompts.get(sessionId)?.push(command.text); + const session = sessions.find((candidate) => candidate.id === sessionId); + if (session) { + session.lastMessageAt = ++clock; + session.lastMessagePreview = command.text; + } + return { ok: true, turnId: command.turnId }; + }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: (projectId) => projectId === 'project-router' + ? 'maka-workhub-session-router' + : 'maka-agent', + newTurnId: () => `turn-${clock + 1}`, + }); + const controller = createWorkHubController({ sessions: port }); + + const payment = await controller.submit({ + requestId: 'setup-payment', + text: '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。', + }); + const layout = await controller.submit({ + requestId: 'setup-layout', + text: '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。', + }); + await controller.submit({ + requestId: 'focus-login', + text: '刷新令牌过期致重复登录的排查计划:补充观测日志字段。', + }); + const ambiguous = await controller.submit({ + requestId: 'ambiguous-repeat', + text: '继续处理重复问题', + }); + + assert.equal(payment.kind === 'submitted' ? payment.evidence : undefined, 'new_session'); + assert.equal(layout.kind === 'submitted' ? layout.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['payment', 'layout']); + assert.equal(ambiguous.kind, 'clarification'); + assert.deepEqual(ambiguous.kind === 'clarification' + ? ambiguous.options.map((option) => option.target.sessionId) + : [], ['login', 'payment']); +}); diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 03597bc010..1dd307674a 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -62,7 +62,10 @@ interface NormalizedSendSessionCommand { quotes?: QuoteRef[]; workspaceFileReferences?: WorkspaceFileReferencePosition[]; } -type NormalizedStopSessionInput = { source?: 'stop_button' }; +type NormalizedStopSessionInput = { + source?: 'stop_button'; + expectedTurnId?: string; +}; export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundaryResponse { if (!input || typeof input !== 'object') { @@ -311,11 +314,20 @@ export function normalizeSessionSkillIds(input: unknown): string[] { export function normalizeStopSessionInput(input: unknown): NormalizedStopSessionInput { if (input === undefined) return {}; const value = requireObject(input, 'Invalid stop session input'); - if (value.source === undefined) return {}; - if (value.source !== 'stop_button') { + if (value.source !== undefined && value.source !== 'stop_button') { throw new Error('Invalid stop session source'); } - return { source: 'stop_button' }; + const expectedTurnId = value.expectedTurnId === undefined + ? undefined + : normalizeRequiredString( + value.expectedTurnId, + 'Invalid stop session expectedTurnId', + MAX_TURN_ID_LENGTH, + ); + return { + ...(value.source ? { source: 'stop_button' as const } : {}), + ...(expectedTurnId ? { expectedTurnId } : {}), + }; } function requireObject(input: unknown, errorMessage: string): Record { diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 3c8a09f77e..93fa5c3816 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -41,6 +41,7 @@ import { normalizeRuntimeHostReviseBeforeTurnInput, normalizeSandboxBoundaryResponse, normalizeSessionSendCommand, + normalizeStopSessionInput, normalizeUserQuestionResponse, } from "./permission-response-guard.js"; import { @@ -482,8 +483,12 @@ export function registerRuntimeHostSessionExecutionIpc( }); }, ); - ipcMain.handle("sessions:stop", async (_event, sessionId: string) => - stopSession(sessionId), + ipcMain.handle( + "sessions:stop", + async (_event, sessionId: string, input: unknown) => { + const normalized = normalizeStopSessionInput(input); + return stopSession(sessionId, normalized.expectedTurnId); + }, ); ipcMain.handle( @@ -694,11 +699,25 @@ function createRuntimeHostSessionStop( "beforeStop" | "client" | "observer" | "emitSessionsChanged" >, newId: () => string = randomUUID, -): (sessionId: string) => Promise { - return async (sessionId) => { +): (sessionId: string, expectedTurnId?: string) => Promise { + return async (sessionId, expectedTurnId) => { + if (expectedTurnId) { + const observed = (await deps.observer.snapshot(sessionId)).rootTurn; + if ( + !observed || + isTerminalStatus(observed.status) || + observed.turnId !== expectedTurnId + ) { + return; + } + } await deps.beforeStop(sessionId); const turn = (await deps.observer.snapshot(sessionId)).rootTurn; - if (!turn || isTerminalStatus(turn.status)) return; + if ( + !turn || + isTerminalStatus(turn.status) || + (expectedTurnId && turn.turnId !== expectedTurnId) + ) return; await deps.client.interruptTurn({ sessionId, interruptId: newId(), diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 337a41e2b3..ae0bac729b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -710,7 +710,10 @@ export interface MakaBridge { skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } >; - stop(sessionId: string, input?: { source?: 'stop_button' }): Promise; + stop( + sessionId: string, + input?: { source?: 'stop_button'; expectedTurnId?: string }, + ): Promise; steer(sessionId: string, text: string): Promise; enqueue( sessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8a05f47e3b..b572fd8035 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1500,7 +1500,10 @@ const makaBridge = { > { return invokeSessionRuntimeHost('sessions:resumeLatest', sessionId); }, - stop(sessionId: string, input?: { source?: 'stop_button' }): Promise { + stop( + sessionId: string, + input?: { source?: 'stop_button'; expectedTurnId?: string }, + ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, steer(sessionId: string, text: string): Promise { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 23b0d49ef5..e8fb03eeed 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -79,6 +79,7 @@ import { } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; +import { Button } from '@astryxdesign/core/Button'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; @@ -128,6 +129,9 @@ import { import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; +import { createWorkHubController } from './workhub-controller.js'; +import { createDesktopWorkHubSessionPort } from './workhub-session-port.js'; +import { WorkHubSurface } from './workhub-surface.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; @@ -450,6 +454,35 @@ function AppShellContent({ )); }, []); const navSelectionRef = useRef(navSelection); + const [workHubEnabled, setWorkHubEnabled] = useState(false); + const [workHubActive, setWorkHubActive] = useState(false); + const workHubEnabledRef = useRef(false); + useEffect(() => { + let disposed = false; + const refresh = async () => { + try { + const enabled = (await window.maka.settings.getClient()).workHub.enabled; + if (disposed) return; + const becameEnabled = enabled && !workHubEnabledRef.current; + workHubEnabledRef.current = enabled; + setWorkHubEnabled(enabled); + if (!enabled) setWorkHubActive(false); + if (becameEnabled) { + setWorkHubActive(true); + setNavSelection({ section: 'sessions' }); + } + } catch { + // Keep the last known client-owned setting. A transient settings read + // must not leave the shell half-switched between WorkHub and Session. + } + }; + void refresh(); + const unsubscribe = window.maka.settings.subscribeClientChanged(() => void refresh()); + return () => { + disposed = true; + unsubscribe(); + }; + }, [setNavSelection]); // #1985: the shell's complete read of session UI state. See the hook for why // the two token-rate maps are absent. const { @@ -1215,6 +1248,7 @@ function AppShellContent({ } function openSessionInChat(sessionId: string, turnId?: string, sequence?: number): void { + setWorkHubActive(false); setNavSelection({ section: 'sessions' }); setActiveId(sessionId); if (turnId) { @@ -1269,6 +1303,10 @@ function AppShellContent({ const sessionListSelectSession = useCallback((sessionId: string) => { openSessionInChatRef.current(sessionId); }, []); + const openWorkHub = useCallback(() => { + setNavSelection({ section: 'sessions' }); + setWorkHubActive(true); + }, [setNavSelection]); // PR109f: branched session context. When the active session was // created via `sessions:branchFromTurn`, its `parentSessionId` is @@ -1625,6 +1663,13 @@ function AppShellContent({ }, toastApi, }); + const workHubController = useMemo(() => createWorkHubController({ + sessions: createDesktopWorkHubSessionPort({ + sessions: window.maka.sessions, + projectName: (projectId) => projects.find((project) => project.id === projectId)?.name, + newTurnId: () => crypto.randomUUID(), + }), + }), [projects]); // Where a NEW chat starts. Built unconditionally and handed to the composer, // which renders it only while no session owns it — the project is fixed once // the first message creates one, so there is nothing to pick after that. @@ -1774,7 +1819,7 @@ function AppShellContent({ [toastApi], ); const workbarAvailable = - navSelection.section === 'sessions' && Boolean(activeId); + navSelection.section === 'sessions' && !workHubActive && Boolean(activeId); const workbar = useWorkbarController({ available: workbarAvailable, activeSession: activeSessionForView, @@ -2805,7 +2850,7 @@ function AppShellContent({ summary loads, and the name this replaced (the context layer's) was showing through that window. Hung on the real record alone, 新任务 was named nowhere for the length of it. */} - {navSelection.section === 'sessions' && activeSessionForView && ( + {navSelection.section === 'sessions' && !workHubActive && activeSessionForView && ( { + setWorkHubActive(false); + setNavSelection(selection); + }} onSelectSession={sessionListSelectSession} onOpenSettings={openSettings} buildStamp={buildStamp} updateReminder={updateReminder} onOpenUpdate={openUpdateDownload} - onNew={createSession} + onNew={() => { + setWorkHubActive(false); + void createSession(); + }} + workHubEntry={workHubEnabled ? { + active: workHubActive, + label: 'WorkHub', + onSelect: openWorkHub, + } : undefined} rowActions={sessionRowActions} projectActions={projectRowActions} /> @@ -2967,6 +3023,13 @@ function AppShellContent({ onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} /> ) : null} + {workHubEnabled && workHubActive && navSelection.section === 'sessions' ? ( + + ) : ( ) : null} {navSelection.section === 'sessions' ? : null} + {workHubEnabled && navSelection.section === 'sessions' && activeId ? ( + + ))} + + + ) : turn.outcome?.kind === 'discussion' ? ( + <> +

{copy.discussionStayed}

+ {copy.discussionHint} + + ) : turn.outcome?.kind === 'waiting' ? ( +
+

{copy.waitingForDecision}

+ {copy.requestNotSent} +
+ ) : submitted ? ( + + session.target.sessionId === submitted.correctedFrom?.sessionId, + ) + : undefined} + targetSessionId={submitted.target.sessionId} + copy={copy} + correctionOptions={workHubSubmissionCanCorrect(submitted) + ? props.projection.sessions.filter( + (session) => + !session.archived && + session.target.sessionId !== submitted.target.sessionId, + ) + : []} + pending={props.pending} + onCorrect={(target) => props.onCorrect(submitted, target)} + onOpenSession={props.onOpenSession} + /> + ) : null} + + + + ); +} + +function SubmittedWorkView(props: { + session: WorkHubSessionSummary | undefined; + correctedFrom: WorkHubSessionSummary | undefined; + targetSessionId: string; + copy: ReturnType; + correctionOptions: WorkHubSessionSummary[]; + pending: boolean; + onCorrect(target: { sessionId: string }): void; + onOpenSession(sessionId: string): void; +}) { + const { session, copy } = props; + const state = session ? (session.archived ? copy.archived : copy.states[session.state]) : copy.accepted; + return ( +
+

{copy.sentTo}

+ + {props.correctedFrom ? ( + + {copy.correctedFrom(props.correctedFrom.sessionName)} + + ) : null} + {session?.latestResult ?

{session.latestResult}

: null} + {props.correctionOptions.length > 0 ? ( +
+ {copy.correctTarget} +
+ {props.correctionOptions.map((option) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function workHubCopy(locale: UiLocale) { + if (locale === 'zh') { + return { + subtitle: '在一个入口里继续、创建和查看普通 Session', + emptyTitle: '从这里继续所有工作', + emptyBody: (count: number) => count > 0 + ? `WorkHub 会根据已有 ${count} 个 Session 判断目标;不确定时会先询问你。` + : '提出一个明确目标,WorkHub 会创建普通 Session 并把结果带回这里。', + workCount: (count: number) => `${count} 项工作`, clarification: '选择工作', + chooseWork: '这条输入可能与多项工作有关,请选择目标:', + discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。', + discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', + sentTo: '已交给:', accepted: '已接收', sessionFallback: '普通 Session', + correctTarget: '更正目标', + correctedFrom: (name: string) => `已从“${name}”更正`, + waitingForDecision: '这项工作正在等待你的决定。', + requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', + routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', + submitFailed: '输入未能送达,请重试。', scrollToBottom: '滚动到底部', archived: '已归档', + states: { active: '活跃', running: '进行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, + } as const; + } + return { + subtitle: 'Continue, create, and review ordinary Sessions from one place', + emptyTitle: 'Continue all work from here', + emptyBody: (count: number) => count > 0 + ? `WorkHub routes against ${count} existing Session${count === 1 ? '' : 's'} and asks when the target is unclear.` + : '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:', + 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.', + sentTo: 'Sent to:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', + correctTarget: 'Correct target', + correctedFrom: (name: string) => `Corrected from “${name}”`, + 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.', + submitFailed: 'The input could not be delivered. Try again.', scrollToBottom: 'Scroll to bottom', archived: 'Archived', + states: { active: 'Active', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Blocked', aborted: 'Aborted' }, + } as const; +} diff --git a/apps/desktop/src/shared/settings-ownership.ts b/apps/desktop/src/shared/settings-ownership.ts index 71e76efbf5..1447e03178 100644 --- a/apps/desktop/src/shared/settings-ownership.ts +++ b/apps/desktop/src/shared/settings-ownership.ts @@ -55,6 +55,7 @@ export function clientOwnedSettingsPatch( ...(appearance ? { appearance } : {}), ...(personalization ? { personalization } : {}), ...(patch.notifications ? { notifications: patch.notifications } : {}), + ...(patch.workHub ? { workHub: patch.workHub } : {}), ...(patch.projects ? { projects: patch.projects } : {}), ...(patch.system ? { system: patch.system } : {}), }; @@ -95,6 +96,7 @@ export function projectClientOwnedSettings( onboarding: client.onboarding, projects: client.projects, notifications: client.notifications, + workHub: client.workHub, system: client.system, }; } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index f1223a3c87..8569a8d979 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 202 files — blocker 0, polish 0, aligned 202. +**Totals:** 204 files — blocker 0, polish 0, aligned 204. ## Exclusions (explicit) @@ -30,7 +30,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/app-shell-chrome-actions.tsx` | shell-chrome-or-panel | IconButton, Tooltip | aligned — uses Astryx (IconButton, Tooltip) | aligned | | `apps/desktop/src/renderer/app-shell-detail-panel.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/app-shell-overlays.tsx` | shell-chrome-or-panel | Spinner | aligned — uses Astryx (Spinner) | aligned | -| `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell | aligned — uses Astryx (AppShell) | aligned | +| `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell, Button | aligned — uses Astryx (AppShell, Button) | aligned | | `apps/desktop/src/renderer/app.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/cascade-layers.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/chat-composer-region.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned | @@ -171,6 +171,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/styles/workbar/shell.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/workbar/side-chat.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/workbar/terminal.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `apps/desktop/src/renderer/styles/workhub.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `apps/desktop/src/renderer/workhub-surface.tsx` | other | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/astryx-chat-reasoning.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/astryx-i18n.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/attachment-kinds.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index c26bb527e5..e612562a0a 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -143,6 +143,8 @@ apps/desktop/src/renderer/styles/workbar/review.css apps/desktop/src/renderer/styles/workbar/shell.css apps/desktop/src/renderer/styles/workbar/side-chat.css apps/desktop/src/renderer/styles/workbar/terminal.css +apps/desktop/src/renderer/styles/workhub.css +apps/desktop/src/renderer/workhub-surface.tsx packages/ui/src/astryx-chat-reasoning.tsx packages/ui/src/astryx-i18n.tsx packages/ui/src/attachment-kinds.tsx diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md new file mode 100644 index 0000000000..3d0231fd80 --- /dev/null +++ b/docs/workhub-domain-language.md @@ -0,0 +1,36 @@ + + +# WorkHub domain language + +WorkHub gives users one conversational place to continue, create, and inspect work while ordinary Sessions remain the product's canonical work records. + +## Terms + +**Session**: The authoritative record for identity, transcript, execution state, permissions, interactions, and recovery. A Session ID is the stable identity of the work. + +**Work**: The user-facing continuity of exactly one ordinary Session. “Work” is a product-language view of a Session, not a second stored record. + +**WorkHub**: A projection and routing surface over ordinary Sessions. It may keep transient inference context while mounted, but it does not own a transcript or execution state. + +**Session projection**: A rebuildable view derived from Session facts for display and routing. It can be discarded and recreated without losing work. + +**Route correction**: A user's decision that an input belongs to a different existing Session. It may influence later transient routing, but it does not become an authority for Session content or state. + +_Avoid_: independent Work records, copied transcripts, or a second writable WorkHub state store. diff --git a/packages/cli/src/__tests__/tui-session-status.test.ts b/packages/cli/src/__tests__/tui-session-status.test.ts index f9e7089f74..1336d599e6 100644 --- a/packages/cli/src/__tests__/tui-session-status.test.ts +++ b/packages/cli/src/__tests__/tui-session-status.test.ts @@ -1,3 +1,22 @@ +/* + * 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 { describe, test } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; diff --git a/packages/cli/src/tui-session-status.ts b/packages/cli/src/tui-session-status.ts index 409d4eaa2b..40747880d9 100644 --- a/packages/cli/src/tui-session-status.ts +++ b/packages/cli/src/tui-session-status.ts @@ -1,3 +1,22 @@ +/* + * 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 type { SessionSummary } from '@maka/core/session'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; diff --git a/packages/core/src/__tests__/settings.test.ts b/packages/core/src/__tests__/settings.test.ts index c2424e53b4..2cc040cd63 100644 --- a/packages/core/src/__tests__/settings.test.ts +++ b/packages/core/src/__tests__/settings.test.ts @@ -173,3 +173,13 @@ test('an app icon that never passed normalization still coerces to the brand mar expect(toAppIconChoice('sky')).toBe('sky'); expect(toAppIconChoice(`custom:${'a'.repeat(32)}`)).toBe(`custom:${'a'.repeat(32)}`); }); + +test('WorkHub stays opt-in and malformed persisted values fail closed', () => { + const defaults = createDefaultSettings(); + expect(defaults.workHub).toEqual({ enabled: false }); + expect(normalizeSettings({ workHub: { enabled: true } }).workHub).toEqual({ enabled: true }); + expect(normalizeSettings({ workHub: { enabled: 'yes' } }).workHub).toEqual({ enabled: false }); + expect(mergeSettings(defaults, { workHub: { enabled: true } }).workHub).toEqual({ + enabled: true, + }); +}); diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 1c8e7dc53f..e3467dad00 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -326,6 +326,11 @@ export interface NotificationSettings { runComplete: boolean; } +/** Client-owned opt-in for the cross-Session WorkHub router. */ +export interface WorkHubSettings { + enabled: boolean; +} + /** * System-level power behavior (Settings surface: the 定时任务 page's * capability row). Scheduled tasks are driven by an in-process timer; when @@ -367,6 +372,7 @@ export interface AppSettings { chatDefaults: ChatDefaultsSettings; projects: ProjectPreferencesSettings; notifications: NotificationSettings; + workHub: WorkHubSettings; system: SystemSettings; shell: ShellSettings; subagents: SubagentSettings; @@ -470,6 +476,7 @@ export type UpdateAppSettingsInput = Partial<{ chatDefaults: Partial; projects: Partial; notifications: Partial; + workHub: Partial; system: Partial; shell: Partial; webSearch: WebSearchSettingsPatch; @@ -548,6 +555,9 @@ export function createDefaultSettings(): AppSettings { notifications: { runComplete: true, }, + workHub: { + enabled: false, + }, system: { // Off by default: holding a power-save blocker is an explicit, // battery-affecting opt-in, not a silent default. @@ -624,6 +634,10 @@ export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInpu ...current.notifications, ...(patch.notifications ?? {}), }, + workHub: { + ...current.workHub, + ...(patch.workHub ?? {}), + }, system: { ...current.system, ...(patch.system ?? {}), @@ -657,6 +671,7 @@ export function normalizeSettings(input: unknown): AppSettings { chatDefaults: value.chatDefaults, projects: value.projects, notifications: value.notifications, + workHub: value.workHub, system: value.system, shell: value.shell, subagents: value.subagents, @@ -735,6 +750,9 @@ export function normalizeSettings(input: unknown): AppSettings { runComplete: typeof base.notifications.runComplete === 'boolean' ? base.notifications.runComplete : true, }, + workHub: { + enabled: typeof base.workHub.enabled === 'boolean' ? base.workHub.enabled : false, + }, // Fail-closed boolean coercion, same reasoning as // `notifications.runComplete`: a non-boolean `keepSystemAwake` (from a // hand-edited or legacy settings.json) must not reach the main-process diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 332c26c701..8c9fb8cb12 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -77,6 +77,11 @@ export function SessionListPanel(props: { updateReminder?: SidebarUpdateReminder; onOpenUpdate?(): void; onNew(): void; + workHubEntry?: { + active: boolean; + label: string; + onSelect(): void; + }; rowActions?: SessionRowActions; }) { const copy = getConversationCopy(useUiLocale()).sessions; @@ -170,6 +175,7 @@ export function SessionListPanel(props: { moduleMemory={props.moduleMemory} onSelect={props.onSelect} onNew={props.onNew} + workHubEntry={props.workHubEntry} /> {groupingSwitch} diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx index e0d5e97459..4df03e1be0 100644 --- a/packages/ui/src/session-sidebar-nav.tsx +++ b/packages/ui/src/session-sidebar-nav.tsx @@ -18,7 +18,7 @@ */ import type { ScheduledTask } from '@maka/core/scheduled-task'; -import { AlertCircle, Blocks, Download, Settings, SquarePen, Timer } from './icons.js'; +import { AlertCircle, Blocks, Download, Network, Settings, SquarePen, Timer } from './icons.js'; import type { NavModuleMemory, NavSelection } from './nav-selection.js'; import { useUiLocale } from './locale-context.js'; import { getShellControlsCopy } from './shell-controls-copy.js'; @@ -33,6 +33,11 @@ export function SessionSidebarNav(props: { moduleMemory?: NavModuleMemory; onSelect(selection: NavSelection): void; onNew(): void; + workHubEntry?: { + active: boolean; + label: string; + onSelect(): void; + }; }) { const locale = useUiLocale(); const copy = getShellControlsCopy(locale).navigation; @@ -63,6 +68,15 @@ export function SessionSidebarNav(props: { onClick={props.onNew} endContent={} /> + {props.workHubEntry ? ( + + ) : null} {/* No 任务 row. Expanded, the list below IS that row's destination, and a control that selects what is already on screen under it is the same redundancy as the 会话 list heading this change deleted one row down.