Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 218 additions & 3 deletions apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion apps/desktop/src/main/__tests__/workhub-remote-choices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import { formatRemoteChoices, parseRemoteChoices } from '../workhub-remote-choic
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));
for (const label of ['1. 项目\n\nA. 甲', '2. 范围', '3. 测试', '#abcd1234', 'C. 丙']) assert.ok(text.includes(label));
assert.ok(!text.includes('A/1'));
assert.ok(text.endsWith('按题顺序回复,如 AAA。'));
const expected = { kind: 'answer', answer: { kind: 'question', answers: ['甲', '丙', '丙'] } };
assert.deepEqual(parseRemoteChoices(request, 'ACC'), expected);
assert.deepEqual(parseRemoteChoices(request, '133'), expected);
Expand All @@ -45,3 +47,23 @@ test('task chooser sends opaque option values, and multi-select preserves all se
assert.equal(parseRemoteChoices(form, 'A / ABC').kind, 'invalid');
assert.equal(parseRemoteChoices(form, 'A / AA').kind, 'invalid');
});
test('recent work picker separates progress and next step and accepts nxt without selecting work', () => {
const recent: InteractionRequest = { kind: 'question', toolUseId: 'ask', questions: [{
question: '最近的工作:', options: [
{ label: 'SQL 入门', description: '进度:第 1 题待回答 → 下一步:写查询并检查' },
{ label: '取消', description: '不生成任何草稿,也不开始任何工作' },
],
}] };
assert.equal(formatRemoteChoices(recent, '7bbc30d2'), [
'待回答 #7bbc30d2', '', '最近的工作:', 'A. SQL 入门',
'进度:第 1 题待回答', '下一步:写查询并检查', '', 'B. 取消', '',
'回复选项生成下一步 prompt, 回复 nxt 查看更多工作',
].join('\n'));
for (const text of ['nxt', ' NXT ']) assert.deepEqual(parseRemoteChoices(recent, text), {
kind: 'answer', answer: { kind: 'question', answers: ['nxt'] },
});
assert.deepEqual(parseRemoteChoices(recent, 'a'), {
kind: 'answer', answer: { kind: 'question', answers: ['SQL 入门'] },
});
assert.equal(parseRemoteChoices(request, 'nxt').kind, 'invalid');
});
134 changes: 134 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-topic-association.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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 { workHubTasksSchema } from '../../shared/workhub-tool-schema.js';
import { createWorkHubRuntime } from '../workhub-runtime.js';
import { workHubLinkedWork } from '../../renderer/features/workhub/testing.js';
import type { StoredMessage } from '@maka/core/session';

test('a direct CS336 answer has a durable work association without starting execution', async () => {
const input = workHubTasksSchema.parse({ operation: 'associate', targetSessionId: 'cs336' });
const writes: unknown[] = [];
const runtime = createWorkHubRuntime({
isCurrent: () => true,
client: () => ({
listWorkHubCoordinationCandidates: async () => ({ candidateSetId: 'set', candidates: [{ sessionId: 'cs336', sessionName: 'CS336 学习' }] }),
actWorkHubCoordinationFromTurn: async (value: unknown) => { writes.push(value); throw new Error('must not execute'); },
} as never),
createContext: async () => { throw new Error('must not create'); },
changed: () => { throw new Error('must not change target'); },
});
assert.deepEqual(await runtime.actTasks({ hostId: 'host', targetEpoch: 'epoch' }, 'unrelated-turn', 'none-call', workHubTasksSchema.parse({ operation: 'associate', targetSessionId: null, reason: 'unrelated' })), { operation: 'associate', targetSessionId: null, reason: 'unrelated' });
await assert.rejects(runtime.actTasks({ hostId: 'host', targetEpoch: 'epoch' }, 'unrelated-turn', 'bad-none', workHubTasksSchema.parse({ operation: 'associate', targetSessionId: null })), /requires a reason/);
const result = await runtime.actTasks({ hostId: 'host', targetEpoch: 'epoch' }, 'turn', 'associate-call', input);
const messages: StoredMessage[] = [
{ type: 'user', id: 'user', turnId: 'turn', ts: 1, text: '[微信] 336 第一课都有什么内容?' },
{ type: 'tool_call', id: 'associate-call', turnId: 'turn', ts: 2, toolName: 'mcp__desktop_workhub__tasks', args: { request: input } },
{ type: 'tool_result', id: 'association', turnId: 'turn', ts: 3, toolUseId: 'associate-call', isError: false, content: { kind: 'json', value: { structuredContent: result } } },
{ type: 'assistant', id: 'answer', turnId: 'turn', ts: 4, modelId: 'test', text: '第一课从语言模型的基本组成讲起。' },
];
const expected = [{ id: 'association', coordinationTurnId: 'turn', targetSessionId: JSON.stringify(['host', 'cs336']), targetSessionName: 'CS336 学习', workspaceName: undefined }];
assert.deepEqual(workHubLinkedWork(messages, [], 'Work'), expected);
assert.deepEqual(workHubLinkedWork(JSON.parse(JSON.stringify(messages)), [], 'Work'), expected);
assert.deepEqual(writes, []);
const call = messages[1]!;
const receipt = messages[2]!;
assert.equal(receipt.type, 'tool_result');
if (receipt.type !== 'tool_result' || call.type !== 'tool_call') throw new Error('bad fixture');
for (const invalid of [
[call, { ...receipt, isError: true }],
[{ ...call, toolName: 'OtherTool' }, receipt],
[call, { ...receipt, turnId: 'other-turn' }],
[{ ...call, args: { request: { operation: 'associate', targetSessionId: 'sql' } } }, receipt],
[receipt],
] as StoredMessage[][]) assert.deepEqual(workHubLinkedWork(invalid, [], 'Work'), []);
await assert.rejects(runtime.actTasks({ hostId: 'host', targetEpoch: 'epoch' }, 'turn', 'bad-call', workHubTasksSchema.parse({ operation: 'associate', targetSessionId: 'unrelated' })), /discovery/);
});

async function topicReceipt(turnId: string, id: string, targetSessionId: string | null, reason = 'unrelated'): Promise<[StoredMessage, StoredMessage]> {
const request = workHubTasksSchema.parse({ operation: 'associate', targetSessionId, ...(targetSessionId === null ? { reason } : {}) });
const runtime = createWorkHubRuntime({
isCurrent: () => true,
client: () => ({
listWorkHubCoordinationCandidates: async () => ({ candidateSetId: 'set', candidates: ['sql', 'cs336'].map((sessionId) => ({ sessionId, sessionName: sessionId })) }),
actWorkHubCoordinationFromTurn: async () => { throw new Error('association must not execute work'); },
} as never),
createContext: async () => { throw new Error('association must not create work'); },
changed: () => { throw new Error('association must not change target'); },
});
const result = await runtime.actTasks({ hostId: 'host', targetEpoch: 'epoch' }, turnId, id, request);
return [
{ type: 'tool_call', id, turnId, ts: 1, toolName: 'mcp__desktop_workhub__tasks', args: { request } },
{ type: 'tool_result', id: `${id}-receipt`, turnId, ts: 2, toolUseId: id, isError: false, content: { kind: 'json', value: { structuredContent: result } } },
];
}

for (const reason of ['unrelated', 'overview', 'ambiguous']) {
test(`Desktop retracts a same-turn topic after a successful ${reason} decision`, async () => {
const initial = await topicReceipt('turn', 'initial', 'sql');
const cleared = await topicReceipt('turn', 'clear', null, reason);
const other = await topicReceipt('other-turn', 'other', 'cs336');
assert.equal(workHubLinkedWork(initial, [], 'Work').length, 1);
const messages = [...initial, ...other, ...cleared];
const expected = workHubLinkedWork(other, [], 'Work');
assert.deepEqual(workHubLinkedWork(messages, [], 'Work'), expected);
assert.deepEqual(workHubLinkedWork(JSON.parse(JSON.stringify(messages)), [], 'Work'), expected);
});
}

test('Desktop uses the latest successful topic when switching or reassociating', async () => {
const initial = await topicReceipt('turn', 'initial', 'sql');
const switched = await topicReceipt('turn', 'switch', 'cs336');
const cleared = await topicReceipt('turn', 'clear', null);
const restored = await topicReceipt('turn', 'restore', 'sql');
assert.deepEqual(workHubLinkedWork([...initial, ...switched], [], 'Work'), workHubLinkedWork(switched, [], 'Work'));
assert.deepEqual(workHubLinkedWork([...initial, ...cleared, ...restored], [], 'Work'), workHubLinkedWork(restored, [], 'Work'));
});

test('Desktop ignores failed, mismatched, and orphaned topic clearing receipts', async () => {
const initial = await topicReceipt('turn', 'initial', 'sql');
const [call, result] = await topicReceipt('turn', 'clear', null);
assert.ok(result.type === 'tool_result' && call.type === 'tool_call');
if (result.type !== 'tool_result' || call.type !== 'tool_call') return;
const expected = workHubLinkedWork(initial, [], 'Work');
for (const invalid of [
[call, { ...result, isError: true }],
[call, { ...result, turnId: 'other-turn' }],
[{ ...call, turnId: 'other-turn' }, result],
[{ ...call, args: { request: { operation: 'associate', targetSessionId: 'sql' } } }, result],
[call, { ...result, toolUseId: 'other-call' }],
[result],
] as StoredMessage[][]) assert.deepEqual(workHubLinkedWork([...initial, ...invalid], [], 'Work'), expected);
});

test('Desktop topic clearing preserves genuine delegation and result links', async () => {
const initial = await topicReceipt('turn', 'initial', 'sql');
const cleared = await topicReceipt('turn', 'clear', null);
const facts: StoredMessage[] = [
{ type: 'tool_call', id: 'delegate', turnId: 'turn', ts: 3, toolName: 'mcp__desktop_workhub__tasks', args: { request: workHubTasksSchema.parse({ operation: 'delegate_existing', candidateRef: 'sql-candidate', text: 'Continue SQL work' }) } },
{ type: 'tool_result', id: 'delegated', turnId: 'turn', ts: 4, toolUseId: 'delegate', isError: false, content: { kind: 'json', value: { structuredContent: { disposition: 'delegate_existing', targetSessionKey: JSON.stringify(['host', 'sql']) } } } },
{ type: 'user', id: 'feedback', turnId: 'turn', ts: 5, text: 'result', origin: { kind: 'workhub_result', actionId: 'delegate', eventId: 'event', delegationId: 'delegation', targetSessionId: 'sql', targetTurnId: 'target-turn' } },
];
const key = JSON.stringify(['host', 'workhub']);
const expected = workHubLinkedWork(facts, [], 'Work', key);
assert.deepEqual(expected.map(({ id }) => id), ['delegated', 'feedback']);
assert.deepEqual(workHubLinkedWork([...initial, ...facts, ...cleared], [], 'Work', key), expected);
});
45 changes: 37 additions & 8 deletions apps/desktop/src/main/workhub-remote-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ 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 { clientCapabilityEntityId } from '@maka/runtime-host/client-capability-entity-id';
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 { readWorkHubTopicAssociation, workHubAssociationTarget } from '../renderer/application/contracts/workhub-topic-association.js';
import { formatRemoteChoices, parseRemoteChoices } from './workhub-remote-choices.js';

type Source = Pick<BotIncomingMessage, 'platform' | 'chatId' | 'userId' | 'sourceMessageId'>;
type Source = Pick<BotIncomingMessage, 'platform' | 'chatId' | 'userId' | 'sourceMessageId'> & { targetSessionId?: string; associationTargetSessionId?: string | null; associationTurnId?: string };
interface State {
version: 1;
version: 1 | 2;
through: number | null;
turns: Record<string, Source>;
actions: Record<string, Source>;
Expand All @@ -45,7 +47,7 @@ export interface WorkHubRemoteBridge {
close(): Promise<void>;
}
interface Deps {
client: Pick<DesktopRuntimeHostClient, 'openSession' | 'resolveWorkHubCoordinationSession' | 'answerWorkHubCoordination' | 'answerInteraction' | 'queryInteraction' | 'queryTurn' | 'subscribeSessionCatalogChanges'>;
client: Pick<DesktopRuntimeHostClient, 'openSession' | 'getSession' | 'resolveWorkHubCoordinationSession' | 'answerWorkHubCoordination' | 'answerInteraction' | 'queryInteraction' | 'queryTurn' | 'subscribeSessionCatalogChanges'>;
botRegistry: Pick<BotRegistry, 'sendMessage'>;
readMode(): Promise<BotMessageHandling>;
statePath: string;
Expand All @@ -57,7 +59,7 @@ interface Deps {
* 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 state: State = { version: 2, through: null, turns: {}, actions: {}, inputs: {}, replies: {}, delivered: {} };
let loaded = false;
let closed = false;
let session: DesktopRuntimeHostSession | undefined;
Expand All @@ -69,12 +71,15 @@ export function createWorkHubRemoteBridge(deps: Deps): WorkHubRemoteBridge {
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;
const sameChat = (source: Source, message: Source) => 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');
if ((value.version !== 1 && value.version !== 2) || !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');
// Rebuild missing action routes from the runtime transcript once. Keep
// delivery receipts so already-sent replies are not sent again.
if (value.version === 1) { value.version = 2; value.through = null; }
state = value;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
Expand All @@ -89,7 +94,17 @@ export function createWorkHubRemoteBridge(deps: Deps): WorkHubRemoteBridge {
}
async function send(source: Source, text: string): Promise<void> {
if (closed || deps.isActive?.() === false) throw new Error('WorkHub remote transport is inactive');
const sent = await deps.botRegistry.sendMessage(source.platform, source.chatId, text,
let name: string | undefined;
// Topic identity is useful on every remote reply, independent of timing
// or whether this turn delegated execution.
if (source.targetSessionId) {
try {
const target = await deps.client.getSession(source.targetSessionId);
if (target?.name.trim()) name = target.name.trim();
} catch (error) { deps.onError(error); }
}
const heading = name?.replace(/[\r\n]+/g, ' ').replace(/\[/g, '[').replace(/\]/g, ']');
const sent = await deps.botRegistry.sendMessage(source.platform, source.chatId, heading ? `[${heading}]\n${text}` : text,
source.sourceMessageId ? { replyToMessageId: source.sourceMessageId } : undefined);
if (!sent) throw new Error('WorkHub remote reply delivery failed');
}
Expand All @@ -101,13 +116,27 @@ export function createWorkHubRemoteBridge(deps: Deps): WorkHubRemoteBridge {
await save();
}
function observe(message: StoredMessage) {
// Modern transcript pages contain runtime tool calls, not the separate
// legacy delegation rows. Use the same action identity as actTasks.
if (message.type === 'tool_call' && message.toolName === 'mcp__desktop_workhub__tasks') {
const source = state.turns[message.turnId];
if (source) state.actions[clientCapabilityEntityId(message.id)] = { ...source, associationTargetSessionId: workHubAssociationTarget(message), associationTurnId: message.turnId };
}
if (message.type === 'tool_result') {
const source = state.actions[clientCapabilityEntityId(message.toolUseId)];
const topic = source?.associationTargetSessionId !== undefined && source.associationTurnId === message.turnId
? readWorkHubTopicAssociation(message, source.associationTargetSessionId) : undefined;
if (topic && state.turns[message.turnId] && sameChat(source!, state.turns[message.turnId]!)) {
state.turns[message.turnId] = { ...state.turns[message.turnId]!, targetSessionId: topic.targetSessionId ?? undefined };
}
}
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 (source) state.turns[message.turnId] = { ...source, targetSessionId: message.origin.targetSessionId };
}
if (message.type === 'assistant' && state.turns[message.turnId] && message.text.trim()) state.replies[message.turnId] = message.text;
}
Expand Down
Loading