diff --git a/apps/desktop/e2e/workhub-pending-question.spec.ts b/apps/desktop/e2e/workhub-pending-question.spec.ts index c8e40da6a4e..4e98ecb49a1 100644 --- a/apps/desktop/e2e/workhub-pending-question.spec.ts +++ b/apps/desktop/e2e/workhub-pending-question.spec.ts @@ -33,7 +33,9 @@ test('a pending WorkHub question preserves docked placement, choices and focus a const choice = hub.getByRole('option', { name: /公开测试/ }); await expect(choice).toBeVisible(); await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked'); - await choice.click(); + // Pointer activation advances immediately; keyboard selection keeps this + // question pending while Electron transfers focus between native windows. + await hub.locator('.maka-choice-panel').press('2'); await app.evaluate(async ({ app }) => { if (process.platform === 'darwin') await app.dock!.show(); }); await hub.evaluate(() => window.maka.workHubPresentation.detach()); await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'floating'); diff --git a/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts b/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts index 95b3462d007..e1594d270d9 100644 --- a/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts @@ -126,3 +126,21 @@ describe('bot session lifecycle bindings', () => { assert.deepEqual(replies, ['Maka 暂时无法处理这条消息:请求超时']); }); }); + +test('WorkHub routing bypasses Bot Session creation, explore preparation and reset commands', async () => { + let routed = 0; + let closed = false; + const unexpected = async (): Promise => { throw new Error('Bot Session must not be used'); }; + const service = createBotIncomingMainService({ + sessions: { createSession: unexpected, prepareSession: unexpected, runTurn: unexpected }, + botRegistry: { sendMessage: unexpected } as unknown as BotRegistry, + workHub: { + async handle(_message, accept) { assert.equal(accept?.(), true); routed++; return true; }, + async close() { closed = true; }, + }, + }); + await service.handleBotIncomingMessage({ platform: 'qq', userId: 'user', userName: 'User', chatId: 'chat', isGroup: false, receivedAt: 1, text: '重置', sourceMessageId: 'source' }); + assert.equal(routed, 1); + await service.close(); + assert.equal(closed, true); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts index ad18f6601ca..97267a0b9e6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -43,57 +43,6 @@ test('registers the WorkHub Session projection as a reconnectable read', () => { assert.equal(ordinary.has('workhub:getSession'), false); }); -test('stores new-work execution defaults separately from the coordination Session', async () => { - const handlers = new Map(); - const client = { hostId: 'host-defaults' } as Parameters< - typeof registerRuntimeHostWorkHubIpc - >[0]; - registerRuntimeHostWorkHubIpc( - client, - { - handle(channel, handler) { - handlers.set(channel, handler); - }, - }, - {}, - ); - const event = { sender: { id: 7 } } as Parameters[0]; - const read = handlers.get('workhub:getNewWorkDefaults'); - const write = handlers.get('workhub:setNewWorkDefaults'); - assert.ok(read); - assert.ok(write); - assert.deepEqual(await read(event), {}); - const defaults = { - executorId: 'codex.app-server', - executorModel: 'gpt-6-astra', - thinkingLevel: 'high', - }; - await write(event, defaults); - assert.deepEqual(await read(event), defaults); - assert.throws( - () => write(event, { ...defaults, permissionMode: 'bypass' }), - /Invalid WorkHub new-work defaults/u, - ); - - const replacementHandlers = new Map(); - registerRuntimeHostWorkHubIpc( - { hostId: client.hostId } as Parameters[0], - { - handle(channel, handler) { - replacementHandlers.set(channel, handler); - }, - }, - {}, - ); - const replacementRead = replacementHandlers.get('workhub:getNewWorkDefaults'); - assert.ok(replacementRead); - assert.deepEqual( - await replacementRead(event), - defaults, - 'replacement clients for the same Host retain the execution defaults', - ); -}); - test('returns a structured WorkHub attachment rejection across IPC', async () => { const handlers = new Map(); registerRuntimeHostWorkHubIpc( diff --git a/apps/desktop/src/main/__tests__/workhub-dock.test.ts b/apps/desktop/src/main/__tests__/workhub-dock.test.ts index eec06815400..a961e103dbe 100644 --- a/apps/desktop/src/main/__tests__/workhub-dock.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-dock.test.ts @@ -24,6 +24,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { deferred } from '@maka/core/test-only/async-primitives'; import { LocaleProvider } from '@maka/ui'; +import { WorkHubEnablementProvider } from '../../renderer/application/contracts/workhub-workspace/workhub-enablement.js'; import { WorkHubDock, WorkHubServicesProvider, type WorkHubServices } from '../../renderer/features/workhub/index.js'; import type { WorkHubHost, WorkHubPresentationSnapshot } from '../../shared/workhub-presentation.js'; @@ -96,7 +97,8 @@ async function mount() { } } as unknown as WorkHubServices; root = createRoot(document.querySelector('#root')!); await act(async () => root!.render(createElement(LocaleProvider, { locale: 'en', children: - createElement(WorkHubServicesProvider, { services }, createElement(WorkHubDock, { enabled: true, workbar: { bottomOpen: false, rightCollapsed: true } })), + createElement(WorkHubEnablementProvider, { value: { isEnabled: () => true, subscribe: () => () => {} } }, + createElement(WorkHubServicesProvider, { services }, createElement(WorkHubDock, { workbar: { bottomOpen: false, rightCollapsed: true } }))), }))); const frame = async (count = 1) => { for (let index = 0; index < count; index++) await act(async () => { diff --git a/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts b/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts index 8f0d6d4b367..fefa5874bec 100644 --- a/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts @@ -22,9 +22,9 @@ import test from "node:test"; import { createElement } from "react"; import { parseHTML } from 'linkedom'; import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui'; -import { WorkHubConversation, workHubLinkedWork } from '../../renderer/features/workhub/testing.js'; +import { WorkHubConversation, workHubLinkedWork, workHubTurnContexts } from '../../renderer/features/workhub/testing.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; -import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; +import type { StoredMessage, ToolCallMessage, ToolResultMessage, WorkHubDelegationAssignedMessage } from '@maka/core/session'; test('WorkHub uses the common answer footer and failure presentation without prompt status or branch actions', async () => { const ts = 1_800_000_000_000; @@ -118,3 +118,85 @@ test('stop and resume keep their scoped Session identity through pending, succes assert.deepEqual(workHubLinkedWork([{ ...call, toolName: 'unrelated' }, success], catalog, 'Work', coordination), []); } }); + + +test('WorkHub restores actual answers and accepted handoffs without displaying proposed work as sent', async () => { + const call: ToolCallMessage = { type: 'tool_call', id: 'question', turnId: 'learn', ts: 2, toolName: 'AskUserQuestion', args: {} }; + const answer: ToolResultMessage = { type: 'tool_result', id: 'answer', turnId: 'learn', ts: 3, toolUseId: call.id, isError: false, content: { kind: 'json', value: { answers: [ + { question: '怎么学?', answer: '直接开练作业' }, + { question: '基础?', answer: '会 Python,但没做过 LLM' }, + { question: '跳过的问题', answer: null }, + ] } } }; + const assignment: WorkHubDelegationAssignedMessage = { + type: 'workhub_coordination', kind: 'delegation_assigned', schemaVersion: 1, + id: 'assignment', turnId: 'learn', coordinationTurnId: 'learn', ts: 5, + actionId: 'action', actionFingerprint: `sha256:${'a'.repeat(64)}`, delegationId: 'delegation', + targetSessionId: 'target', targetSessionName: 'CS336 学习', targetTurnId: 'target-turn', targetMessageId: 'target-message', + disposition: 'create_new', userText: '我想学一下 CS336 这门课程', + delegationText: '用户原话:我想学一下 CS336 这门课程。\n补充选择:直接开练作业;会 Python,但没做过 LLM。', + }; + const messages: StoredMessage[] = [ + { type: 'user', id: 'user', turnId: 'learn', ts: 1, text: assignment.userText }, call, answer, + { type: 'tool_call', id: 'proposal', turnId: 'learn', ts: 4, toolName: 'mcp__desktop_workhub__tasks', args: { request: { operation: 'create_new', text: 'UNACCEPTED PROPOSAL' } } }, + assignment, + ]; + const context = workHubTurnContexts(messages).get('learn')!; + assert.equal(context.answers.length, 2); + assert.equal(context.handoffs[0]?.text, assignment.delegationText); + assert.equal(workHubTurnContexts([call, { ...answer, isError: true }]).size, 0); + assert.equal(workHubTurnContexts([{ ...call, toolName: 'OtherTool' }, answer]).size, 0); + assert.equal(workHubTurnContexts([call, { ...answer, turnId: 'other' }]).size, 0); + assert.equal(workHubTurnContexts([answer]).size, 0); + assert.deepEqual(workHubTurnContexts([...messages, answer, assignment]), workHubTurnContexts(messages)); + assert.equal(workHubTurnContexts([{ ...assignment, delegationText: undefined }]).get('learn')?.handoffs[0]?.text, assignment.userText); + assert.equal(workHubTurnContexts([assignment, { ...assignment, id: 'second', delegationText: '第二次交接' }]).get('learn')?.handoffs.length, 2); + const handoffCall: ToolCallMessage = { ...call, id: 'handoff-call', toolName: 'mcp__desktop_workhub__tasks', args: { request: { operation: 'create_new', title: 'CS336 学习', text: assignment.delegationText } } }; + const receipt = { actionId: assignment.actionId, disposition: 'create_new', targetSessionKey: 'target' }; + const handoffResult: ToolResultMessage = { ...answer, id: 'handoff-result', toolUseId: handoffCall.id, content: { kind: 'json', value: { structuredContent: receipt } } }; + assert.equal(workHubTurnContexts([handoffCall]).size, 0); + assert.equal(workHubTurnContexts([handoffCall, { ...handoffResult, isError: true }]).size, 0); + assert.equal(workHubTurnContexts([handoffCall, { ...handoffResult, content: { kind: 'json', value: { kind: 'cancelled' } } }]).size, 0); + assert.equal(workHubTurnContexts([handoffCall, handoffResult]).get('learn')?.handoffs[0]?.text, assignment.delegationText); + assert.equal(workHubTurnContexts([handoffCall, handoffResult, assignment]).get('learn')?.handoffs.length, 1); + assert.equal(workHubTurnContexts([handoffCall, { ...handoffResult, content: { kind: 'text', text: JSON.stringify(receipt) } }]).get('learn')?.handoffs[0]?.text, assignment.delegationText); + const markup = await renderTranscriptMarkup(createElement(LocaleProvider, { locale: 'zh-CN', children: null }, + createElement(ChatSurfaceLayout, { composer: null, children: null }, createElement(WorkHubConversation, { + activeSession: { id: 'coordination', name: 'WorkHub', status: 'active', labels: [], isFlagged: false, isArchived: false, hasUnread: false, backend: 'ai-sdk', llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask' }, + messages, workLinks: [], onNew: () => {}, onOpenWork: () => {}, scrollBehavior: 'auto', + })), + )); + const { document } = parseHTML(markup); + assert.match(document.querySelector('.workhub-clarification-summary')!.textContent!, /直接开练作业/); + assert.doesNotMatch(document.querySelector('.workhub-turn-context')!.textContent!, /UNACCEPTED PROPOSAL|跳过的问题/); + assert.equal(document.querySelector('.workhub-handoff'), null); +}); + + +test('task result replies keep their source Work rail even without a new delegation or older history', async () => { + const target = JSON.stringify(['host-a', 'task-a']); + const coordination = JSON.stringify(['host-a', 'maka_workhub_coordination']); + const messages: StoredMessage[] = [ + { type: 'user', id: 'notification', turnId: 'result-turn', ts: 1, text: 'Task result received', + origin: { kind: 'workhub_result', eventId: 'event', actionId: 'action', delegationId: 'delegation', targetSessionId: 'task-a', targetTurnId: 'task-turn' } }, + { type: 'assistant', id: 'summary', turnId: 'result-turn', ts: 2, text: 'The first exercise is ready.', modelId: 'test' }, + ]; + const links = workHubLinkedWork(messages, [{ id: target, name: 'SQL practice', cwd: '/projects/sql' }, + { id: JSON.stringify(['host-b', 'task-a']), name: 'Other host' }], 'Work', coordination); + assert.equal(links.length, 1); + assert.equal(links[0]?.targetSessionId, target); + assert.equal(links[0]?.coordinationTurnId, 'result-turn'); + assert.equal(links[0]?.workspaceName, 'sql'); + assert.equal(workHubLinkedWork(messages, [], 'Work', coordination)[0]?.targetSessionId, target); + assert.deepEqual(workHubLinkedWork(messages, [], 'Work'), []); + const markup = await renderTranscriptMarkup(createElement(LocaleProvider, { locale: 'en', children: null }, + createElement(ChatSurfaceLayout, { composer: null, children: null }, createElement(WorkHubConversation, { + activeSession: { id: coordination, name: 'WorkHub', status: 'active', labels: [], isFlagged: false, isArchived: false, hasUnread: false, backend: 'ai-sdk', llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask' }, + messages, workLinks: links, onNew: () => {}, onOpenWork: () => {}, scrollBehavior: 'auto', + })), + )); + const { document } = parseHTML(markup); + const rail = document.querySelector('.maka-assistant-answer .workhub-message-rail'); + assert.ok(rail, 'the result reply must have the same clickable rail as its source Work'); + assert.equal(rail.getAttribute('data-work-session-id'), target); + assert.doesNotMatch(rail.getAttribute('aria-label') ?? '', /Task result received|result-turn/); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts b/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts new file mode 100644 index 00000000000..fd3811d19ea --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { WORKHUB_COORDINATION_SESSION_ID as sessionId, type StoredMessage } from '@maka/core/session'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import type { BotIncomingMessage } from '@maka/runtime/bots'; +import type { InteractionPendingSnapshot, SessionContinuitySnapshot, SubscriptionFrame } from '@maka/runtime-host/protocol'; +import { createWorkHubRemoteBridge } from '../workhub-remote-bridge.js'; +import { AsyncFrameQueue, runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; + +type Deps = Parameters[0]; +const message = (chatId: string, text: string, sourceMessageId = text): BotIncomingMessage => ({ platform: 'qq', chatId, userId: 'owner', userName: 'Owner', isGroup: false, text, sourceMessageId, receivedAt: 1 }); +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'workhub-remote-')); + const sends: { chat: string; text: string }[] = []; + const starts: { turnId: string; text: string }[] = []; + const answers: unknown[] = []; + const errors: unknown[] = []; + let rows: StoredMessage[] = []; + let events = new AsyncFrameQueue(); + let pending: InteractionPendingSnapshot[] = []; + let mode: 'task' | 'workhub' = 'workhub'; + let completed = false; + let busy = false; + const snapshot = (): SessionContinuitySnapshot => ({ schemaVersion: 1, session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, projectionRevision: 1, rootTurn: null, goal: null, queue: { messages: [] }, interactions: { pending } } as unknown as SessionContinuitySnapshot); + const client: Deps['client'] = { + openSession: async (id) => { + assert.equal(id, sessionId); + const ownEvents = events; + return runtimeHostSessionFixture({ snapshot: snapshot(), events: ownEvents, + transcriptWatermark: () => rows.length ? rows.length - 1 : null, + loadTranscriptPage: async (input) => ({ kind: 'page', sessionId, direction: 'newer', throughSequence: rows.length - 1, rawBytes: 0, fragments: [], nextCursor: null, endsAtTurnBoundary: true, anchor: input.anchorSequence } as never), + decodeTranscriptPage: async (page) => ({ messages: rows.map((message, identity) => ({ message, identity })).filter(({ identity }) => identity > ((page as unknown as { anchor: number | null }).anchor ?? -1)), nextCursor: null }), + close: async () => { ownEvents.end(); }, + }); + }, + resolveWorkHubCoordinationSession: async () => ({ sessionId }), + answerWorkHubCoordination: async (input) => { if (busy) throw new RuntimeHostOperationError('workhub.coordination.answer', 'session_busy', 'busy'); starts.push(input); return { turnId: input.turnId }; }, + answerInteraction: async (input) => { answers.push(input); pending = []; return {} as never; }, + queryInteraction: async (input) => pending.find((item) => item.interactionId === input.interactionId)!, + queryTurn: async ({ turnId }) => ({ sessionId, turnId, runId: 'run', status: completed ? 'completed' : 'running' } as never), + subscribeSessionCatalogChanges: () => () => {}, + }; + const deps: Deps = { client, statePath: join(directory, 'routes.json'), readMode: async () => mode, + botRegistry: { sendMessage: async (_platform, chat, text) => { sends.push({ chat, text }); return 'sent'; } }, onError: (error) => { errors.push(error); } }; + let bridge = createWorkHubRemoteBridge(deps); + const tick = async () => { for (let i = 0; i < 30; i++) await new Promise((resolve) => setTimeout(resolve, 2)); }; + return { + sends, starts, answers, errors, get bridge() { return bridge; }, + setBusy(value: boolean) { busy = value; }, + setMode(value: typeof mode) { mode = value; }, + async project(value: InteractionPendingSnapshot[]) { pending = value; events.push({ kind: 'subscription.session_projection', snapshot: snapshot() } as SubscriptionFrame); await tick(); }, + async transcript(value: StoredMessage[], done = true) { rows = value; completed = done; events.push({ kind: 'subscription.transcript_advanced', throughSequence: rows.length - 1 } as SubscriptionFrame); await tick(); }, + async restart() { await bridge.close(); events = new AsyncFrameQueue(); bridge = createWorkHubRemoteBridge(deps); await tick(); }, + async close() { await bridge.close(); await rm(directory, { recursive: true, force: true }); assert.deepEqual(errors, []); }, + }; +} +function question(turnId: string): InteractionPendingSnapshot { + return { schemaVersion: 1, revision: 1, status: 'pending', outcome: null, sessionId, turnId, runId: 'run', interactionId: 'question-1', request: { kind: 'question', toolUseId: 'ask', questions: ['项目', '范围', '测试'].map((question) => ({ question, options: ['甲', '乙', '丙'].map((label) => ({ label })) })) } }; +} +test('all chats use one WorkHub; questions route to their source and survive restart', async () => { + const f = await fixture(); + try { + assert.equal(await f.bridge.handle(message('one', '修改 Maka')), true); + const turnId = f.starts[0]!.turnId; + await f.project([question(turnId)]); + assert.equal(f.sends.length, 1); + assert.equal(f.sends[0]!.chat, 'one'); + assert.ok(f.sends[0]!.text.includes('3. 测试')); + await f.restart(); + assert.equal(f.sends.length, 1, 'recovery does not resend an already delivered question'); + await f.bridge.handle(message('two', 'ACC')); + assert.equal(f.answers.length, 0, 'another chat cannot accidentally answer this request'); + await f.bridge.handle(message('one', 'AZC')); + assert.equal(f.answers.length, 0); + await f.bridge.handle(message('one', '133')); + assert.deepEqual(f.answers, [{ sessionId, interactionId: 'question-1', answer: { kind: 'question', answers: ['甲', '丙', '丙'] } }]); + assert.equal(f.starts.length, 2, 'answers resume the interaction instead of starting a new Turn'); + } finally { await f.close(); } +}); +test('delegated asynchronous result returns to original chat after restart, never the last chat', async () => { + const f = await fixture(); + try { + await f.bridge.handle(message('one', '实现功能')); + const firstTurn = f.starts[0]!.turnId; + await f.bridge.handle(message('two', '其他事情')); + const assignment = { type: 'workhub_coordination', kind: 'delegation_assigned', actionId: 'action-1', coordinationTurnId: firstTurn } as StoredMessage; + await f.transcript([assignment]); + await f.restart(); + await f.transcript([assignment, + { type: 'user', id: 'feedback', turnId: 'feedback', ts: 1, text: 'result', origin: { kind: 'workhub_result', actionId: 'action-1', eventId: 'event', delegationId: 'd', targetSessionId: 'target', targetTurnId: 'target-turn' } }, + { type: 'assistant', id: 'reply', turnId: 'feedback', ts: 2, text: '功能完成', modelId: 'test' }, + ]); + assert.deepEqual(f.sends, [{ chat: 'one', text: '功能完成' }]); + await f.restart(); + assert.equal(f.sends.length, 1); + } finally { await f.close(); } +}); +test('task setting leaves the existing Bot Session route in charge', async () => { + const f = await fixture(); + try { f.setMode('task'); assert.equal(await f.bridge.handle(message('one', 'hello')), false); assert.equal(f.starts.length, 0); } + finally { await f.close(); } +}); + +test('busy WorkHub queues messages durably and dispatches after restart without changing their source', async () => { + const f = await fixture(); + try { + f.setBusy(true); + await f.bridge.handle(message('one', '先做这个')); + await f.bridge.handle(message('two', '再做那个')); + assert.equal(f.starts.length, 0); + assert.equal(f.sends.length, 2); + f.setBusy(false); + await f.restart(); + assert.equal(f.starts.length, 1); + await f.project([]); + assert.equal(f.starts.length, 2); + assert.ok(f.starts[0]!.text.includes('先做这个')); + assert.ok(f.starts[1]!.text.includes('再做那个')); + await f.bridge.handle(message('one', '先做这个')); + assert.equal(f.starts.length, 2, 'same incoming source message is not admitted twice'); + } finally { await f.close(); } +}); diff --git a/apps/desktop/src/main/__tests__/workhub-remote-choices.test.ts b/apps/desktop/src/main/__tests__/workhub-remote-choices.test.ts new file mode 100644 index 00000000000..930cd3f1949 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-remote-choices.test.ts @@ -0,0 +1,47 @@ +/* + * 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 type { InteractionRequest } from '@maka/core/interaction'; +import { formatRemoteChoices, parseRemoteChoices } from '../workhub-remote-choices.js'; + +const request: InteractionRequest = { kind: 'question', toolUseId: 'ask', questions: ['项目', '范围', '测试'].map((question) => ({ question, options: ['甲', '乙', '丙'].map((label) => ({ label })) })) }; +test('three questions travel in one message; ACC and 133 produce the same exact answers', () => { + const text = formatRemoteChoices(request, 'abcd1234')!; + for (const label of ['1. 项目', '2. 范围', '3. 测试', '#abcd1234', 'C/3. 丙']) assert.ok(text.includes(label)); + const expected = { kind: 'answer', answer: { kind: 'question', answers: ['甲', '丙', '丙'] } }; + assert.deepEqual(parseRemoteChoices(request, 'ACC'), expected); + assert.deepEqual(parseRemoteChoices(request, '133'), expected); + assert.deepEqual(parseRemoteChoices(request, 'a / c / c'), expected); +}); +test('bad count and out-of-range answers stay invalid; prose stays text', () => { + for (const text of ['AC', '144', 'AZC', 'A / / C']) assert.equal(parseRemoteChoices(request, text).kind, 'invalid'); + assert.equal(parseRemoteChoices(request, '先修改 Maka,测试稍后再说').kind, 'text'); + assert.equal(parseRemoteChoices(request, 'please explain the choices').kind, 'text'); +}); +test('task chooser sends opaque option values, and multi-select preserves all selections', () => { + const form: InteractionRequest = { kind: 'form', toolUseId: 'choose', message: '选择任务', requester: { name: 'WorkHub' }, fields: [ + { kind: 'single_select', name: 'target', label: '任务', required: true, options: [{ label: 'Maka', value: 'opaque-ref' }, { label: '新任务', value: 'create_new' }] }, + { kind: 'multi_select', name: 'scope', label: '范围', required: true, minItems: 1, maxItems: 2, options: [{ label: 'UI', value: 'ui' }, { label: 'Host', value: 'host' }, { label: 'Runtime', value: 'runtime' }] }, + ] }; + assert.deepEqual(parseRemoteChoices(form, 'B / AC'), { kind: 'answer', answer: { kind: 'form', action: 'accept', values: { target: 'create_new', scope: ['ui', 'runtime'] } } }); + assert.equal(parseRemoteChoices(form, 'A / ABC').kind, 'invalid'); + assert.equal(parseRemoteChoices(form, 'A / AA').kind, 'invalid'); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts index 34a0613ef07..e112dc953bc 100644 --- a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts @@ -33,7 +33,7 @@ function fixture() { const deps: Parameters[0] = { isCurrent: () => current, client: () => client, - createContext: async () => ({ workspace: { kind: 'project', projectId: 'project' }, defaults: { permissionMode: 'ask' } }), + createContext: async () => ({ workspace: { kind: 'project', projectId: 'project' } }), changed: (...args) => { changes.push(args); }, }; const client = { @@ -54,7 +54,6 @@ test('task delegation binds the tool action to the Host turn and trusted creatio assert.deepEqual(f.requests, [{ turnId: 'turn', actionId: 'tool-call', proposal: { disposition: 'create_new', title: 'Fix login' }, delegationText: 'Implement and test the login fix', create: { workspace: { kind: 'project', projectId: 'project' } }, - newWorkDefaults: { permissionMode: 'ask' }, }]); assert.ok('actionId' in result); assert.equal(result.actionId, 'tool-call'); @@ -95,11 +94,9 @@ test('nested tool calls produce stable task identities accepted by both Host act test('linked task operations remain operations at the Host protocol boundary', async () => { const f = fixture(); - await f.runtime.actTasks(scope, 'turn', 'correct-action', { - operation: 'correct', - replacesActionId: 'old-action', - candidateSetId: 'set', - target: { disposition: 'delegate_existing', candidateRef: 'candidate' }, + await f.runtime.actTasks(scope, 'turn', 'steer-action', { + operation: 'delegate_existing', + candidateRef: 'candidate', text: 'Move the delegated work', }); await f.runtime.actTasks(scope, 'turn', 'stop-action', { @@ -115,14 +112,11 @@ test('linked task operations remain operations at the Host protocol boundary', a assert.deepEqual(f.requests, [ { turnId: 'turn', - actionId: 'correct-action', + actionId: 'steer-action', proposal: { - operation: 'correct', - replacesActionId: 'old-action', - target: { disposition: 'delegate_existing', candidateRef: 'candidate' }, + disposition: 'delegate_existing', candidateRef: 'candidate', }, delegationText: 'Move the delegated work', - candidateSetId: 'set', }, { turnId: 'turn', @@ -141,14 +135,14 @@ test('linked task operations remain operations at the Host protocol boundary', a ]); }); -test('the task tool exposes correction as a linked operation, not a disposition', () => { +test('the task tool rejects atomic correction operations', () => { const correction = { operation: 'correct', replacesActionId: 'old-action', target: { disposition: 'create_new', title: 'Replacement' }, text: 'Correct the earlier delegation', }; - assert.deepEqual(workHubTasksSchema.parse(correction), correction); + assert.equal(workHubTasksSchema.safeParse(correction).success, false); assert.equal( workHubTasksSchema.safeParse({ ...correction, operation: 'replace' }).success, false, diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 51249e7bcd1..9b2bd72875b 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -55,7 +55,6 @@ async function mountController(failFirstRead = false, overrides: Partial> = 'admitted'; const executionQueries: string[][] = []; let executionResolutions: Awaited>['resolutions'] = []; - let newWorkDefaults: Awaited> = {}; let onSteer: ((input: Parameters) => void) | undefined; const interrupts: Array<{ sessionId: string; turnId: string; runId: string }> = []; let stopRetractions: string[] = []; @@ -97,10 +96,6 @@ async function mountController(failFirstRead = false, overrides: Partial [], modelChoices: async () => [], setDefaultModel: async () => {}, - getNewWorkDefaults: async () => newWorkDefaults, - setNewWorkDefaults: async (_id: string, defaults: typeof newWorkDefaults) => { - newWorkDefaults = defaults; - }, subscribeHosts: () => () => {}, subscribeAvailability: () => () => {}, subscribeSessions: () => () => {}, @@ -214,70 +209,37 @@ test('WorkHub offers pre-session models and saves the selected default', async ( assert.equal(h.controller.error, undefined); }); -test('WorkHub model selection configures only newly created work', async () => { +test('WorkHub picker configures the persistent coordinating model', async () => { type Session = Awaited>; - const initial = { - id: JSON.stringify(['host-1', 'workhub-coordination']), - revision: 1, model: 'A', llmConnectionId: 'connection', llmConnectionSlug: 'provider', - runningTurnIds: [], - } as unknown as Session; + let saved = { id: JSON.stringify(['host-1', 'workhub-coordination']), revision: 1, + model: 'A', llmConnectionId: 'connection', llmConnectionSlug: 'provider', runningTurnIds: [] } as unknown as Session; let failSave = false; - const requests: Array>> = []; + const requests: Array[1]> = []; const h = await mountController(false, { - getSession: async () => initial, - getNewWorkDefaults: async () => ({}), - setNewWorkDefaults: async (_id, defaults) => { + getSession: async () => saved, + configureModel: async (_id, input) => { if (failSave) throw new Error('configuration failed'); - requests.push(defaults); + requests.push(input); + saved = { ...saved, revision: saved.revision + 1, model: input.modelTarget.model, + thinkingLevel: input.thinkingLevel ?? undefined }; + return { kind: 'committed', session: {} as never }; }, }); await act(async () => { - await h.controller.changeExecutor({ - executorId: 'codex.app-server', - model: 'gpt-6-astra', - thinkingLevel: 'high', - }); - }); - assert.deepEqual(requests[0], { - executorId: 'codex.app-server', - executorModel: 'gpt-6-astra', - thinkingLevel: 'high', + await h.controller.changeModel({ llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'C' }, 'high'); }); - assert.equal(h.controller.session?.model, 'A', 'the coordination Session keeps its own model'); - assert.equal(h.controller.session?.revision, 1); - + assert.equal(requests[0]?.expectedRevision, 1); + assert.equal(h.controller.session?.model, 'C'); + assert.equal(h.controller.session?.revision, 2); await act(async () => { await h.controller.changeThinkingLevel('max'); }); assert.equal(requests.at(-1)?.thinkingLevel, 'max'); - assert.equal(requests.at(-1)?.executorModel, 'gpt-6-astra'); - - await act(async () => { - await h.controller.changeModel({ - llmConnectionId: 'connection', - llmConnectionSlug: 'provider', - model: 'C', - }); - }); - assert.deepEqual(requests.at(-1), { - model: { llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'C' }, - }); - assert.equal(h.controller.session?.model, 'A'); - failSave = true; await act(async () => { - await h.controller.changeModel({ - llmConnectionId: 'connection', - llmConnectionSlug: 'provider', - model: 'D', - }); + await h.controller.changeModel({ llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'D' }); }); - assert.equal(h.controller.newWorkDefaults.model?.model, 'C', 'failed writes retain the saved default'); + assert.equal(h.controller.session?.model, 'C'); assert.equal(h.controller.error, 'configuration failed'); assert.equal(h.controller.configuringModel, false); - - const count = requests.length; - await act(async () => { h.admit('busy-turn'); }); - await act(async () => { await h.controller.changeThinkingLevel('high'); }); - assert.equal(requests.length, count, 'running coordination turns freeze new-work defaults'); }); test('WorkHub stops presenting execution on observation loss while retaining the Stop target', async () => { diff --git a/apps/desktop/src/main/__tests__/workhub-wn-draft.test.ts b/apps/desktop/src/main/__tests__/workhub-wn-draft.test.ts new file mode 100644 index 00000000000..c317c3d7d19 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-wn-draft.test.ts @@ -0,0 +1,45 @@ +/* + * 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 type { StoredMessage } from '@maka/core/session'; +import { completedWorkHubDraft } from '../../renderer/features/workhub/testing.js'; + +const messages: StoredMessage[] = [ + { type: 'user', id: 'u', turnId: 't', ts: 1, text: ' WN ' }, + { type: 'tool_call', id: 'q', turnId: 't', ts: 2, toolName: 'AskUserQuestion', args: {} }, + { type: 'tool_result', id: 'r', turnId: 't', ts: 3, toolUseId: 'q', isError: false, content: { kind: 'json', value: { answers: [{ question: 'Which?', answer: 'SQL' }] } } }, + { type: 'assistant', id: 'a', turnId: 't', ts: 4, modelId: 'test', text: '```text\nContinue SQL, one question at a time.\n```' }, + { type: 'turn_state', id: 's', turnId: 't', ts: 5, status: 'completed' }, +]; +test('only completed newly requested and selected wn drafts enter the composer', () => { + assert.equal(completedWorkHubDraft(messages, new Set()), 'Continue SQL, one question at a time.'); + assert.equal(completedWorkHubDraft(messages.map(m => m.type === 'assistant' ? { ...m, text: m.text.replace('```text', '```') } : m), new Set()), 'Continue SQL, one question at a time.'); + assert.equal(completedWorkHubDraft(messages, new Set(['u'])), undefined); + assert.equal(completedWorkHubDraft(messages, new Set(), 2), undefined); + assert.equal(completedWorkHubDraft(messages.slice(0, -1), new Set()), undefined); + assert.equal(completedWorkHubDraft(messages.filter(m => m.id !== 'r'), new Set()), undefined); + assert.equal(completedWorkHubDraft([...messages, { type: 'turn_state', id: 'f', turnId: 't', ts: 6, status: 'failed' }], new Set()), undefined); +}); +test('unrelated replies and ambiguous code blocks are not draft content', () => { + assert.equal(completedWorkHubDraft(messages.map(m => m.type === 'assistant' ? { ...m, turnId: 'other' } : m), new Set()), undefined); + assert.equal(completedWorkHubDraft(messages.map(m => m.type === 'assistant' ? { ...m, text: m.text + '\n```text\nother\n```' } : m), new Set()), undefined); +}); diff --git a/apps/desktop/src/main/bot-incoming-main.ts b/apps/desktop/src/main/bot-incoming-main.ts index 15b85f649ac..53bd98b6e75 100644 --- a/apps/desktop/src/main/bot-incoming-main.ts +++ b/apps/desktop/src/main/bot-incoming-main.ts @@ -32,6 +32,7 @@ import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; import type { BotIncomingMessage, BotRegistry, BotReplyStream } from '@maka/runtime/bots'; import type { BotSessionAdapter, BotSessionTurnResult } from './bot-session-adapter.js'; import { isBotSessionUnavailableError } from './bot-session-adapter.js'; +import type { WorkHubRemoteBridge } from './workhub-remote-bridge.js'; import { isSessionWorkspaceUnavailableError } from './project-context-root.js'; const BOT_RECENT_SOURCE_EVENT_LIMIT = 1_000; @@ -55,6 +56,7 @@ export interface BotIncomingMainService { interface BotIncomingMainServiceDeps { sessions: BotSessionAdapter; + workHub?: WorkHubRemoteBridge; botRegistry: BotRegistry; } @@ -118,7 +120,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): function close(): Promise { if (closeTask) return closeTask; closed = true; - closeTask = Promise.allSettled([...activeTasks]).then(() => { + closeTask = Promise.allSettled([...activeTasks, deps.workHub?.close()]).then(() => { botConversationSessions.clear(); botConversationQueues.clear(); botRecentSourceEventKeys.clear(); @@ -224,6 +226,14 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): text: string, ): Promise { if (closed) return; + if (deps.workHub) { + try { + if (await deps.workHub.handle(message, () => consumeBotConversationToken(conversationKey))) return; + } catch (error) { + await sendTransientBotNotice(message, generalizedErrorMessageForLocale(error, 'WorkHub 暂时无法处理这条消息', 'zh-CN'), 5 * 60_000); + return; + } + } let replyStream: BotReplyStream | null = null; // PR-BOT-EPHEMERAL-REPLY-0: TTL for system notices (help / reset ack / // fallback errors). Five minutes is long enough for the user to read diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index b0f37b1e518..7857bd5902a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -80,7 +80,6 @@ import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { createWorkHubControl } from './workhub-control.js'; import { createWorkHubPresentation } from './workhub-presentation.js'; import { createWorkHubRuntime } from './workhub-runtime.js'; -import { readWorkHubNewWorkDefaults } from './workhub-new-work-defaults.js'; import { createWindowsAppTray } from './windows-app-tray.js'; import { readableAppIconPath } from './app-icon-surface.js'; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; @@ -757,10 +756,6 @@ const workHubRuntime = createWorkHubRuntime({ const target = requireWorkHubTarget(scope); return { workspace: await currentDesktopWorkspaceTarget(target.policy), - defaults: { - permissionMode: (await settingsStore.get()).chatDefaults.permissionMode, - ...readWorkHubNewWorkDefaults(target.client.hostId), - }, }; }, changed: emitSessionsChanged, @@ -1145,6 +1140,8 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( releaseDesktopInteractionSession, }, botRegistry, + readBotMessageHandling: async () => (await settingsStore.get()).botChat.messageHandling ?? 'task', + botWorkHubStateDirectory: join(userDataDir, 'workhub-remote'), resolveBotCreateTarget: async (target) => ({ workspace: await currentDesktopWorkspaceTarget(target), }), diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 1413dd6b0e7..b8646806d17 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -17,7 +17,10 @@ * under the License. */ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from 'node:path'; +import { createWorkHubRemoteBridge } from './workhub-remote-bridge.js'; +import type { BotMessageHandling } from '@maka/core/bot-chat-settings'; import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; import type { IpcMain } from "electron"; import type { ActiveInteractionRequestEvent } from '@maka/core/events'; @@ -147,6 +150,8 @@ export interface DesktopRuntimeHostCandidateDeps { }; readonly nativeCapabilities: DesktopNativeCapabilityProviderInput; readonly botRegistry: BotRegistry; + readonly readBotMessageHandling?: () => Promise; + readonly botWorkHubStateDirectory?: string; readonly resolveBotCreateTarget: ( target: DesktopRuntimeHostTargetPolicy, ) => Promise<{ readonly workspace: WorkspaceTarget }>; @@ -974,6 +979,14 @@ export async function createDesktopRuntimeHostCandidate( const botIncoming = target.access === 'owner' ? createBotIncomingMainService({ botRegistry: deps.botRegistry, + ...(deps.readBotMessageHandling && deps.botWorkHubStateDirectory ? { + workHub: createWorkHubRemoteBridge({ + client, botRegistry: deps.botRegistry, readMode: deps.readBotMessageHandling, + statePath: join(deps.botWorkHubStateDirectory, `${createHash('sha256').update(client.hostId).digest('hex')}.json`), + onError: reportError, + isActive: deps.isTargetActive, + }), + } : {}), sessions: createRuntimeHostBotSessionAdapter({ client, resolveCreateTarget: () => deps.resolveBotCreateTarget(target), diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index efb177b4fb8..fb80192f3c8 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -18,9 +18,7 @@ */ import { - isWorkHubCreateDefaults, WORKHUB_COORDINATION_SESSION_ID, - type WorkHubCreateDefaults, } from '@maka/core/session'; import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError } from '@maka/runtime-host/client'; @@ -34,10 +32,6 @@ import type { WorkHubPrepareAttachmentsResult, } from '../shared/workhub-conversation.js'; import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js'; -import { - readWorkHubNewWorkDefaults, - writeWorkHubNewWorkDefaults, -} from './workhub-new-work-defaults.js'; type RuntimeHostWorkHubClient = Pick< DesktopRuntimeHostClient, @@ -128,16 +122,6 @@ export function registerRuntimeHostWorkHubIpc( reconciliationUnavailable: async (attempt) => unknown(attempt), }); ipcMain.handle('workhub:configureModel', (_event, input) => client.configureWorkHubModel(input)); - ipcMain.handle('workhub:getNewWorkDefaults', () => readWorkHubNewWorkDefaults(client.hostId)); - ipcMain.handle('workhub:setNewWorkDefaults', (_event, value: unknown) => { - if (!isWorkHubCreateDefaults(value) || value.permissionMode !== undefined) { - throw new Error('Invalid WorkHub new-work defaults'); - } - writeWorkHubNewWorkDefaults( - client.hostId, - value as Omit, - ); - }); ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown): Promise => { if (!options.attachmentIngest) throw new Error('WorkHub attachments are unavailable'); try { diff --git a/apps/desktop/src/main/workhub-new-work-defaults.ts b/apps/desktop/src/main/workhub-new-work-defaults.ts deleted file mode 100644 index d61b070697b..00000000000 --- a/apps/desktop/src/main/workhub-new-work-defaults.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { WorkHubCreateDefaults } from '@maka/core/session'; - -type WorkHubExecutionDefaults = Omit; - -// A transport reconnect replaces the client object but preserves the logical -// Host identity. Keep this Desktop-owned preference on that stable boundary so -// the renderer and the WorkHub creation path cannot disagree after reconnect. -// This remains process-local by design: persisting a user preference across -// app restarts is a separate product decision. -const defaultsByHost = new Map(); - -export function readWorkHubNewWorkDefaults(hostId: string): WorkHubExecutionDefaults { - return defaultsByHost.get(hostId) ?? {}; -} - -export function writeWorkHubNewWorkDefaults( - hostId: string, - defaults: WorkHubExecutionDefaults, -): void { - defaultsByHost.set(hostId, Object.freeze({ ...defaults })); -} diff --git a/apps/desktop/src/main/workhub-remote-bridge.ts b/apps/desktop/src/main/workhub-remote-bridge.ts new file mode 100644 index 00000000000..efa4a04cb53 --- /dev/null +++ b/apps/desktop/src/main/workhub-remote-bridge.ts @@ -0,0 +1,279 @@ +/* + * 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 { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { WORKHUB_COORDINATION_SESSION_ID, type StoredMessage } from '@maka/core/session'; +import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; +import { formatBotMessageForSession } from '@maka/core/bot-events'; +import type { BotMessageHandling } from '@maka/core/bot-chat-settings'; +import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { SESSION_TRANSCRIPT_PAGE_MAX_BYTES, type InteractionPendingSnapshot, type SessionContinuitySnapshot } from '@maka/runtime-host/protocol'; +import type { DesktopRuntimeHostClient, DesktopRuntimeHostSession } from './runtime-host-client.js'; +import { formatRemoteChoices, parseRemoteChoices } from './workhub-remote-choices.js'; + +type Source = Pick; +interface State { + version: 1; + through: number | null; + turns: Record; + actions: Record; + inputs: Record; + replies: Record; + delivered: Record; +} +export interface WorkHubRemoteBridge { + handle(message: BotIncomingMessage, acceptMessage?: () => boolean): Promise; + close(): Promise; +} +interface Deps { + client: Pick; + botRegistry: Pick; + readMode(): Promise; + statePath: string; + isActive?(): boolean; + onError(error: unknown): void; +} + +/** One transport adapter for the canonical WorkHub Session. Routing receipts + * belong to this Desktop/Host pair; task and interaction authority stays in Host. + */ +export function createWorkHubRemoteBridge(deps: Deps): WorkHubRemoteBridge { + let state: State = { version: 1, through: null, turns: {}, actions: {}, inputs: {}, replies: {}, delivered: {} }; + let loaded = false; + let closed = false; + let session: DesktopRuntimeHostSession | undefined; + let snapshot: SessionContinuitySnapshot | undefined; + let lane: Promise = Promise.resolve(); + const serial = (action: () => Promise): Promise => { + const next = lane.then(action, action); + lane = next.catch(() => undefined); + return next; + }; + const reference = (id: string) => createHash('sha256').update(id).digest('hex').slice(0, 8); + const sameChat = (source: Source, message: BotIncomingMessage) => source.platform === message.platform && source.chatId === message.chatId && source.userId === message.userId; + async function load() { + if (loaded) return; + try { + const value = JSON.parse(await readFile(deps.statePath, 'utf8')) as State; + if (value.version !== 1 || !value.turns || !value.actions || !value.inputs || !value.replies || !value.delivered || !(value.through === null || Number.isSafeInteger(value.through))) throw new Error('Invalid WorkHub remote routing state'); + state = value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + loaded = true; + } + async function save() { + await mkdir(dirname(deps.statePath), { recursive: true }); + const temporary = `${deps.statePath}.${randomUUID()}.tmp`; + await writeFile(temporary, JSON.stringify(state), { mode: 0o600 }); + await rename(temporary, deps.statePath); + } + async function send(source: Source, text: string): Promise { + if (closed || deps.isActive?.() === false) throw new Error('WorkHub remote transport is inactive'); + const sent = await deps.botRegistry.sendMessage(source.platform, source.chatId, text, + source.sourceMessageId ? { replyToMessageId: source.sourceMessageId } : undefined); + if (!sent) throw new Error('WorkHub remote reply delivery failed'); + } + async function deliver(key: string, source: Source, text: string) { + const digest = createHash('sha256').update(text).digest('hex'); + if (state.delivered[key] === digest) return; + await send(source, text); + state.delivered[key] = digest; + await save(); + } + function observe(message: StoredMessage) { + if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') { + const source = state.turns[message.coordinationTurnId]; + if (source) state.actions[message.actionId] = source; + } + if (message.type === 'user' && message.origin?.kind === 'workhub_result') { + const source = state.actions[message.origin.actionId]; + if (source) state.turns[message.turnId] = source; + } + if (message.type === 'assistant' && state.turns[message.turnId] && message.text.trim()) state.replies[message.turnId] = message.text; + } + async function dispatch() { + const first = Object.entries(state.inputs)[0]; + if (!first || closed) return; + const [turnId, text] = first; + try { + await deps.client.answerWorkHubCoordination({ turnId, text }); + delete state.inputs[turnId]; + await save(); + } catch (error) { + if (error instanceof RuntimeHostOperationError) { + if (error.code === 'session_busy') return; + if (error.code !== 'outcome_unknown' && error.code !== 'commit_outcome_unknown') { + await deliver(`admission:${turnId}`, state.turns[turnId]!, generalizedErrorMessageForLocale(error, 'WorkHub 无法开始处理这条消息', 'zh-CN')); + delete state.inputs[turnId]; + await save(); + return; + } + } + // A lost response is not a rejection. Retry the same durable Turn ID; + // Host admission deduplicates it even if the first request committed. + deps.onError(error); + } + } + async function reconcile() { + if (!session || closed || deps.isActive?.() === false) return; + const through = session.transcriptWatermark; + if (through !== null && (state.through === null || through > state.through)) { + let cursor: string | null = null; + do { + const page = await session.loadTranscriptPage({ direction: 'newer', throughSequence: through, + anchorSequence: state.through, cursor, maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES }); + const decoded = await session.decodeTranscriptPage(page); + for (const { message } of decoded.messages) observe(message); + cursor = decoded.nextCursor; + } while (cursor !== null); + state.through = through; + await save(); + } + for (const [turnId, text] of Object.entries(state.replies)) { + const turn = await deps.client.queryTurn({ sessionId: WORKHUB_COORDINATION_SESSION_ID, turnId }); + if (turn.status !== 'completed' && turn.status !== 'failed' && turn.status !== 'cancelled') continue; + try { + await deliver(`reply:${turnId}`, state.turns[turnId]!, text); + delete state.replies[turnId]; + await save(); + } catch (error) { deps.onError(error); } + } + for (const interaction of snapshot?.interactions.pending ?? []) { + const source = state.turns[interaction.turnId]; + if (!source) continue; + const text = formatRemoteChoices(interaction.request, reference(interaction.interactionId)) + ?? '这条 WorkHub 请求需要在 Maka 桌面端处理,完成后会继续回复。'; + await deliver(`question:${interaction.interactionId}`, source, text).catch(deps.onError); + } + await dispatch(); + const turn = snapshot?.rootTurn; + if (turn && (turn.status === 'failed' || turn.status === 'cancelled') && state.turns[turn.turnId]) { + await deliver(`failure:${turn.turnId}`, state.turns[turn.turnId]!, turn.status === 'failed' ? `WorkHub 处理失败:${turn.failureClass}` : 'WorkHub 本次处理已取消。'); + } + } + async function connect() { + await load(); + if (session || closed || deps.isActive?.() === false) return; + const opened = await deps.client.openSession(WORKHUB_COORDINATION_SESSION_ID); + session = opened; + snapshot = opened.snapshot; + void (async () => { + try { + for await (const frame of opened.events) { + if (closed) break; + if (frame.kind === 'subscription.closed') break; + if (frame.kind === 'subscription.session_projection') snapshot = frame.snapshot; + if (frame.kind === 'subscription.session_projection' || frame.kind === 'subscription.transcript_advanced') + await serial(reconcile); + } + } catch (error) { if (!closed) deps.onError(error); } + finally { + if (session === opened) session = undefined; + await opened.close().catch(deps.onError); + } + })(); + await opened.ready(); + await reconcile(); + } + function pendingFor(message: BotIncomingMessage): InteractionPendingSnapshot[] { + return (snapshot?.interactions.pending ?? []).filter((item) => { + const source = state.turns[item.turnId]; + return source && sameChat(source, message) && formatRemoteChoices(item.request, reference(item.interactionId)); + }); + } + const unsubscribe = deps.client.subscribeSessionCatalogChanges((frame) => { + if (frame.sessionId !== WORKHUB_COORDINATION_SESSION_ID || closed) return; + void serial(async () => { + await load(); + if (Object.keys(state.turns).length) { await connect(); await reconcile(); } + }).catch(deps.onError); + }); + // Recover known routing receipts without creating or configuring WorkHub. + void serial(async () => { await load(); if (Object.keys(state.turns).length) await connect(); }).catch(deps.onError); + const retry = setInterval(() => { + if (closed) return; + void serial(async () => { await load(); if (Object.keys(state.turns).length) { await connect(); await reconcile(); } }).catch(deps.onError); + }, 5000); + retry.unref(); + return { + handle: (message, acceptMessage) => serial(async () => { + if (closed || deps.isActive?.() === false) return true; + await load(); + const enabled = await deps.readMode() === 'workhub'; + if (!enabled && !session) return false; + if (enabled) await deps.client.resolveWorkHubCoordinationSession(); + await connect(); + const tagged = message.text.trim().match(/^#([a-f0-9]{8})\s+(.+)$/is); + const pending = pendingFor(message).filter((item) => !tagged || reference(item.interactionId) === tagged[1]!.toLowerCase()); + if ((enabled || pending.length) && acceptMessage && !acceptMessage()) { + await send(message, 'Maka 收到的机器人消息过于频繁,请稍后再试。'); + return true; + } + const text = tagged?.[2] ?? message.text; + if (!enabled && !pending.length) return false; + if (tagged && !pending.length) { + await send(message, '这组问题已结束或不属于当前聊天,请根据最新问题重新回答。'); + return true; + } + if (pending.length) { + const parsed = pending.map((item) => ({ item, result: parseRemoteChoices(item.request, text) })); + if (parsed.some(({ result }) => result.kind !== 'text')) { + if (pending.length > 1) { + await send(message, '当前有多组问题,请在选择前加上问题编号,例如 #编号 ACC。'); + return true; + } + const { item, result } = parsed[0]!; + if (result.kind === 'invalid') { await send(message, result.message); return true; } + if (result.kind === 'answer') { + const current = await deps.client.queryInteraction({ sessionId: WORKHUB_COORDINATION_SESSION_ID, interactionId: item.interactionId }); + if (current.status !== 'pending') { await send(message, '这组问题已经回答或失效,请查看最新回复。'); return true; } + await deps.client.answerInteraction({ sessionId: WORKHUB_COORDINATION_SESSION_ID, interactionId: item.interactionId, answer: result.answer }); + return true; + } + } + } + if (!enabled) return false; + if (Object.keys(state.inputs).length >= 100) { + await send(message, 'WorkHub 等待处理的消息过多,请稍后再试。'); + return true; + } + const turnId = message.sourceMessageId + ? `whr_${createHash('sha256').update(JSON.stringify([message.platform, message.chatId, message.sourceMessageId])).digest('hex').slice(0, 48)}` + : randomUUID(); + if (state.turns[turnId]) return true; + state.turns[turnId] = { platform: message.platform, chatId: message.chatId, userId: message.userId, sourceMessageId: message.sourceMessageId }; + state.inputs[turnId] = formatBotMessageForSession(message); + await save(); + await dispatch(); + if (state.inputs[turnId]) await send(message, '消息已收到,WorkHub 会在当前处理结束后继续。若正在等待选择,请先回答上面的选项。'); + return true; + }), + async close() { + closed = true; + unsubscribe(); + clearInterval(retry); + await session?.close(); + await lane; + }, + }; +} diff --git a/apps/desktop/src/main/workhub-remote-choices.ts b/apps/desktop/src/main/workhub-remote-choices.ts new file mode 100644 index 00000000000..610fe432a87 --- /dev/null +++ b/apps/desktop/src/main/workhub-remote-choices.ts @@ -0,0 +1,96 @@ +/* + * 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 { InteractionAnswer, InteractionFormValue, InteractionRequest } from '@maka/core/interaction'; + +type Choice = { label: string; description?: string; value: string }; +type Question = { label: string; options: readonly Choice[]; multiple: boolean; name: string; min?: number; max?: number }; + +function questions(request: InteractionRequest): Question[] | undefined { + if (request.kind === 'question') return request.questions.map((question, index) => ({ + label: question.question, name: String(index), multiple: false, + options: question.options.map((option) => ({ ...option, value: option.label })), + })); + if (request.kind !== 'form' || request.fields.some((field) => field.kind !== 'single_select' && field.kind !== 'multi_select')) return; + return request.fields.map((field) => { + if (field.kind !== 'single_select' && field.kind !== 'multi_select') throw new Error('Unsupported remote field'); + return { label: field.label, name: field.name, options: field.options, multiple: field.kind === 'multi_select', + ...(field.kind === 'multi_select' ? { min: field.minItems ?? (field.required ? 1 : 0), max: field.maxItems } : {}) }; + }); +} + +function optionMark(index: number): string { + return index < 26 ? `${String.fromCharCode(65 + index)}/${index + 1}` : String(index + 1); +} + +export function formatRemoteChoices(request: InteractionRequest, reference: string): string | undefined { + const list = questions(request); + if (!list?.length) return; + return [ + `待回答 #${reference}`, + ...(request.kind === 'form' ? [request.message] : []), + ...list.map((question, index) => [ + `${index + 1}. ${question.label}${question.multiple ? '(多选)' : ''}`, + ...question.options.map((option, index) => `${optionMark(index)}. ${option.label}${option.description ? ` — ${option.description}` : ''}`), + ].join('\n')), + '按问题顺序回复字母或数字,例如 ACC 或 133。多选用 / 分隔问题,例如 AC / B / AD。', + `有多组问题时加编号:#${reference} ACC。也可以直接发文字补充。`, + ].join('\n\n'); +} + +export type RemoteChoiceAnswer = + | { kind: 'answer'; answer: InteractionAnswer } + | { kind: 'invalid'; message: string } + | { kind: 'text' }; + +export function parseRemoteChoices(request: InteractionRequest, text: string): RemoteChoiceAnswer { + const list = questions(request); + if (!list?.length) return { kind: 'text' }; + const input = text.trim().toUpperCase(); + // Ordinary language remains a WorkHub message. Only compact choice syntax + // is interpreted here; permission requests are deliberately unsupported. + if (!/^[A-Z0-9\s/,,、]+$/.test(input)) return { kind: 'text' }; + if (/[A-Z]{5,}/.test(input) && input.length !== list.length) return { kind: 'text' }; + let parts: string[]; + if (input.includes('/')) parts = input.split('/').map((part) => part.trim()); + else if (list.length === 1) parts = [input]; + else if (/\s/.test(input)) parts = input.split(/\s+/); + else parts = [...input]; + const invalid = (message: string): RemoteChoiceAnswer => ({ kind: 'invalid', message }); + if (parts.length !== list.length) return invalid(`需要按顺序回答 ${list.length} 个问题,请重新发送完整选择。`); + const values: Record = {}; + const answers: string[] = []; + for (const [index, question] of list.entries()) { + const part = parts[index]!; + const tokens = question.multiple + ? (/[\s,,、]/.test(part) ? part.split(/[\s,,、]+/).filter(Boolean) : /^\d+$/.test(part) && question.options.length > 9 ? [part] : [...part]) + : [part]; + const indices = tokens.map((token) => /^[A-Z]$/.test(token) ? token.charCodeAt(0) - 65 : /^\d+$/.test(token) ? Number(token) - 1 : -1); + if (!indices.length || indices.some((i) => i < 0 || i >= question.options.length) || new Set(indices).size !== indices.length) + return invalid(`第 ${index + 1} 题选项无效,请使用题目列出的字母或数字。`); + if (question.multiple && (indices.length < (question.min ?? 0) || indices.length > (question.max ?? Infinity))) + return invalid(`第 ${index + 1} 题选择数量不符合要求。`); + const selected = indices.map((i) => question.options[i]!.value); + values[question.name] = question.multiple ? selected : selected[0]!; + answers.push(selected[0]!); + } + return { kind: 'answer', answer: request.kind === 'question' + ? { kind: 'question', answers } + : { kind: 'form', action: 'accept', values } }; +} diff --git a/apps/desktop/src/main/workhub-runtime.ts b/apps/desktop/src/main/workhub-runtime.ts index 4be8997b04b..6a771af7519 100644 --- a/apps/desktop/src/main/workhub-runtime.ts +++ b/apps/desktop/src/main/workhub-runtime.ts @@ -17,7 +17,7 @@ * under the License. */ -import { WORKHUB_COORDINATION_SESSION_ID, type WorkHubCreateDefaults } from '@maka/core/session'; +import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; import type { WorkHubActionResult } from '@maka/core/workhub-action-result'; import { clientCapabilityEntityId } from '@maka/runtime-host/client-capability-entity-id'; import type { WorkHubCoordinationProposal, WorkspaceTarget } from '@maka/runtime-host/protocol'; @@ -28,7 +28,7 @@ import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; interface WorkHubRuntimeDeps { client(scope: DesktopTargetScope): Pick; isCurrent(scope: DesktopTargetScope): boolean; - createContext(scope: DesktopTargetScope): Promise<{ workspace: WorkspaceTarget; defaults: WorkHubCreateDefaults }>; + createContext(scope: DesktopTargetScope): Promise<{ workspace: WorkspaceTarget }>; changed(scope: DesktopTargetScope, reason: 'created' | 'status-change', sessionId: string): void; } @@ -38,7 +38,7 @@ function executionEvidence(result: WorkHubActionResult) { : {}; } -/** Keep task authority in the Host; Desktop supplies only its selected workspace and preferences. */ +/** Keep task authority in the Host; Desktop supplies only its selected workspace. */ export function createWorkHubRuntime(deps: WorkHubRuntimeDeps) { const requireCurrent = (scope: DesktopTargetScope) => { if (!deps.isCurrent(scope)) throw new Error('Runtime Host changed'); @@ -89,8 +89,11 @@ export function createWorkHubRuntime(deps: WorkHubRuntimeDeps) { // WorkHub persists actions as entities; capability tool-call IDs are opaque. const actionId = clientCapabilityEntityId(toolCallId); if (input.operation === 'select_and_delegate') { + const context = await deps.createContext(scope); + requireCurrent(scope); const outcome = await client.selectAndDelegateWorkHubTarget({ turnId, actionId, - candidateSetId: input.candidateSetId, candidateRefs: input.candidateRefs, delegationText: input.text }); + candidateSetId: input.candidateSetId, candidateRefs: input.candidateRefs, delegationText: input.text, + create: { workspace: context.workspace } }); if (outcome.kind === 'cancelled') return outcome; const result = outcome.result; if ('targetSessionId' in result) deps.changed(scope, 'status-change', result.targetSessionId); @@ -102,22 +105,17 @@ export function createWorkHubRuntime(deps: WorkHubRuntimeDeps) { switch (input.operation) { case 'delegate_existing': proposal = { disposition: 'delegate_existing', candidateRef: input.candidateRef }; break; case 'create_new': proposal = { disposition: 'create_new', title: input.title }; break; - case 'correct': proposal = { operation: 'correct', replacesActionId: input.replacesActionId, target: input.target }; break; case 'stop': proposal = { operation: 'stop', expects: { targetSessionId: input.targetSessionId } }; break; case 'resume': proposal = { operation: 'resume', resumesActionId: input.resumesActionId, expects: { targetSessionId: input.targetSessionId } }; break; } - const createsTarget = - ('disposition' in proposal && proposal.disposition === 'create_new') || - ('operation' in proposal && - proposal.operation === 'correct' && - proposal.target.disposition === 'create_new'); + const createsTarget = 'disposition' in proposal && proposal.disposition === 'create_new'; const context = createsTarget ? await deps.createContext(scope) : undefined; requireCurrent(scope); const result = await client.actWorkHubCoordinationFromTurn({ turnId, actionId, proposal, ...('text' in input ? { delegationText: input.text } : {}), ...('candidateSetId' in input && input.candidateSetId ? { candidateSetId: input.candidateSetId } : {}), - ...(context ? { create: { workspace: context.workspace }, newWorkDefaults: context.defaults } : {}), + ...(context ? { create: { workspace: context.workspace } } : {}), }); if (result.disposition === 'create_new' || (result.disposition === 'replace' && result.replacementDisposition === 'create_new')) { deps.changed(scope, 'created', result.targetSessionId); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 0be748de2a0..a328980f151 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1153,8 +1153,6 @@ export interface MakaBridge { prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise; answer(coordinationSessionId: string, input: WorkHubAnswerInput): Promise; configureModel(coordinationSessionId: string, input: OperationInput<'workhub.coordination.configureModel'>): Promise>; - getNewWorkDefaults(coordinationSessionId: string): Promise>; - setNewWorkDefaults(coordinationSessionId: string, defaults: Omit): Promise; /** Resolve the active Runtime Host's stable coordination conversation. */ resolveCoordinationSession(): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0928351e25f..4376044d0c5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2236,14 +2236,6 @@ const makaBridge = { const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); return invokeWhenReady('workhub:configureModel', scope, input) as Promise>; }, - async getNewWorkDefaults(coordinationSessionId: string) { - const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); - return ipcRenderer.invoke('workhub:getNewWorkDefaults', scope) as Promise>; - }, - async setNewWorkDefaults(coordinationSessionId: string, defaults: Omit) { - const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); - await ipcRenderer.invoke('workhub:setNewWorkDefaults', scope, defaults); - }, resolveCoordinationSession(): Promise { return resolveDesktopWorkHubCoordinationSession( activeRuntimeHostRef, diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx index 83377c41562..d8af9036304 100644 --- a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx @@ -592,7 +592,8 @@ function UsageRequestsPanel(props: { ) : undefined} columns={[ - { header: props.copy.tables.requestHeaders[0], width: 168 }, + // Leave room for the localized timestamp and cell padding with Linux fonts. + { header: props.copy.tables.requestHeaders[0], width: 176 }, { header: props.copy.tables.requestHeaders[1], width: 72 }, { header: props.copy.tables.requestHeaders[2], grow: true }, { header: props.copy.tables.requestHeaders[3], width: 168 }, diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 4e7cfb31bc8..70e4923d4c6 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -41,7 +41,6 @@ import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../../../../shared import type { AttachmentRef, FollowUpMode, MessageQueueEntryProjection, MessageQueuePlacement } from '@maka/core/events'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { WorkHubCreateDefaults } from '@maka/core/session'; import { startWorkHubCoordinationLifecycle, WorkHubModelConfigurationRequiredError, @@ -80,9 +79,6 @@ export function useWorkHubController( ); const [configuringModel, setConfiguringModel] = useState(false); const configuringModelRef = useRef(false); - const [newWorkDefaults, setNewWorkDefaults] = useState< - Omit - >({}); const [choices, setChoices] = useState([]); const [modelSetupChoicesReady, setModelSetupChoicesReady] = useState(false); const [transcript, setTranscript] = useState(emptyTranscript); @@ -336,7 +332,6 @@ export function useWorkHubController( useEffect(() => { setChoices([]); - setNewWorkDefaults({}); transcriptRef.current = emptyTranscript; settledBeforePublication.current.clear(); setTranscript(emptyTranscript); @@ -352,21 +347,6 @@ export function useWorkHubController( }] : [] }); }, [sessionId]); - useEffect(() => { - if (!sessionId) return; - let disposed = false; - void services - .getNewWorkDefaults(sessionId) - .then((defaults) => { - if (!disposed && currentSessionId.current === sessionId) setNewWorkDefaults(defaults); - }) - .catch((reason: unknown) => { - if (!disposed && currentSessionId.current === sessionId) report(reason); - }); - return () => { - disposed = true; - }; - }, [services, sessionId]); useEffect(() => { if (!sessionId) return; @@ -638,47 +618,21 @@ export function useWorkHubController( llmConnectionSlug: string; model: string; }, thinkingLevel: ThinkingLevel | null = null) { - if (!sessionId || busy || configuringModelRef.current) return; + if (!sessionId || !session || busy || configuringModelRef.current) return; configuringModelRef.current = true; setConfiguringModel(true); try { - const next: Omit = { - model: { - llmConnectionId: input.llmConnectionId, - llmConnectionSlug: input.llmConnectionSlug, - model: input.model, - }, - ...(thinkingLevel ? { thinkingLevel } : {}), - }; - await services.setNewWorkDefaults(sessionId, next); + const result = await services.configureModel(sessionId, { + expectedRevision: session.revision, + modelTarget: { kind: 'explicit', connectionId: input.llmConnectionId, + connectionSlug: input.llmConnectionSlug, model: input.model }, + thinkingLevel, + }); if (currentSessionId.current !== sessionId) return; - setNewWorkDefaults(next); - setError(undefined); - } catch (reason) { + const updated = await services.getSession(sessionId); if (currentSessionId.current !== sessionId) return; - report(reason); - } finally { - configuringModelRef.current = false; - setConfiguringModel(false); - } - } - async function changeExecutor(input: { - executorId: string; - model?: string; - thinkingLevel?: ThinkingLevel; - }) { - if (!sessionId || busy || configuringModelRef.current) return; - configuringModelRef.current = true; - setConfiguringModel(true); - try { - const next: Omit = { - executorId: input.executorId, - ...(input.model ? { executorModel: input.model } : {}), - ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}), - }; - await services.setNewWorkDefaults(sessionId, next); - if (currentSessionId.current !== sessionId) return; - setNewWorkDefaults(next); + setSessions((current) => [...current.filter((item) => item.id !== sessionId), updated]); + if (result.kind !== 'committed') throw new Error('WorkHub configuration changed; try again'); setError(undefined); } catch (reason) { if (currentSessionId.current !== sessionId) return; @@ -725,7 +679,6 @@ export function useWorkHubController( session, sessions, choices, - newWorkDefaults, transcript, activeForm: activeInteraction?.type === 'form_request' ? activeInteraction : undefined, respondToUserForm: async (response: import('@maka/core/interaction').InteractionFormResponse) => { @@ -766,26 +719,12 @@ export function useWorkHubController( send, stop, changeModel, - changeExecutor, selectSetupModel, configuringModel, changeThinkingLevel: async (level: ThinkingLevel | undefined) => { - if (newWorkDefaults.executorId) { - await changeExecutor({ - executorId: newWorkDefaults.executorId, - ...(newWorkDefaults.executorModel ? { model: newWorkDefaults.executorModel } : {}), - ...(level ? { thinkingLevel: level } : {}), - }); - return; - } - const model = newWorkDefaults.model ?? - (session?.llmConnectionId && session.llmConnectionSlug && session.model - ? { - llmConnectionId: session.llmConnectionId, - llmConnectionSlug: session.llmConnectionSlug, - model: session.model, - } - : undefined); + const model = session?.llmConnectionId && session.llmConnectionSlug && session.model + ? { llmConnectionId: session.llmConnectionId, llmConnectionSlug: session.llmConnectionSlug, model: session.model } + : undefined; if (!model) return; await changeModel(model, level ?? null); }, diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 449975cb968..8c77fa188e7 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -19,7 +19,7 @@ import type { UiCatalog } from '@maka/core/ui-locale'; export const workHubLiveCopy = { - en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', work: 'Work', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', + en: { selectedAnswers: 'Selected', clarifiedPreferences: 'Your answers', filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', work: 'Work', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', retrySteering: 'Retry the original text and attachments with Cmd/Ctrl+Enter to resolve the previous submission first.', retryFollowup: 'Retry the original text and attachments with Enter to resolve the previous submission first.', sendUnknown: 'The Host has not confirmed this message. Retry checks the same submission.', @@ -43,7 +43,7 @@ export const workHubLiveCopy = { restore: 'Bring WorkHub back', openWork: 'Open task', openResult: 'Open result', }, - 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', work: '工作', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', + 'zh-CN': { selectedAnswers: '已选择', clarifiedPreferences: '你的补充', filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', work: '工作', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', retrySteering: '请先保留原文和附件,用 Cmd/Ctrl+Enter 重试并确认上次提交结果。', retryFollowup: '请先保留原文和附件,用 Enter 重试并确认上次提交结果。', sendUnknown: 'Host 尚未确认这条消息。重试会核对原提交。', @@ -67,7 +67,7 @@ export const workHubLiveCopy = { restore: '收回工作台', openWork: '打开任务', openResult: '打开结果', }, - 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', work: '工作', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', + 'zh-TW': { selectedAnswers: '已選擇', clarifiedPreferences: '你的補充', filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', work: '工作', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', retrySteering: '請先保留原文和附件,用 Cmd/Ctrl+Enter 重試並確認上次提交結果。', retryFollowup: '請先保留原文和附件,用 Enter 重試並確認上次提交結果。', sendUnknown: 'Host 尚未確認這則訊息。重試會核對原提交。', diff --git a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts index 9a9c970286b..8dcb4ec0ddf 100644 --- a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts +++ b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts @@ -31,7 +31,7 @@ export interface WorkHubLinkedWork { readonly workspaceName?: string; } -/** Links come from successful tool results in the same durable conversation. */ +/** Links come from durable task receipts and Host-owned result origins. */ export function workHubLinkedWork( messages: readonly StoredMessage[], sessions: readonly { id: string; name: string; cwd?: string }[], @@ -55,6 +55,24 @@ export function workHubLinkedWork( }; const results = new Map(messages.flatMap((message) => message.type === 'tool_result' ? [[message.toolUseId, message] as const] : [])); return messages.flatMap((message): WorkHubLinkedWork[] => { + // A result notification starts its own coordination turn. It remains + // linked even when that turn only summarizes the result without tools, + // or the original delegation is outside the loaded history window. + if (message.type === 'user' && message.origin?.kind === 'workhub_result') { + if (!coordinationSessionKey) return []; + let target: string; + try { + target = desktopSessionKey({ + hostId: parseDesktopSessionKey(coordinationSessionKey).hostId, + sessionId: message.origin.targetSessionId, + }); + } catch { return []; } + return [{ + id: message.id, coordinationTurnId: message.turnId, targetSessionId: target, + targetSessionName: sessionById.get(target)?.name ?? fallbackName, + workspaceName: workspaceName(target), + }]; + } if (message.type === 'tool_call' && taskCalls.has(message.id)) { const args = message.args; const request = args && typeof args === 'object' && 'request' in args ? args.request : undefined; diff --git a/apps/desktop/src/renderer/features/workhub/model/turn-context.ts b/apps/desktop/src/renderer/features/workhub/model/turn-context.ts new file mode 100644 index 00000000000..576b5f88f10 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/model/turn-context.ts @@ -0,0 +1,91 @@ +/* + * 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 { StoredMessage } from '@maka/core/session'; + +export interface WorkHubTurnContext { + readonly answers: { id: string; question: string; answer: string }[]; + readonly handoffs: { id: string; targetSessionId: string; targetSessionName: string; text: string }[]; +} + +/** Rebuild presentation from durable answers and accepted assignments, never unaccepted tool proposals. */ +export function workHubTurnContexts(messages: readonly StoredMessage[]): Map { + const contexts = new Map(); + const calls = new Map(messages.flatMap((message) => + message.type === 'tool_call' ? [[message.id, message] as const] : [], + )); + const assignedActions = new Set(messages.flatMap((message) => message.type === 'workhub_coordination' && message.kind === 'delegation_assigned' ? [message.actionId] : [])); + const seen = new Set(); + const contextFor = (turnId: string) => { + let context = contexts.get(turnId); + if (!context) { + context = { answers: [], handoffs: [] }; + contexts.set(turnId, context); + } + return context; + }; + for (const message of messages) { + if (seen.has(message.id)) continue; + seen.add(message.id); + if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') { + contextFor(message.coordinationTurnId).handoffs.push({ + id: message.id, + targetSessionId: message.targetSessionId, + targetSessionName: message.targetSessionName, + text: message.delegationText ?? message.userText, + }); + } + if (message.type !== 'tool_result' || message.isError) continue; + const call = calls.get(message.toolUseId); + if (!call || call.turnId !== message.turnId) continue; + let result: unknown; + if (message.content.kind === 'json') result = message.content.value; + else if (message.content.kind === 'text') { + try { result = JSON.parse(message.content.text); } catch { continue; } + } + // Live transcript pages may contain the successful tool receipt without + // the assignment record. Pair it with the exact call that the Host accepted. + if (result && typeof result === 'object' && 'structuredContent' in result) result = result.structuredContent; + if (call.toolName === 'mcp__desktop_workhub__tasks') { + const request = call.args && typeof call.args === 'object' && 'request' in call.args ? call.args.request : undefined; + if (request && typeof request === 'object' && 'text' in request && typeof request.text === 'string' && + 'operation' in request && ['create_new', 'delegate_existing', 'select_and_delegate'].includes(String(request.operation)) && + result && typeof result === 'object' && 'disposition' in result && + ['create_new', 'delegate_existing', 'replace'].includes(String(result.disposition)) && + 'targetSessionKey' in result && typeof result.targetSessionKey === 'string' && + !('actionId' in result && typeof result.actionId === 'string' && assignedActions.has(result.actionId))) { + contextFor(call.turnId).handoffs.push({ + id: message.id, targetSessionId: result.targetSessionKey, + targetSessionName: 'title' in request && typeof request.title === 'string' ? request.title : '', + text: request.text, + }); + } + continue; + } + if (call.toolName !== 'AskUserQuestion') continue; + if (!result || typeof result !== 'object' || !('answers' in result) || !Array.isArray(result.answers)) continue; + for (const [index, entry] of result.answers.entries()) { + if (!entry || typeof entry !== 'object' || typeof entry.question !== 'string' || + typeof entry.answer !== 'string' || !entry.answer.trim()) continue; + contextFor(call.turnId).answers.push({ id: `${message.id}:${index}`, question: entry.question, answer: entry.answer }); + } + } + return contexts; +} diff --git a/apps/desktop/src/renderer/features/workhub/model/wn-draft.ts b/apps/desktop/src/renderer/features/workhub/model/wn-draft.ts new file mode 100644 index 00000000000..db1849bbfea --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/model/wn-draft.ts @@ -0,0 +1,37 @@ +/* + * 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 { StoredMessage } from '@maka/core/session'; +import { workHubTurnContexts } from './turn-context.js'; + +/** Only a newly submitted local shortcut can populate the local composer. */ +export function completedWorkHubDraft(messages: readonly StoredMessage[], previousIds: ReadonlySet, submittedAt = 0): string | undefined { + const request = messages.find((message) => message.type === 'user' && + message.ts >= submittedAt && !previousIds.has(message.id) && /^wn$/i.test(message.text.trim())); + if (!request?.turnId) return; + const turn = messages.filter((message) => message.turnId === request.turnId); + const state = turn.filter((message) => message.type === 'turn_state').at(-1); + if (state?.status !== 'completed' || !workHubTurnContexts(turn).get(request.turnId)?.answers.length) return; + const reply = turn.filter((message) => message.type === 'assistant').at(-1); + if (!reply || reply.interrupted) return; + const blocks = [...reply.text.matchAll(/^```(?:text)?[ \t]*\n([\s\S]*?)^```\s*$/gm)]; + if (blocks.length !== 1) return; + return blocks[0]?.[1]?.trim() || undefined; +} diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index d5efe30f223..59a81bcf963 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -20,7 +20,7 @@ import type { ArtifactBinaryReadResult } from '@maka/core/artifacts'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { StoredMessage, SessionSummary, WorkHubCreateDefaults } from '@maka/core/session'; +import type { StoredMessage, SessionSummary } from '@maka/core/session'; import type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; import type { SessionEvent, AttachmentRef, MessageQueuePlacement } from '@maka/core/events'; import type { OperationInput, OperationOutput, TurnMessageExecutionQueryResult } from '@maka/runtime-host/protocol'; @@ -73,11 +73,6 @@ export interface WorkHubServices extends WorkHubWorkspaceServices { sessionId: string, input: OperationInput<'workhub.coordination.configureModel'>, ): Promise>; - getNewWorkDefaults(sessionId: string): Promise>; - setNewWorkDefaults( - sessionId: string, - defaults: Omit, - ): Promise; observe( sessionId: string, handler: (event: SessionEvent) => void, diff --git a/apps/desktop/src/renderer/features/workhub/testing.ts b/apps/desktop/src/renderer/features/workhub/testing.ts index 4d01f6de8e6..cd8fd427232 100644 --- a/apps/desktop/src/renderer/features/workhub/testing.ts +++ b/apps/desktop/src/renderer/features/workhub/testing.ts @@ -26,3 +26,7 @@ export { WorkHubHighlightContext } from './ui/workhub-work-identity.js'; export { workspaceNameFromCwd } from './model/workspace-name.js'; export { allocateWorkHubHues } from './model/identity-colors.js'; export { workHubLinkedWork } from './model/linked-work.js'; + +export { workHubTurnContexts } from './model/turn-context.js'; + +export { completedWorkHubDraft } from './model/wn-draft.js'; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index 9e933653d06..dd245acd9a1 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -19,10 +19,11 @@ import type { CSSProperties } from 'react'; import { useContext, useMemo, type ComponentProps } from 'react'; -import { ChatView, useUiLocale } from '@maka/ui'; +import { ChatView, TranscriptDisclosure, useUiLocale } from '@maka/ui'; import { Button, Link, Text } from '@astryxdesign/core'; import { WorkHubHighlightContext, useWorkHubIdentityHue } from './workhub-work-identity.js'; import type { WorkHubLinkedWork } from '../model/linked-work.js'; +import { workHubTurnContexts } from '../model/turn-context.js'; import { workHubLiveCopy } from '../locales/workhub-live-copy.js'; import { useAppShellTurnPresentation } from '../../../application/contracts/turn-presentation.js'; @@ -54,16 +55,16 @@ export function WorkHubConversation(props: ComponentProps & { w highlighted: works.some((work) => highlight.sessionId === work.targetSessionId), }]; })), [worksByTurn, highlight.sessionId, workHubIdentityHue]); - const promptTextByTurn = new Map(chat.messages?.flatMap((message) => message.type === 'user' ? [[message.turnId, message.text.slice(0, 80)] as const] : [])); + const promptTextByTurn = new Map(chat.messages?.flatMap((message) => message.type === 'user' && message.origin?.kind !== 'workhub_result' ? [[message.turnId, message.text.slice(0, 80)] as const] : [])); const turnDecorations = new Map['turnDecorations']> extends ReadonlyMap ? V : never>([...worksByTurn].map(([turnId, works]) => [turnId, { accentColor: promptRailDecorations.get(turnId)?.accentColor ?? 'transparent', - messageRail: <>{works.map((work, index) =>