From 566625e06557e8c66ef3328424578126f723ca9c Mon Sep 17 00:00:00 2001 From: colafornia Date: Fri, 28 Aug 2026 14:51:02 +0800 Subject: [PATCH 1/3] fix(i18n): localize built-in tool presentation Resolve Maka-owned tool labels from the active UI locale while preserving provider and compatibility labels for non-localized consumers. Move thread search metadata presentation out of Core so historical results follow the current locale. Refs #3962 Generated-by: OpenAI Codex --- .../desktop/e2e/fixture-thread-search.spec.ts | 3 +- .../runtime-host-native-capabilities.test.ts | 16 ++++ .../runtime-host-search-ipc-main.test.ts | 42 +++++++++- .../main/__tests__/streaming-handoff.test.ts | 10 +-- .../src/main/__tests__/thread-search.test.ts | 24 +++++- .../src/main/runtime-host-search-ipc-main.ts | 5 ++ packages/core/src/search.ts | 4 + packages/core/src/session.ts | 1 + packages/core/src/thread-search.ts | 38 +++------ .../src/__tests__/history-tools.test.ts | 70 +++++++++++++++- packages/runtime/src/history-tools.ts | 22 +++++- packages/runtime/src/tool-runtime.ts | 2 +- .../src/__tests__/search-modal-source.test.ts | 43 +++++++++- .../tool-activity-presentation.test.ts | 18 ++++- .../src/__tests__/tool-display-name.test.ts | 79 +++++++++++++++++++ packages/ui/src/search-modal.tsx | 32 +++++++- packages/ui/src/shell-controls-copy.ts | 24 ++++++ packages/ui/src/tool-activity/copy.ts | 76 ++++++++++++++++++ packages/ui/src/tool-activity/display-name.ts | 32 ++++++-- 19 files changed, 489 insertions(+), 52 deletions(-) create mode 100644 packages/ui/src/__tests__/tool-display-name.test.ts diff --git a/apps/desktop/e2e/fixture-thread-search.spec.ts b/apps/desktop/e2e/fixture-thread-search.spec.ts index e4b50b1bc1..34123bb7a4 100644 --- a/apps/desktop/e2e/fixture-thread-search.spec.ts +++ b/apps/desktop/e2e/fixture-thread-search.spec.ts @@ -43,7 +43,7 @@ test('fixture-seeded transcripts return content hits with turn ids', async ({ hit.target?.kind === 'thread' && hit.target.turnId === 'turn-prompt-rail-3', ); - expect(content?.summary).toBe('用户消息'); + expect(content?.summary).toBeUndefined(); if (!content || content.target?.kind !== 'thread') { throw new Error(`expected a thread search hit, got ${JSON.stringify(content)}`); } @@ -53,5 +53,6 @@ test('fixture-seeded transcripts return content hits with turn ids', async ({ sessionId: desktopSessionKey({ hostId, sessionId: PROMPT_RAIL_SESSION_ID }), turnId: 'turn-prompt-rail-3', sequence: 4, + matchKind: 'user_message', }); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 3e2f36007d..6674a48ce9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -30,6 +30,7 @@ import { type ClientCapabilityServiceCallFrame, } from '@maka/runtime-host/protocol'; import { z } from 'zod'; +import { buildBrowserTools } from '../browser/browser-tools.js'; import { buildClientSettingsTools } from '../client-settings-tools.js'; import { browserOriginAdmission } from '../browser/browser-origin-admission.js'; import { buildRiveWorkflowTool } from '../rive-workflow-tool.js'; @@ -84,6 +85,21 @@ test('publishes self-described session-affine Browser and Computer Use offers', ); }); +test('preserves built-in Browser labels for non-localized consumers', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: buildBrowserTools(), + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + }); + + assert.deepEqual( + provider.offers().find((offer) => offer.offerId === 'desktop_browser') + ?.tools.map((tool) => tool.annotations?.title), + ['浏览器导航', '浏览器快照', '浏览器点击', '浏览器输入', '浏览器等待', '浏览器提取'], + ); +}); + test('remote providers do not request Host paths and use a Client-owned cwd', async () => { let invokedCwd: string | undefined; const provider = createDesktopNativeCapabilityProvider( diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts index 3f127d52a9..4df78a6194 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts @@ -49,6 +49,25 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as ts: 1, text: '第 3 个问题:这一段的调用链路是怎样的?', }, + { + type: 'tool_call', + id: 'browser-call', + turnId: 'turn-host-3', + ts: 2, + toolName: 'mcp__desktop_browser__browser_navigate', + displayName: 'browser_navigate', + intent: '打开检查页面', + args: {}, + }, + { + type: 'tool_result', + id: 'browser-result', + turnId: 'turn-host-3', + ts: 3, + toolUseId: 'browser-call', + isError: true, + content: { kind: 'text', text: '检查页面失败' }, + }, ], close: async () => { closed += 1; @@ -66,10 +85,11 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as limit: 10, }), ); - assert.equal(titleHits[0]?.summary, '任务标题'); + assert.equal(titleHits[0]?.summary, undefined); assert.deepEqual(titleHits[0]?.target, { kind: 'thread', sessionId: 'searchable-session', + matchKind: 'session_title', }); const contentHits = expectResults( @@ -80,14 +100,27 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as }), ); assert.equal(contentHits.length, 1); - assert.equal(contentHits[0]?.summary, '用户消息'); + assert.equal(contentHits[0]?.summary, undefined); assert.deepEqual(contentHits[0]?.target, { kind: 'thread', sessionId: 'searchable-session', turnId: 'turn-host-3', sequence: 0, + matchKind: 'user_message', + }); + + const toolHit = expectResults( + await handler({} as never, { source: 'thread', query: '打开检查', limit: 10 }), + )[0]; + assert.deepEqual(toolHit?.target?.tool, { + name: 'mcp__desktop_browser__browser_navigate', + displayName: 'browser_navigate', }); - assert.equal(closed, 2); + const resultHit = expectResults( + await handler({} as never, { source: 'thread', query: '检查页面失败', limit: 10 }), + )[0]; + assert.equal(resultHit?.target?.toolResultIsError, true); + assert.equal(closed, 4); }); test('a Runtime Host transcript failure yields no content hit', async () => { @@ -128,6 +161,9 @@ function expectResults(outcome: unknown): Array<{ sessionId: string; turnId?: string; sequence?: number; + matchKind?: string; + tool?: { name: string; displayName?: string }; + toolResultIsError?: boolean; }; }> { if (!Array.isArray(outcome)) { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index df1ac3b1c6..8cb108bff5 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -205,7 +205,7 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 2); }); - it('renders one ordered timeline: thinking before its tool and answer', () => { + it('renders thinking, answer, and tool in one ordered timeline', () => { const markup = renderLiveTurn({ turnId: 'turn-1', phase: 'streamed', @@ -215,7 +215,8 @@ describe('single live-turn handoff', () => { text: { text: '最终答案', truncated: false, complete: true }, tools: [{ toolUseId: 'tool-1', - toolName: 'Bash', + toolName: 'mcp__fixture__ordered_tool', + displayName: 'Timeline tool marker', stepId: 'assistant-1', status: 'running', args: {}, @@ -226,9 +227,8 @@ describe('single live-turn handoff', () => { // Thinking and tools own their disclosures; do not wrap them in another. assert.equal((markup.match(/maka-processing-block/g) ?? []).length, 0); - assert.ok(markup.indexOf('深度思考') >= 0); - assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案')); - assert.ok(markup.indexOf('最终答案') < markup.indexOf('Bash')); + assert.ok(markup.indexOf('先检查') < markup.indexOf('最终答案')); + assert.ok(markup.indexOf('最终答案') < markup.indexOf('Timeline tool marker')); assert.equal((markup.match(/data-turn-id=/g) ?? []).length, 1); }); diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts index cac84c1e77..18d9902111 100644 --- a/apps/desktop/src/main/__tests__/thread-search.test.ts +++ b/apps/desktop/src/main/__tests__/thread-search.test.ts @@ -428,7 +428,7 @@ describe('runThreadSearch', () => { sessionId: 's1', matchKind: 'session_title', }); - assert.equal(titleHit.summary, '任务标题'); + assert.equal(titleHit.summary, undefined); assert.equal(titleHit.url, undefined); assert.match(titleHit.snippet ?? '', /\[redacted\]/); assert.equal(titleHit.snippet?.includes('sk-ant-test-secret-token-12345'), false); @@ -445,7 +445,7 @@ describe('runThreadSearch', () => { matchKind: 'user_message', messageTimestamp: 1_700_000_000_000, }); - assert.equal(messageHit.summary, '用户消息'); + assert.equal(messageHit.summary, undefined); assert.equal(messageHit.url, undefined); }); @@ -536,6 +536,7 @@ describe('thread search text projection', () => { ); assert.equal(hits[0]?.target?.matchKind, 'tool_result'); assert.equal(hits[0]?.target?.messageId, 'tr1'); + assert.equal(hits[0]?.target?.toolResultIsError, false); }); it('indexes tool intent but not tool names or display names', async () => { @@ -562,6 +563,25 @@ describe('thread search text projection', () => { assert.equal(hits.length, 1); assert.equal(hits[0]?.target?.matchKind, 'tool_intent'); assert.equal(hits[0]?.target?.messageId, 'tc1'); + assert.deepEqual(hits[0]?.target?.tool, { + name: 'Bash', + displayName: 'Shell command', + }); + }); + + it('redacts tool labels in result metadata', async () => { + const message = { + ...toolCall('find the metadata needle'), + displayName: 'token=secret-search-label', + }; + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'metadata needle', limit: 5 }, + makeDeps({ s1: { session: session({ id: 's1' }), messages: [message] } }), + ), + ); + + assert.equal(hits[0]?.target?.tool?.displayName, 'token=[redacted]'); }); it('indexes assistant answers without exposing thinking', async () => { diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts index 194188998a..e6ad58933e 100644 --- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-search-ipc-main.ts @@ -69,6 +69,11 @@ function projectDesktopSearchResult(result: SearchResult): SearchResult { sessionId: result.target.sessionId, ...(result.target.turnId !== undefined ? { turnId: result.target.turnId } : {}), ...(result.target.sequence !== undefined ? { sequence: result.target.sequence } : {}), + ...(result.target.matchKind !== undefined ? { matchKind: result.target.matchKind } : {}), + ...(result.target.tool !== undefined ? { tool: result.target.tool } : {}), + ...(result.target.toolResultIsError !== undefined + ? { toolResultIsError: result.target.toolResultIsError } + : {}), }, }; } diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index b3a0a58bc4..27d7379018 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -116,6 +116,10 @@ export type SearchResultTarget = { messageId?: string; /** Stable machine-readable classification of the matched transcript surface. */ matchKind?: ThreadSearchMatchKind; + /** Tool identity for UI-owned presentation of tool-intent matches. */ + tool?: { name: string; displayName?: string }; + /** Outcome for UI-owned presentation of tool-result matches. */ + toolResultIsError?: boolean; /** Timestamp of the matched stored message; absent for session-title matches. */ messageTimestamp?: number; }; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index fb29abb545..af41169af0 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -849,6 +849,7 @@ export interface ToolCallMessage { toolName: string; /** Stable semantic category for presentation; absent on legacy rows. */ activityKind?: ToolActivityKind; + /** Provider/compatibility label; built-in UI copy must resolve from toolName and the active locale. */ displayName?: string; intent?: string; args: unknown; diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts index 7e6086e8d5..1f0b7b4387 100644 --- a/packages/core/src/thread-search.ts +++ b/packages/core/src/thread-search.ts @@ -279,7 +279,6 @@ export async function runThreadSearch( results.push({ source: THREAD_SOURCE, title: searchableTitle, - summary: '任务标题', snippet, target: { kind: 'thread', @@ -339,7 +338,6 @@ export async function runThreadSearch( results.push({ source: THREAD_SOURCE, title: redactSecrets(session.name), - summary: formatSearchResultSummary(message), snippet, // PR-SEARCH-1.5: navigation target via discriminated union; no // `url` field for thread results (maka://session is deferred). @@ -350,6 +348,17 @@ export async function runThreadSearch( sequence: messageIndex, messageId: message.id, matchKind: threadSearchMatchKind(message), + ...(message.type === 'tool_call' + ? { + tool: { + name: message.toolName, + ...(message.displayName + ? { displayName: redactSecrets(message.displayName) } + : {}), + }, + } + : {}), + ...(message.type === 'tool_result' ? { toolResultIsError: message.isError } : {}), messageTimestamp: message.ts, }, }); @@ -471,31 +480,6 @@ export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatch } } -export function formatSearchResultSummary(message: StoredMessage): string { - switch (message.type) { - case 'user': - return '用户消息'; - case 'assistant': - return '助手回复'; - case 'tool_call': - return message.displayName - ? `工具调用:${message.displayName}` - : `工具调用:${message.toolName}`; - case 'tool_result': - return message.isError ? '工具结果:失败' : '工具结果:成功'; - case 'permission_decision': - return '权限记录'; - case 'token_usage': - return '用量记录'; - case 'turn_state': - return '回合状态'; - case 'workhub_coordination': - return 'WorkHub 协调记录'; - case 'system_note': - return '系统记录'; - } -} - /** * Extract user-visible answer text from a stored message. Returns `undefined` * for excluded message kinds (system notes, token usage, turn state, diff --git a/packages/runtime/src/__tests__/history-tools.test.ts b/packages/runtime/src/__tests__/history-tools.test.ts index 5fbfbdd384..404edac2a0 100644 --- a/packages/runtime/src/__tests__/history-tools.test.ts +++ b/packages/runtime/src/__tests__/history-tools.test.ts @@ -117,8 +117,12 @@ test('SearchHistory returns typed message hits from current and other sessions', assert.ok(messageRows.every((row) => typeof row.message_id === 'string')); assert.ok(messageRows.every((row) => typeof row.message_timestamp === 'number')); assert.deepEqual( - new Set(result.rows.map((row) => row.match_kind)), - new Set(['session_title', 'user_message', 'assistant_message']), + new Map(result.rows.map((row) => [row.match_kind, row.summary])), + new Map([ + ['session_title', 'Session title'], + ['user_message', 'User message'], + ['assistant_message', 'Assistant response'], + ]), ); assert.equal(result.rows.find((row) => row.session_id === 'current')?.is_current_session, true); assert.ok(result.rows.every((row) => row.turn_id !== 'current-turn')); @@ -158,6 +162,68 @@ test('SearchHistory exposes and consumes an opaque session continuation', async assert.equal(second.next_cursor, undefined); }); +test('SearchHistory summaries echo invoked wire tool names and cover tool outcomes', async () => { + const needle = 'history summary needle'; + const messages = new Map([ + [ + 'past', + [ + { + type: 'tool_call', + id: 'legacy-browser', + turnId: 'turn-1', + ts: 1, + toolName: 'mcp__desktop_browser__browser_navigate', + displayName: '浏览器导航', + intent: needle, + args: {}, + }, + { + type: 'tool_call', + id: 'secret-tool', + turnId: 'turn-1', + ts: 2, + toolName: 'sk-ant-test-secret-token-12345', + displayName: 'Acme Lookup', + intent: needle, + args: {}, + }, + { + type: 'tool_result', + id: 'success-result', + turnId: 'turn-1', + ts: 3, + toolUseId: 'legacy-browser', + isError: false, + content: { note: needle } as never, + }, + { + type: 'tool_result', + id: 'failure-result', + turnId: 'turn-1', + ts: 4, + toolUseId: 'secret-tool', + isError: true, + content: { note: needle } as never, + }, + ], + ], + ]); + const result = (await buildSearchHistoryTool( + historyDeps([session('past', 'Past', 1)], messages), + ).impl({ query: needle, limit: 10 }, context())) as { + rows: Array<{ summary: string }>; + }; + + assert.ok( + result.rows.some((row) => row.summary === 'Tool call: mcp__desktop_browser__browser_navigate'), + ); + assert.ok(result.rows.some((row) => row.summary === 'Tool result: Succeeded')); + assert.ok(result.rows.some((row) => row.summary === 'Tool result: Failed')); + assert.match(JSON.stringify(result.rows), /\[redacted\]/u); + assert.doesNotMatch(JSON.stringify(result.rows), /sk-ant-test-secret-token-12345/u); +}); + test('ReadHistory returns a bounded visible excerpt without reasoning or raw tool data', async () => { const huge = `finished ${'x'.repeat(HISTORY_READ_MAX_BYTES * 2)}`; const messages = new Map([ diff --git a/packages/runtime/src/history-tools.ts b/packages/runtime/src/history-tools.ts index 67357be10e..913d64fe88 100644 --- a/packages/runtime/src/history-tools.ts +++ b/packages/runtime/src/history-tools.ts @@ -24,6 +24,7 @@ import { normalizeSearchLimit, normalizeSearchQuery, type SearchError, + type SearchResult, type ThreadSearchMatchKind, } from '@maka/core/search'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; @@ -58,6 +59,25 @@ type HistoryReadErrorReason = | 'empty_transcript' | 'aborted'; +function historySearchSummary(row: SearchResult): string { + switch (row.target?.matchKind) { + case 'session_title': + return 'Session title'; + case 'user_message': + return 'User message'; + case 'assistant_message': + return 'Assistant response'; + case 'tool_intent': { + const tool = row.target.tool; + return `Tool call: ${redactSecrets(tool?.name ?? 'unknown')}`; + } + case 'tool_result': + return row.target.toolResultIsError ? 'Tool result: Failed' : 'Tool result: Succeeded'; + default: + return redactSecrets(row.summary ?? ''); + } +} + interface HistoryTurnMessage { readonly messageId: string; readonly matchKind: Exclude; @@ -178,7 +198,7 @@ export function buildSearchHistoryTool(deps: HistoryToolDeps): MakaTool { : {}), is_current_session: row.target.sessionId === context.sessionId, title: row.title, - summary: redactSecrets(row.summary ?? ''), + summary: historySearchSummary(row), snippet: row.snippet ?? '', ...(session?.lastMessageAt !== undefined ? { last_message_at: session.lastMessageAt } diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index cb81443d34..10347f6557 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -175,7 +175,7 @@ export interface MakaTool

{ description: string; /** Zod schema describing the tool's argument shape. */ parameters: unknown; - /** Optional UI display name. */ + /** Optional provider or compatibility label. Localized built-in UI copy resolves from the canonical name. */ displayName?: string; /** Stable semantic category used by UI presentation; never carries styling. */ activityKind?: ToolActivityKind; diff --git a/packages/ui/src/__tests__/search-modal-source.test.ts b/packages/ui/src/__tests__/search-modal-source.test.ts index 01c45b0672..47095d1a7a 100644 --- a/packages/ui/src/__tests__/search-modal-source.test.ts +++ b/packages/ui/src/__tests__/search-modal-source.test.ts @@ -21,7 +21,11 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { SearchResult } from '@maka/core/search'; -import { createThreadSearchSource } from '../search-modal.js'; +import { + createThreadSearchSource, + formatThreadSearchResultSummary, +} from '../search-modal.js'; + function result(sessionId: string): SearchResult { return { source: 'thread', @@ -77,6 +81,43 @@ function createHarness() { } describe('thread search source', () => { + it('formats result metadata in the active locale', () => { + const summary = ( + matchKind: NonNullable['matchKind']>, + locale: 'zh' | 'en', + target: Partial> = {}, + ) => formatThreadSearchResultSummary({ + source: 'thread', + title: 'Task', + target: { kind: 'thread', sessionId: 's1', matchKind, ...target }, + }, locale); + + assert.equal(summary('session_title', 'en'), 'Task title'); + assert.equal(summary('user_message', 'zh'), '用户消息'); + assert.equal(summary('assistant_message', 'en'), 'Assistant response'); + assert.equal(summary('tool_result', 'zh'), '工具结果:成功'); + assert.equal(summary('tool_result', 'en', { toolResultIsError: true }), 'Tool result: Failed'); + assert.equal( + summary('tool_intent', 'en', { + tool: { name: 'Bash', displayName: '执行命令' }, + }), + 'Tool call: Run command', + ); + assert.equal( + summary('tool_intent', 'zh', { + tool: { name: 'mcp__acme__lookup', displayName: 'Acme Lookup' }, + }), + '工具调用:Acme Lookup', + ); + assert.equal( + summary('tool_intent', 'zh', { + tool: { name: 'tool_search', displayName: 'Enable capability' }, + }), + '工具调用:启用能力', + ); + assert.equal(summary('tool_intent', 'en'), 'Tool call'); + }); + it('keeps the selectable mapping while a filtered follow-up is pending', async () => { const harness = createHarness(); const initial = harness.source.search('current'); diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 97f234d4d8..2bae0c89f3 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -31,6 +31,7 @@ import { computerRunningLabel, isComputerTool, } from '../tool-activity/computer-action-label.js'; +import { BUILTIN_TOOL_LABELS } from '../tool-activity/copy.js'; function renderToStaticMarkup(node: ReactNode, locale: 'zh' | 'en' = 'zh'): string { return renderReactToStaticMarkup(createElement(LocaleProvider, { @@ -95,6 +96,21 @@ describe('tool activity presentation', () => { assert.doesNotMatch(markup, /控制本机应用|切换并重试/); }); + it('localizes Desktop Browser proxy rows', () => { + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + toolUseId: 'browser-navigation', + toolName: 'mcp__desktop_browser__browser_navigate', + displayName: 'browser_navigate', + status: 'errored', + args: { url: 'https://example.com' }, + } satisfies ToolActivityItem], + }), 'en'); + + assert.match(markup, /Browser navigation/); + assert.doesNotMatch(markup, /browser_navigate/); + }); + it('localizes file-write result summaries', () => { const result = { kind: 'file_write' as const, @@ -435,7 +451,7 @@ describe('tool activity presentation', () => { assert.doesNotMatch(render(firstId), new RegExp(firstId)); assert.doesNotMatch(render(secondId), new RegExp(secondId)); - assert.match(render(firstId), /Bash/); + assert.match(render(firstId), new RegExp(BUILTIN_TOOL_LABELS.Bash.zh)); }); it('disambiguates code copy actions by their tool call', () => { diff --git a/packages/ui/src/__tests__/tool-display-name.test.ts b/packages/ui/src/__tests__/tool-display-name.test.ts new file mode 100644 index 0000000000..3f28ba0153 --- /dev/null +++ b/packages/ui/src/__tests__/tool-display-name.test.ts @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { MAKA_CATALOG_TOOLS } from '@maka/core/tool-catalog'; +import { UI_LOCALES } from '@maka/core/ui-locale'; +import type { ToolActivityItem } from '../materialize.js'; +import { resolveToolDisplayName } from '../tool-activity/display-name.js'; +import { BUILTIN_TOOL_LABELS } from '../tool-activity/copy.js'; + +function item(toolName: string, displayName?: string): ToolActivityItem { + return { + toolUseId: toolName, + toolName, + ...(displayName !== undefined ? { displayName } : {}), + status: 'completed', + args: {}, + }; +} + +describe('tool display names', () => { + it('renders persisted built-in tools in the active locale', () => { + const browser = item('browser_navigate', '浏览器导航'); + const search = item('WebSearch', 'Web search'); + + assert.equal(resolveToolDisplayName(browser, 'en'), 'Browser navigation'); + assert.equal(resolveToolDisplayName(browser, 'zh'), '浏览器导航'); + assert.equal(resolveToolDisplayName(search, 'en'), 'Web search'); + assert.equal(resolveToolDisplayName(search, 'zh'), '联网搜索'); + }); + + it('resolves every built-in tool through every locale cell', () => { + for (const locale of UI_LOCALES) { + for (const [toolName, labels] of Object.entries(BUILTIN_TOOL_LABELS)) { + assert.equal( + resolveToolDisplayName(item(toolName, 'wrong persisted label'), locale), + labels[locale], + `${toolName} in ${locale}`, + ); + } + } + }); + + it('covers every product catalog tool with a built-in label', () => { + for (const { name } of MAKA_CATALOG_TOOLS) { + assert.ok(Object.hasOwn(BUILTIN_TOOL_LABELS, name), name); + } + }); + + it('localizes Maka-owned tools outside the product binding catalog', () => { + assert.equal(resolveToolDisplayName(item('MakaSettingsGet', 'Read Maka settings'), 'zh'), '读取 Maka 设置'); + assert.equal(resolveToolDisplayName(item('SkillSearch', 'SkillSearch'), 'zh'), '搜索技能'); + assert.equal(resolveToolDisplayName(item('SubmitPlan'), 'zh'), '提交计划'); + assert.equal(resolveToolDisplayName(item('tool_search'), 'zh'), '启用能力'); + }); + + it('keeps provider labels for external tools and falls back to their identifiers', () => { + assert.equal(resolveToolDisplayName(item('mcp__acme__lookup', 'Acme Lookup'), 'zh'), 'Acme Lookup'); + assert.equal(resolveToolDisplayName(item('custom_lookup'), 'en'), 'custom_lookup'); + assert.equal(resolveToolDisplayName(item('empty_label', ''), 'en'), 'empty_label'); + }); +}); diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index ac13374e1a..8a779256ce 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -31,6 +31,7 @@ import { import { AstryxLocaleProvider } from './astryx-i18n.js'; import { getShellControlsCopy } from './shell-controls-copy.js'; import { useUiLocale } from './locale-context.js'; +import { resolveToolName } from './tool-activity/display-name.js'; interface SearchModalDeps { searchThread( @@ -50,6 +51,32 @@ interface SearchItemAuxiliaryData { type SearchItem = SearchableItem; +export function formatThreadSearchResultSummary( + result: SearchResult, + locale: UiLocale, +): string | undefined { + if (result.target?.kind !== 'thread' || !result.target.matchKind) return result.summary; + const copy = getShellControlsCopy(locale).search.resultSummary; + switch (result.target.matchKind) { + case 'session_title': + return copy.sessionTitle; + case 'user_message': + return copy.userMessage; + case 'assistant_message': + return copy.assistantMessage; + case 'tool_intent': { + const tool = result.target.tool; + return copy.toolCall( + tool ? resolveToolName(tool.name, tool.displayName, locale) : undefined, + ); + } + case 'tool_result': + return result.target.toolResultIsError + ? copy.toolResultFailure + : copy.toolResultSuccess; + } +} + interface ThreadSearchSourceInput { searchThread?: SearchModalDeps['searchThread']; canNavigate: boolean; @@ -253,14 +280,15 @@ export function SearchModal(props: { renderItem={(item) => { const result = item.auxiliaryData?.result; if (!result) return item.label; + const summary = formatThreadSearchResultSummary(result, locale); return (

{result.title}
- {result.summary && ( + {summary && (
- {result.summary} + {summary}
)} {result.snippet && ( diff --git a/packages/ui/src/shell-controls-copy.ts b/packages/ui/src/shell-controls-copy.ts index c1d184544a..ce7591c2fd 100644 --- a/packages/ui/src/shell-controls-copy.ts +++ b/packages/ui/src/shell-controls-copy.ts @@ -50,6 +50,14 @@ type ShellControlsCopy = { results(count: number): string; truncatedResults(count: number): string; resultsLabel: string; + resultSummary: { + sessionTitle: string; + userMessage: string; + assistantMessage: string; + toolCall(name?: string): string; + toolResultSuccess: string; + toolResultFailure: string; + }; }; }; @@ -83,6 +91,14 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { results: (count: number) => `找到 ${count} 条匹配`, truncatedResults: (count: number) => `结果较多,已显示前 ${count} 条`, resultsLabel: '搜索结果', + resultSummary: { + sessionTitle: '任务标题', + userMessage: '用户消息', + assistantMessage: '助手回复', + toolCall: (name?: string) => name ? `工具调用:${name}` : '工具调用', + toolResultSuccess: '工具结果:成功', + toolResultFailure: '工具结果:失败', + }, }, }, en: { @@ -115,6 +131,14 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { results: (count: number) => `${count} ${count === 1 ? 'match' : 'matches'}`, truncatedResults: (count: number) => `Many results; showing the first ${count}`, resultsLabel: 'Search results', + resultSummary: { + sessionTitle: 'Task title', + userMessage: 'User message', + assistantMessage: 'Assistant response', + toolCall: (name?: string) => name ? `Tool call: ${name}` : 'Tool call', + toolResultSuccess: 'Tool result: Succeeded', + toolResultFailure: 'Tool result: Failed', + }, }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index bc1fb04be6..1020e41e55 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -23,6 +23,82 @@ type BackgroundTerminalStatus = 'running' | 'completed' | 'failed' | 'timed_out' type WebCredentialCopyKey = 'env' | 'settings' | 'missing' | 'unknown'; type WebGuidanceKey = 'env' | 'settings' | 'rate_limited' | 'not_configured' | 'timed_out' | 'privacy_mode' | 'unknown'; +type LocalizedLabel = Record; + +/** Maka-owned tool labels, grouped by tool so translations stay aligned. */ +export const BUILTIN_TOOL_LABELS = { + apply_patch: { zh: '应用补丁', en: 'Apply patch' }, + Read: { zh: '读取文件', en: 'Read file' }, + Write: { zh: '写入文件', en: 'Write file' }, + Edit: { zh: '编辑文件', en: 'Edit file' }, + FormatJson: { zh: '格式化 JSON', en: 'Format JSON' }, + Glob: { zh: '查找文件', en: 'Find files' }, + Grep: { zh: '搜索文本', en: 'Search text' }, + Bash: { zh: '运行命令', en: 'Run command' }, + StopBackgroundTask: { zh: '停止后台任务', en: 'Stop background task' }, + WriteStdin: { zh: '写入终端', en: 'Write to terminal' }, + ArchiveRead: { zh: '读取归档结果', en: 'Read archived result' }, + WebFetch: { zh: '网页读取', en: 'Web fetch' }, + WebSearch: { zh: '联网搜索', en: 'Web search' }, + web_search: { zh: '联网搜索', en: 'Web search' }, + ScheduledTask: { zh: '定时任务', en: 'Scheduled task' }, + AskUserQuestion: { zh: '询问用户', en: 'Ask user' }, + request_sandbox_boundary: { zh: '请求沙箱边界', en: 'Request sandbox boundary' }, + Skill: { zh: '加载技能', en: 'Load skill' }, + SkillSearch: { zh: '搜索技能', en: 'Search skills' }, + MakaSettingsGet: { zh: '读取 Maka 设置', en: 'Read Maka settings' }, + MakaSettingsUpdate: { zh: '更新 Maka 设置', en: 'Update Maka settings' }, + memory_remember: { zh: '记住信息', en: 'Remember information' }, + memory_extract: { zh: '提取记忆', en: 'Extract memory' }, + GoalSet: { zh: '设置目标', en: 'Set goal' }, + GoalClear: { zh: '清除目标', en: 'Clear goal' }, + GoalPause: { zh: '暂停目标', en: 'Pause goal' }, + GoalResume: { zh: '恢复目标', en: 'Resume goal' }, + GoalStatus: { zh: '目标状态', en: 'Goal status' }, + task_create: { zh: '创建任务', en: 'Create task' }, + task_update: { zh: '更新任务', en: 'Update task' }, + task_list: { zh: '任务列表', en: 'List tasks' }, + task_get: { zh: '获取任务', en: 'Get task' }, + SearchHistory: { zh: '搜索对话历史', en: 'Search conversation history' }, + ReadHistory: { zh: '读取对话历史', en: 'Read conversation history' }, + agent_spawn: { zh: '启动智能体', en: 'Agent' }, + agent_list: { zh: '智能体列表', en: 'Agent list' }, + agent_output: { zh: '智能体输出', en: 'Agent output' }, + agent_swarm_status: { zh: '智能体集群状态', en: 'Agent swarm status' }, + view_agent_graph: { zh: '查看智能体图', en: 'View agent graph' }, + update_agent_graph: { zh: '更新智能体图', en: 'Update agent graph' }, + yield_agent_graph: { zh: '让出智能体图', en: 'Yield agent graph' }, + deep_research_start: { zh: '初始化研究工作区', en: 'Initialize research workspace' }, + deep_research_read_artifact: { zh: '读取研究产物', en: 'Read research artifact' }, + deep_research_save_artifact: { zh: '保存研究产物', en: 'Save research artifact' }, + deep_research_update_checklist: { zh: '更新研究清单', en: 'Update research checklist' }, + deep_research_record_step: { zh: '记录研究步骤', en: 'Record research step' }, + deep_research_checkpoint: { zh: '保存研究检查点', en: 'Checkpoint research' }, + deep_research_status: { zh: '读取研究工作区', en: 'Read research workspace' }, + deep_research_complete: { zh: '完成研究', en: 'Complete research' }, + ExploreAgent: { zh: '只读探索', en: 'Read-only exploration' }, + maka_computer: { zh: 'Maka Computer', en: 'Maka Computer' }, + SubmitPlan: { zh: '提交计划', en: 'Submit plan' }, + update_plan: { zh: '更新计划', en: 'Update plan' }, + cancel_plan: { zh: '取消计划', en: 'Cancel plan' }, + browser_navigate: { zh: '浏览器导航', en: 'Browser navigation' }, + browser_snapshot: { zh: '浏览器快照', en: 'Browser snapshot' }, + browser_click: { zh: '浏览器点击', en: 'Browser click' }, + browser_type: { zh: '浏览器输入', en: 'Browser input' }, + browser_wait: { zh: '浏览器等待', en: 'Browser wait' }, + browser_extract: { zh: '浏览器提取', en: 'Browser extract' }, + MakaClientSettingsGet: { zh: '读取客户端设置', en: 'Read client settings' }, + MakaClientSettingsUpdate: { zh: '更新客户端设置', en: 'Update client settings' }, + RiveWorkflow: { zh: 'Rive 工作流', en: 'Rive workflow' }, +} satisfies Record; + +export type BuiltinToolName = keyof typeof BUILTIN_TOOL_LABELS; + +export function getBuiltinToolLabel(toolName: string, locale: UiLocale): string | undefined { + if (!Object.hasOwn(BUILTIN_TOOL_LABELS, toolName)) return undefined; + return BUILTIN_TOOL_LABELS[toolName as BuiltinToolName][locale]; +} + export interface ToolActivityCopy { errorLabel: string; /** The two outcomes a tool row spells out next to its name. */ diff --git a/packages/ui/src/tool-activity/display-name.ts b/packages/ui/src/tool-activity/display-name.ts index c75cfc503a..6d18254db9 100644 --- a/packages/ui/src/tool-activity/display-name.ts +++ b/packages/ui/src/tool-activity/display-name.ts @@ -18,15 +18,15 @@ */ /** - * The name a tool row shows. A tool carries its own `displayName` when the - * backend named it; the search/legacy activation connector gets a localized label - * (its raw name reads as an implementation detail); everything else falls back - * to the canonical tool name. + * The name a tool row shows. Maka-owned tools use locale copy, connectors use + * their activation summary, and external tools keep their provider label before + * falling back to the canonical tool name. */ import type { UiLocale } from '@maka/core/ui-locale'; import type { ToolActivityItem } from '../materialize.js'; import { describeLoadToolResult, loadToolDisplayName } from '../tool-format.js'; +import { getBuiltinToolLabel } from './copy.js'; const CONNECTOR_TOOL_NAMES: ReadonlySet = new Set([ 'tool_search', @@ -34,16 +34,36 @@ const CONNECTOR_TOOL_NAMES: ReadonlySet = new Set([ 'load_tool', ]); +const BUILTIN_PROXY_PREFIXES = [ + 'mcp__desktop_browser__', + 'mcp__desktop_computer_use__', + 'mcp__desktop_settings__', + 'mcp__desktop_rive__', +] as const; + export function isConnectorTool(name: string): boolean { return CONNECTOR_TOOL_NAMES.has(name); } +export function resolveToolName( + toolName: string, + displayName: string | undefined, + locale: UiLocale, +): string { + if (isConnectorTool(toolName)) return loadToolDisplayName(locale); + const proxyPrefix = BUILTIN_PROXY_PREFIXES.find((prefix) => toolName.startsWith(prefix)); + const builtinLabel = getBuiltinToolLabel( + proxyPrefix ? toolName.slice(proxyPrefix.length) : toolName, + locale, + ); + return (builtinLabel ?? displayName) || toolName; +} + export function resolveToolDisplayName(item: ToolActivityItem, locale: UiLocale): string { - if (item.displayName) return item.displayName; if (isConnectorTool(item.toolName)) { const value = item.result?.kind === 'json' ? item.result.value : undefined; return describeLoadToolResult(item.args, value, locale)?.actionLabel ?? loadToolDisplayName(locale); } - return item.toolName; + return resolveToolName(item.toolName, item.displayName, locale); } From 5641e0f6527558b4be10423859b546d18a878539 Mon Sep 17 00:00:00 2001 From: colafornia Date: Fri, 28 Aug 2026 23:48:17 +0800 Subject: [PATCH 2/3] fix(i18n): enforce built-in tool coverage --- .../e2e/accessibility-coverage.spec.ts | 2 +- .../desktop/e2e/fixture-thread-search.spec.ts | 2 +- ...builtin-tool-presentation-contract.test.ts | 61 +++++++++++++++++++ .../runtime-host-native-capabilities.test.ts | 1 + .../runtime-host-search-ipc-main.test.ts | 4 +- .../src/main/__tests__/thread-search.test.ts | 4 +- packages/core/src/thread-search.ts | 27 ++++++++ packages/runtime-host/package.json | 1 + .../src/test-only/interactive-run-composer.ts | 20 ++++++ .../src/__tests__/history-tools.test.ts | 18 +++--- packages/runtime/src/history-tools.ts | 22 +------ .../src/__tests__/tool-display-name.test.ts | 25 +++++--- packages/ui/src/tool-activity/copy.ts | 2 + scripts/release-cli-file-policy.test.mjs | 1 + 14 files changed, 146 insertions(+), 44 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts create mode 100644 packages/runtime-host/src/test-only/interactive-run-composer.ts diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 8d55ab9c85..e6869a08ce 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -159,7 +159,7 @@ test('data-backed conversation exposes ordered todos and keyboard access to tool await expect(page.getByRole('main')).toHaveCount(1); await enterMainFromSkipLink(page); - const toolCall = page.getByRole('button', { name: /^检查测试状态/ }); + const toolCall = page.getByRole('button', { name: /^运行命令/ }); await tabTo(page, toolCall, 'tool result'); await page.keyboard.press('Enter'); await expect(toolCall).toHaveAttribute('aria-expanded', 'true'); diff --git a/apps/desktop/e2e/fixture-thread-search.spec.ts b/apps/desktop/e2e/fixture-thread-search.spec.ts index 34123bb7a4..e9f6a27b67 100644 --- a/apps/desktop/e2e/fixture-thread-search.spec.ts +++ b/apps/desktop/e2e/fixture-thread-search.spec.ts @@ -43,7 +43,7 @@ test('fixture-seeded transcripts return content hits with turn ids', async ({ hit.target?.kind === 'thread' && hit.target.turnId === 'turn-prompt-rail-3', ); - expect(content?.summary).toBeUndefined(); + expect(content?.summary).toBe('用户消息'); if (!content || content.target?.kind !== 'thread') { throw new Error(`expected a thread search hit, got ${JSON.stringify(content)}`); } diff --git a/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts b/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts new file mode 100644 index 0000000000..a0c923936e --- /dev/null +++ b/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; +import { buildComputerUseTools } from '@maka/runtime/computer-use-tools'; +import { createInteractiveRunComposer } from '@maka/runtime-host/test-only/interactive-run-composer'; +import { BUILTIN_TOOL_LABELS } from '@maka/ui'; +import { buildBrowserTools } from '../browser/browser-tools.js'; +import { buildClientSettingsTools } from '../client-settings-tools.js'; +import { buildRiveWorkflowTool } from '../rive-workflow-tool.js'; + +test('every default Runtime Host tool has localized Desktop presentation', () => { + const composer = createInteractiveRunComposer({ + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as never, + memory: {} as never, + sessionTodo: {} as never, + builtinTools: {}, + }); + + assert.deepEqual( + composer.tools + .map(({ name }) => name) + .filter((name) => !Object.hasOwn(BUILTIN_TOOL_LABELS, name)), + [], + ); +}); + +test('every Desktop-owned tool has localized presentation', () => { + const tools = [ + ...buildBrowserTools(), + ...buildComputerUseTools({ backend: {} as never }), + ...buildClientSettingsTools({} as never), + buildRiveWorkflowTool(), + ]; + + assert.deepEqual( + tools.map(({ name }) => name).filter((name) => !Object.hasOwn(BUILTIN_TOOL_LABELS, name)), + [], + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 6674a48ce9..faaf160c3e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -88,6 +88,7 @@ test('publishes self-described session-affine Browser and Computer Use offers', test('preserves built-in Browser labels for non-localized consumers', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: buildBrowserTools(), + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts index 4df78a6194..80f55964ab 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts @@ -85,7 +85,7 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as limit: 10, }), ); - assert.equal(titleHits[0]?.summary, undefined); + assert.equal(titleHits[0]?.summary, '任务标题'); assert.deepEqual(titleHits[0]?.target, { kind: 'thread', sessionId: 'searchable-session', @@ -100,7 +100,7 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as }), ); assert.equal(contentHits.length, 1); - assert.equal(contentHits[0]?.summary, undefined); + assert.equal(contentHits[0]?.summary, '用户消息'); assert.deepEqual(contentHits[0]?.target, { kind: 'thread', sessionId: 'searchable-session', diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts index 18d9902111..becce05b49 100644 --- a/apps/desktop/src/main/__tests__/thread-search.test.ts +++ b/apps/desktop/src/main/__tests__/thread-search.test.ts @@ -428,7 +428,7 @@ describe('runThreadSearch', () => { sessionId: 's1', matchKind: 'session_title', }); - assert.equal(titleHit.summary, undefined); + assert.equal(titleHit.summary, '任务标题'); assert.equal(titleHit.url, undefined); assert.match(titleHit.snippet ?? '', /\[redacted\]/); assert.equal(titleHit.snippet?.includes('sk-ant-test-secret-token-12345'), false); @@ -445,7 +445,7 @@ describe('runThreadSearch', () => { matchKind: 'user_message', messageTimestamp: 1_700_000_000_000, }); - assert.equal(messageHit.summary, undefined); + assert.equal(messageHit.summary, '用户消息'); assert.equal(messageHit.url, undefined); }); diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts index 1f0b7b4387..54172243d1 100644 --- a/packages/core/src/thread-search.ts +++ b/packages/core/src/thread-search.ts @@ -279,6 +279,7 @@ export async function runThreadSearch( results.push({ source: THREAD_SOURCE, title: searchableTitle, + summary: '任务标题', snippet, target: { kind: 'thread', @@ -338,6 +339,7 @@ export async function runThreadSearch( results.push({ source: THREAD_SOURCE, title: redactSecrets(session.name), + summary: formatSearchResultSummary(message), snippet, // PR-SEARCH-1.5: navigation target via discriminated union; no // `url` field for thread results (maka://session is deferred). @@ -480,6 +482,31 @@ export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatch } } +export function formatSearchResultSummary(message: StoredMessage): string { + switch (message.type) { + case 'user': + return '用户消息'; + case 'assistant': + return '助手回复'; + case 'tool_call': + return message.displayName + ? `工具调用:${message.displayName}` + : `工具调用:${message.toolName}`; + case 'tool_result': + return message.isError ? '工具结果:失败' : '工具结果:成功'; + case 'permission_decision': + return '权限记录'; + case 'token_usage': + return '用量记录'; + case 'turn_state': + return '回合状态'; + case 'workhub_coordination': + return 'WorkHub 协调记录'; + case 'system_note': + return '系统记录'; + } +} + /** * Extract user-visible answer text from a stored message. Returns `undefined` * for excluded message kinds (system notes, token usage, turn state, diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index 75a7a3f099..60531de0e9 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -18,6 +18,7 @@ "./execution-candidate-main": "./dist/execution-candidate-main.js", "./server": "./dist/server/index.js", "./test-only/client-capability-host": "./dist/test-only/client-capability-host.js", + "./test-only/interactive-run-composer": "./dist/test-only/interactive-run-composer.js", "./test-only/execution-candidate-e2e-main": "./dist/test-only/execution-candidate-e2e-main.js" }, "scripts": { diff --git a/packages/runtime-host/src/test-only/interactive-run-composer.ts b/packages/runtime-host/src/test-only/interactive-run-composer.ts new file mode 100644 index 0000000000..8fd6a33168 --- /dev/null +++ b/packages/runtime-host/src/test-only/interactive-run-composer.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { createInteractiveRunComposer } from '../server/interactive-run-composer.js'; diff --git a/packages/runtime/src/__tests__/history-tools.test.ts b/packages/runtime/src/__tests__/history-tools.test.ts index 404edac2a0..2bec41ed88 100644 --- a/packages/runtime/src/__tests__/history-tools.test.ts +++ b/packages/runtime/src/__tests__/history-tools.test.ts @@ -119,9 +119,9 @@ test('SearchHistory returns typed message hits from current and other sessions', assert.deepEqual( new Map(result.rows.map((row) => [row.match_kind, row.summary])), new Map([ - ['session_title', 'Session title'], - ['user_message', 'User message'], - ['assistant_message', 'Assistant response'], + ['session_title', '任务标题'], + ['user_message', '用户消息'], + ['assistant_message', '助手回复'], ]), ); assert.equal(result.rows.find((row) => row.session_id === 'current')?.is_current_session, true); @@ -162,7 +162,7 @@ test('SearchHistory exposes and consumes an opaque session continuation', async assert.equal(second.next_cursor, undefined); }); -test('SearchHistory summaries echo invoked wire tool names and cover tool outcomes', async () => { +test('SearchHistory preserves existing summaries without exposing canonical tool secrets', async () => { const needle = 'history summary needle'; const messages = new Map([ [ @@ -215,12 +215,10 @@ test('SearchHistory summaries echo invoked wire tool names and cover tool outcom rows: Array<{ summary: string }>; }; - assert.ok( - result.rows.some((row) => row.summary === 'Tool call: mcp__desktop_browser__browser_navigate'), - ); - assert.ok(result.rows.some((row) => row.summary === 'Tool result: Succeeded')); - assert.ok(result.rows.some((row) => row.summary === 'Tool result: Failed')); - assert.match(JSON.stringify(result.rows), /\[redacted\]/u); + assert.ok(result.rows.some((row) => row.summary === '工具调用:浏览器导航')); + assert.ok(result.rows.some((row) => row.summary === '工具调用:Acme Lookup')); + assert.ok(result.rows.some((row) => row.summary === '工具结果:成功')); + assert.ok(result.rows.some((row) => row.summary === '工具结果:失败')); assert.doesNotMatch(JSON.stringify(result.rows), /sk-ant-test-secret-token-12345/u); }); diff --git a/packages/runtime/src/history-tools.ts b/packages/runtime/src/history-tools.ts index 913d64fe88..67357be10e 100644 --- a/packages/runtime/src/history-tools.ts +++ b/packages/runtime/src/history-tools.ts @@ -24,7 +24,6 @@ import { normalizeSearchLimit, normalizeSearchQuery, type SearchError, - type SearchResult, type ThreadSearchMatchKind, } from '@maka/core/search'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; @@ -59,25 +58,6 @@ type HistoryReadErrorReason = | 'empty_transcript' | 'aborted'; -function historySearchSummary(row: SearchResult): string { - switch (row.target?.matchKind) { - case 'session_title': - return 'Session title'; - case 'user_message': - return 'User message'; - case 'assistant_message': - return 'Assistant response'; - case 'tool_intent': { - const tool = row.target.tool; - return `Tool call: ${redactSecrets(tool?.name ?? 'unknown')}`; - } - case 'tool_result': - return row.target.toolResultIsError ? 'Tool result: Failed' : 'Tool result: Succeeded'; - default: - return redactSecrets(row.summary ?? ''); - } -} - interface HistoryTurnMessage { readonly messageId: string; readonly matchKind: Exclude; @@ -198,7 +178,7 @@ export function buildSearchHistoryTool(deps: HistoryToolDeps): MakaTool { : {}), is_current_session: row.target.sessionId === context.sessionId, title: row.title, - summary: historySearchSummary(row), + summary: redactSecrets(row.summary ?? ''), snippet: row.snippet ?? '', ...(session?.lastMessageAt !== undefined ? { last_message_at: session.lastMessageAt } diff --git a/packages/ui/src/__tests__/tool-display-name.test.ts b/packages/ui/src/__tests__/tool-display-name.test.ts index 3f28ba0153..81f45678f6 100644 --- a/packages/ui/src/__tests__/tool-display-name.test.ts +++ b/packages/ui/src/__tests__/tool-display-name.test.ts @@ -19,7 +19,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { MAKA_CATALOG_TOOLS } from '@maka/core/tool-catalog'; import { UI_LOCALES } from '@maka/core/ui-locale'; import type { ToolActivityItem } from '../materialize.js'; import { resolveToolDisplayName } from '../tool-activity/display-name.js'; @@ -46,6 +45,19 @@ describe('tool display names', () => { assert.equal(resolveToolDisplayName(search, 'zh'), '联网搜索'); }); + it('localizes every Desktop-owned proxy group', () => { + const cases = [ + ['mcp__desktop_browser__browser_navigate', 'Browser navigation'], + ['mcp__desktop_computer_use__maka_computer', 'Maka Computer'], + ['mcp__desktop_settings__MakaClientSettingsGet', 'Read client settings'], + ['mcp__desktop_rive__RiveWorkflow', 'Rive workflow'], + ] as const; + + for (const [toolName, label] of cases) { + assert.equal(resolveToolDisplayName(item(toolName, 'wrong persisted label'), 'en'), label); + } + }); + it('resolves every built-in tool through every locale cell', () => { for (const locale of UI_LOCALES) { for (const [toolName, labels] of Object.entries(BUILTIN_TOOL_LABELS)) { @@ -58,12 +70,6 @@ describe('tool display names', () => { } }); - it('covers every product catalog tool with a built-in label', () => { - for (const { name } of MAKA_CATALOG_TOOLS) { - assert.ok(Object.hasOwn(BUILTIN_TOOL_LABELS, name), name); - } - }); - it('localizes Maka-owned tools outside the product binding catalog', () => { assert.equal(resolveToolDisplayName(item('MakaSettingsGet', 'Read Maka settings'), 'zh'), '读取 Maka 设置'); assert.equal(resolveToolDisplayName(item('SkillSearch', 'SkillSearch'), 'zh'), '搜索技能'); @@ -71,6 +77,11 @@ describe('tool display names', () => { assert.equal(resolveToolDisplayName(item('tool_search'), 'zh'), '启用能力'); }); + it('localizes current Session Todo tools', () => { + assert.equal(resolveToolDisplayName(item('todo_read', 'Todo Read'), 'zh'), '读取待办列表'); + assert.equal(resolveToolDisplayName(item('todo_write', 'Todo Write'), 'zh'), '更新待办列表'); + }); + it('keeps provider labels for external tools and falls back to their identifiers', () => { assert.equal(resolveToolDisplayName(item('mcp__acme__lookup', 'Acme Lookup'), 'zh'), 'Acme Lookup'); assert.equal(resolveToolDisplayName(item('custom_lookup'), 'en'), 'custom_lookup'); diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 1020e41e55..df1df5d284 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -50,6 +50,8 @@ export const BUILTIN_TOOL_LABELS = { MakaSettingsUpdate: { zh: '更新 Maka 设置', en: 'Update Maka settings' }, memory_remember: { zh: '记住信息', en: 'Remember information' }, memory_extract: { zh: '提取记忆', en: 'Extract memory' }, + todo_read: { zh: '读取待办列表', en: 'Read Todo list' }, + todo_write: { zh: '更新待办列表', en: 'Update Todo list' }, GoalSet: { zh: '设置目标', en: 'Set goal' }, GoalClear: { zh: '清除目标', en: 'Clear goal' }, GoalPause: { zh: '暂停目标', en: 'Pause goal' }, diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 2766b4c2b6..1ae2a144dc 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -59,6 +59,7 @@ describe('CLI release file policy', () => { mcp: ['./test-only/stdio-server'], 'runtime-host': [ './test-only/client-capability-host', + './test-only/interactive-run-composer', './test-only/execution-candidate-e2e-main', ], runtime: ['./test-only/fake-backend', './test-only/observation-text-reader'], From a5650d235adbad26bb54857e42b44c5041be8e4d Mon Sep 17 00:00:00 2001 From: colafornia Date: Thu, 3 Sep 2026 14:11:48 +0800 Subject: [PATCH 3/3] test(i18n): cover conditional built-in tools --- ...builtin-tool-presentation-contract.test.ts | 65 +++++++++++++++---- .../src/test-only/interactive-run-composer.ts | 3 + packages/runtime/package.json | 1 + .../src/test-only/tool-presentation.ts | 20 ++++++ scripts/release-cli-file-policy.test.mjs | 6 +- 5 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 packages/runtime/src/test-only/tool-presentation.ts diff --git a/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts b/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts index a0c923936e..941c9b3454 100644 --- a/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts +++ b/apps/desktop/src/main/__tests__/builtin-tool-presentation-contract.test.ts @@ -20,13 +20,39 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; +import { buildAgentSwarmStatusTool } from '@maka/runtime/agent-swarm-status-tool'; import { buildComputerUseTools } from '@maka/runtime/computer-use-tools'; -import { createInteractiveRunComposer } from '@maka/runtime-host/test-only/interactive-run-composer'; +import { buildDeepResearchTools } from '@maka/runtime/deep-research-tools'; +import { buildGoalTools } from '@maka/runtime/goal-tools'; +import { buildHistoryTools } from '@maka/runtime/history-tools'; +import { buildMemoryExtractionTriggerTools } from '@maka/runtime/memory-extraction'; +import { + buildCancelPlanTool, + buildSubmitPlanTool, + buildUpdatePlanTool, +} from '@maka/runtime/plan-tools'; +import { buildScheduledTaskTool } from '@maka/runtime/scheduled-task-tools'; +import { buildParentAgentTools } from '@maka/runtime/subagent-tools'; +import { buildAgentGraphSupervisorTools } from '@maka/runtime/test-only/tool-presentation'; +import { createToolResultArchiveCapability } from '@maka/runtime/tool-result-archive-capability'; +import { + buildHostAgentSettingsTools, + createInteractiveRunComposer, + createHostWebFetchTool, + createHostWebSearchTool, +} from '@maka/runtime-host/test-only/interactive-run-composer'; import { BUILTIN_TOOL_LABELS } from '@maka/ui'; import { buildBrowserTools } from '../browser/browser-tools.js'; import { buildClientSettingsTools } from '../client-settings-tools.js'; import { buildRiveWorkflowTool } from '../rive-workflow-tool.js'; +function assertLocalized(tools: readonly { readonly name: string }[]): void { + assert.deepEqual( + tools.map(({ name }) => name).filter((name) => !Object.hasOwn(BUILTIN_TOOL_LABELS, name)), + [], + ); +} + test('every default Runtime Host tool has localized Desktop presentation', () => { const composer = createInteractiveRunComposer({ runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, @@ -35,15 +61,33 @@ test('every default Runtime Host tool has localized Desktop presentation', () => } as never, memory: {} as never, sessionTodo: {} as never, - builtinTools: {}, + builtinTools: { + backgroundTasks: {} as never, + ptyControls: {} as never, + }, }); - assert.deepEqual( - composer.tools - .map(({ name }) => name) - .filter((name) => !Object.hasOwn(BUILTIN_TOOL_LABELS, name)), - [], - ); + assertLocalized(composer.tools); +}); + +test('every conditional Runtime Host tool has localized Desktop presentation', () => { + assertLocalized([ + ...buildHostAgentSettingsTools({} as never), + createHostWebSearchTool({} as never), + createHostWebFetchTool({} as never), + ...buildHistoryTools({} as never), + buildScheduledTaskTool({} as never), + ...buildGoalTools({} as never), + ...buildParentAgentTools(), + buildSubmitPlanTool({} as never), + buildUpdatePlanTool({} as never, 'presentation-contract'), + buildCancelPlanTool({} as never, 'presentation-contract'), + ...buildDeepResearchTools({} as never), + ...buildAgentGraphSupervisorTools({ graphId: 'presentation-contract' } as never), + buildAgentSwarmStatusTool({} as never), + ...buildMemoryExtractionTriggerTools({} as never), + createToolResultArchiveCapability({} as never).archiveReadTool, + ]); }); test('every Desktop-owned tool has localized presentation', () => { @@ -54,8 +98,5 @@ test('every Desktop-owned tool has localized presentation', () => { buildRiveWorkflowTool(), ]; - assert.deepEqual( - tools.map(({ name }) => name).filter((name) => !Object.hasOwn(BUILTIN_TOOL_LABELS, name)), - [], - ); + assertLocalized(tools); }); diff --git a/packages/runtime-host/src/test-only/interactive-run-composer.ts b/packages/runtime-host/src/test-only/interactive-run-composer.ts index 8fd6a33168..61c23a62a7 100644 --- a/packages/runtime-host/src/test-only/interactive-run-composer.ts +++ b/packages/runtime-host/src/test-only/interactive-run-composer.ts @@ -18,3 +18,6 @@ */ export { createInteractiveRunComposer } from '../server/interactive-run-composer.js'; +export { buildHostAgentSettingsTools } from '../server/agent-settings-tools.js'; +export { createHostWebFetchTool } from '../server/web-fetch-tool.js'; +export { createHostWebSearchTool } from '../server/web-search-tool.js'; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 854124140e..80a01220bc 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -23,6 +23,7 @@ "./session-manager": "./dist/session-manager.js", "./session-todo-tools": "./dist/session-todo-tools.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", + "./test-only/tool-presentation": "./dist/test-only/tool-presentation.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", "./sandbox": "./dist/sandbox/index.js", diff --git a/packages/runtime/src/test-only/tool-presentation.ts b/packages/runtime/src/test-only/tool-presentation.ts new file mode 100644 index 0000000000..9bf4a90cd6 --- /dev/null +++ b/packages/runtime/src/test-only/tool-presentation.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { buildAgentGraphSupervisorTools } from '../stream-graph-supervisor-tools.js'; diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 1ae2a144dc..1d49be64e7 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -62,7 +62,11 @@ describe('CLI release file policy', () => { './test-only/interactive-run-composer', './test-only/execution-candidate-e2e-main', ], - runtime: ['./test-only/fake-backend', './test-only/observation-text-reader'], + runtime: [ + './test-only/fake-backend', + './test-only/tool-presentation', + './test-only/observation-text-reader', + ], })) { const manifestPath = join(repoRoot, 'packages', directory, 'package.json'); const source = JSON.parse(readFileSync(manifestPath, 'utf8'));