+
+
+
+
+
+
+
+
+
+ {{ item.description }}
+
+
+
+
+
+
diff --git a/apps/desktop/src/views/SettingsView/components/BuiltInTools/index.vue b/apps/desktop/src/views/SettingsView/components/BuiltInTools/index.vue
index 4d73af5e..7b88e606 100644
--- a/apps/desktop/src/views/SettingsView/components/BuiltInTools/index.vue
+++ b/apps/desktop/src/views/SettingsView/components/BuiltInTools/index.vue
@@ -8,6 +8,7 @@
import { computed, onMounted, ref, watch } from 'vue';
import { t } from '@/i18n';
+ import { native } from '@/services/NativeService';
import { useSettingsResizablePanel } from '../../composables/useSettingsResizablePanel';
import SectionTabs, { type SectionTabItem } from '../SectionTabs.vue';
@@ -19,6 +20,7 @@
type BuiltInToolUpdateData,
isBuiltInToolVisibleInSettings,
loadBuiltInToolQueries,
+ parseDesktopContextToolConfig,
usesBuiltInToolEmptyConfig,
} from './types';
defineOptions({
@@ -112,6 +114,33 @@
}
}
+ /**
+ * 桌面上下文工具的启用状态与子捕获开关需要同步到原生层:
+ * 禁用后原生 begin_invocation 直接跳过捕获,避免无谓的 UIA 扫描与隐私读取;
+ * 子开关(选中文本/浏览器 URL/截图 OCR)则控制 enrich 阶段跑哪些子捕获。
+ */
+ async function syncDesktopContextCaptureState(tool: BuiltInToolEntity) {
+ if (tool.tool_id !== 'get_desktop_context') {
+ return;
+ }
+
+ try {
+ await native.desktopContext.setCaptureEnabled(tool.enabled === 1);
+
+ const config = parseDesktopContextToolConfig(tool.config_json);
+ await native.desktopContext.setCaptureConfig({
+ captureSelectedText: config.captureSelectedText,
+ captureBrowserUrl: config.captureBrowserUrl,
+ enableScreenshotOcr: config.enableScreenshotOcr,
+ });
+ } catch (error) {
+ console.error(
+ '[BuiltInToolsView] Failed to sync desktop context capture state:',
+ error
+ );
+ }
+ }
+
async function handleToggleEnabled(toolId: number, enabled: boolean) {
if (togglingToolIds.value.has(toolId)) {
return;
@@ -127,6 +156,7 @@
throw new Error(`Built-in tool not found after update: ${toolId}`);
}
applyToolUpdate(updatedTool);
+ await syncDesktopContextCaptureState(updatedTool);
} catch (error) {
console.error('[BuiltInToolsView] Failed to toggle tool enabled:', error);
alertMessage.value?.error(t('settings.builtInTools.updateEnabledFailed'), 6000);
@@ -157,6 +187,7 @@
throw new Error(`Built-in tool not found after update: ${currentToolId}`);
}
applyToolUpdate(nextTool);
+ await syncDesktopContextCaptureState(nextTool);
} catch (error) {
console.error('[BuiltInToolsView] Failed to update tool:', error);
alertMessage.value?.error(t('settings.builtInTools.saveConfigFailed'), 6000);
diff --git a/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts b/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts
index 0a76a275..6cc20ec1 100644
--- a/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts
+++ b/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts
@@ -9,6 +9,12 @@ export {
parseUpgradeModelToolConfig,
type UpgradeModelToolConfig,
} from '@/services/BuiltInToolService/tools/upgradeModel/config';
+export {
+ DEFAULT_DESKTOP_CONTEXT_TOOL_CONFIG,
+ type DesktopContextToolConfig,
+ parseDesktopContextToolConfig,
+ serializeDesktopContextToolConfig,
+} from '@/services/BuiltInToolService/tools/desktopContext/config';
import { t } from '@/i18n';
import { formatDateTime } from '@/i18n/format';
@@ -105,6 +111,10 @@ export function getBuiltInToolSummary(toolId: string, description?: string | nul
return t('settings.builtInTools.summary.webFetch');
}
+ if (toolId === 'get_desktop_context') {
+ return t('settings.builtInTools.summary.desktopContext');
+ }
+
if (toolId === 'upgrade_model') {
return t('settings.builtInTools.summary.upgradeModel');
}
diff --git a/apps/desktop/src/views/SettingsView/components/DataManagement/index.vue b/apps/desktop/src/views/SettingsView/components/DataManagement/index.vue
index 4d1e0184..0cb36338 100644
--- a/apps/desktop/src/views/SettingsView/components/DataManagement/index.vue
+++ b/apps/desktop/src/views/SettingsView/components/DataManagement/index.vue
@@ -23,8 +23,21 @@
import { t, tt } from '@/i18n';
import { formatDateTime } from '@/i18n/format';
import { updateModelMetadata } from '@/services/AgentService/infrastructure/modelMetadata';
+ import { native } from '@/services/NativeService';
import { serializeImportSuccessStartupPayload } from '@/services/StartupService';
+ /**
+ * 清除会话历史时,桌面上下文截图的持久副本不再被任何 turn artifact 引用,
+ * 一并清理,避免磁盘上残留孤儿截图。
+ */
+ async function clearPersistedDesktopContextScreenshots(): Promise
{
+ try {
+ await native.desktopContext.clearPersistedScreenshots();
+ } catch (error) {
+ console.error('Failed to clear persisted desktop context screenshots:', error);
+ }
+ }
+
defineOptions({
name: 'SettingsDataManagementSection',
});
@@ -129,6 +142,7 @@
try {
isLoading.value = true;
await deleteAllSessions();
+ await clearPersistedDesktopContextScreenshots();
alert.success(t('settings.dataManagement.clearSessionsSuccess'));
await loadStats();
} catch (error) {
@@ -151,6 +165,7 @@
try {
isLoading.value = true;
await deleteAllMessages();
+ await clearPersistedDesktopContextScreenshots();
alert.success(t('settings.dataManagement.clearMessagesSuccess'));
await loadStats();
} catch (error) {
diff --git a/apps/desktop/tests/components/DesktopContextToolCallItem.test.ts b/apps/desktop/tests/components/DesktopContextToolCallItem.test.ts
new file mode 100644
index 00000000..4b759a1f
--- /dev/null
+++ b/apps/desktop/tests/components/DesktopContextToolCallItem.test.ts
@@ -0,0 +1,135 @@
+import { mount } from '@vue/test-utils';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { setLocale } from '@/i18n';
+import type { SessionMessage, ToolCallInfo } from '@/types/session';
+import ToolCallItem from '@/views/SearchView/components/ConversationPanel/components/ToolCallItem.vue';
+import UserMessage from '@/views/SearchView/components/ConversationPanel/components/UserMessage.vue';
+
+const iconStub = {
+ name: 'AppIcon',
+ props: ['name'],
+ template: '',
+};
+
+function createDesktopContextToolCall(overrides: Partial = {}): ToolCallInfo {
+ const result = JSON.stringify(
+ {
+ available: true,
+ capsuleId: 'ctx-1',
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ selectedText: {
+ available: true,
+ source: 'windows-uia-focused-textpattern',
+ textSummary: 'selected Rust error text',
+ textLength: 24,
+ truncated: false,
+ fullText: 'selected Rust error text',
+ },
+ screenshot: {
+ available: true,
+ path: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1.png',
+ mimeType: 'image/png',
+ width: 1200,
+ height: 800,
+ target: 'active_display',
+ persisted: true,
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ reason: null,
+ },
+ },
+ null,
+ 2
+ );
+
+ return {
+ id: 'call-1',
+ name: 'GetDesktopContext',
+ namespacedName: 'builtin__get_desktop_context',
+ source: 'builtin',
+ sourceLabel: '内置工具',
+ arguments: {
+ include: ['selected_text.full_text', 'screenshot.image'],
+ },
+ builtinPresentation: {
+ verb: '已阅读',
+ content: '桌面上下文',
+ },
+ result,
+ status: 'completed',
+ durationMs: 18,
+ ...overrides,
+ };
+}
+
+describe('desktop context conversation rendering', () => {
+ beforeEach(() => {
+ setLocale('zh-CN');
+ });
+
+ it('does not render desktop context artifacts inside the user message bubble', () => {
+ const message: SessionMessage = {
+ id: 'user-1',
+ role: 'user',
+ content: '解释我选中的内容',
+ desktopContext: {
+ capsuleId: 'ctx-1',
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ activeWindowTitle: 'main.rs - TouchAI',
+ screenshotPath: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1.png',
+ screenshotMimeType: 'image/png',
+ screenshotWidth: 1200,
+ screenshotHeight: 800,
+ },
+ parts: [],
+ timestamp: Date.now(),
+ };
+
+ const wrapper = mount(UserMessage, {
+ props: { message },
+ global: {
+ stubs: {
+ AppIcon: iconStub,
+ ActionButton: true,
+ },
+ },
+ });
+
+ expect(wrapper.text()).toContain('解释我选中的内容');
+ expect(wrapper.text()).not.toContain('桌面上下文');
+ expect(wrapper.find('img[alt="呼出时桌面截图"]').exists()).toBe(false);
+ expect(wrapper.text()).not.toContain('main.rs - TouchAI');
+ });
+
+ it('renders approved desktop context inside the tool call expansion with screenshot first', async () => {
+ const wrapper = mount(ToolCallItem, {
+ props: {
+ toolCall: createDesktopContextToolCall(),
+ },
+ global: {
+ stubs: {
+ AppIcon: iconStub,
+ },
+ },
+ });
+
+ expect(wrapper.text()).toContain('已阅读');
+ expect(wrapper.text()).toContain('桌面上下文');
+
+ await wrapper.get('button.tool-call-log-button').trigger('click');
+
+ const screenshot = wrapper.get('.tool-call-desktop-context-screenshot');
+ const rawBlock = wrapper.get('.tool-call-desktop-context-raw');
+ expect(
+ screenshot.element.compareDocumentPosition(rawBlock.element) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+ expect(screenshot.attributes('src')).toContain('asset.localhost');
+ expect(screenshot.attributes('src')).toContain('ctx-1.png');
+ expect(rawBlock.text()).toContain('"fullText": "selected Rust error text"');
+ expect(rawBlock.text()).toContain(
+ '"path": "E:/TouchAI/data/desktop-context/screenshots/ctx-1.png"'
+ );
+ });
+});
diff --git a/apps/desktop/tests/composables/SearchView/useSearchRequest.test.ts b/apps/desktop/tests/composables/SearchView/useSearchRequest.test.ts
index 27ec1ba1..2ace0653 100644
--- a/apps/desktop/tests/composables/SearchView/useSearchRequest.test.ts
+++ b/apps/desktop/tests/composables/SearchView/useSearchRequest.test.ts
@@ -211,6 +211,7 @@ describe('useSearchRequestFlow', () => {
inputSnapshot: queuedSnapshot,
modelId: 'queued-model',
providerId: 3,
+ desktopContextCapsuleId: null,
});
expect(mounted.result.isWaitingForCompletion.value).toBe(true);
expect(agentState.sendRequest).not.toHaveBeenCalled();
@@ -226,7 +227,8 @@ describe('useSearchRequestFlow', () => {
[supportedAttachment],
queuedSnapshot,
'queued-model',
- 3
+ 3,
+ null
);
expect(mounted.result.pendingRequest.value).toBeNull();
expect(mounted.result.isWaitingForCompletion.value).toBe(false);
@@ -398,7 +400,8 @@ describe('useSearchRequestFlow', () => {
excludeFromHistory: true,
}),
'regen-model',
- 11
+ 11,
+ null
);
mounted.unmount();
diff --git a/apps/desktop/tests/services/AgentService/prompt/language-context.test.ts b/apps/desktop/tests/services/AgentService/prompt/language-context.test.ts
index af70a2f7..64f52297 100644
--- a/apps/desktop/tests/services/AgentService/prompt/language-context.test.ts
+++ b/apps/desktop/tests/services/AgentService/prompt/language-context.test.ts
@@ -52,6 +52,44 @@ describe('prompt current language context', () => {
);
});
+ it('tells the model how to request a persisted desktop screenshot path', async () => {
+ const snapshot = await composePromptSnapshot({
+ prompt: '看看我桌面上是什么。',
+ desktopContext: {
+ capsuleId: 'ctx-1',
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ boundAt: '2026-06-02T12:00:05.000Z',
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ activeWindowTitle: 'main.rs - TouchAI',
+ selectedTextSummary: 'panic at src/main.rs:42 with token=[REDACTED:secret]',
+ selectedTextLength: 23,
+ clipboardTextLength: 16,
+ screenshotAvailable: true,
+ screenshotPersisted: true,
+ screenshotWidth: 1200,
+ screenshotHeight: 800,
+ capabilities: [
+ {
+ id: 'screenshot',
+ supported: true,
+ method: 'xcap-monitor-capture',
+ },
+ ],
+ },
+ });
+
+ expect(snapshot.systemPrompt).toContain('本轮请求绑定了一份只读桌面上下文胶囊:ctx-1');
+ expect(snapshot.systemPrompt).toContain(
+ '本轮已有一张经用户批准后捕获的桌面截图并已持久化,尺寸 1200x800'
+ );
+ expect(snapshot.systemPrompt).toContain(
+ '已默认注入脱敏后的选中文本摘要:panic at src/main.rs:42 with token=[REDACTED:secret]'
+ );
+ expect(snapshot.systemPrompt).toContain("include: ['screenshot.image']");
+ expect(snapshot.systemPrompt).toContain('builtin__get_desktop_context');
+ expect(snapshot.systemPrompt).toContain("include: ['selected_text.full_text']");
+ });
+
it('builds the current system and tool language context from the active locale', () => {
setLocale('en-US');
diff --git a/apps/desktop/tests/services/AgentService/session-history-i18n.test.ts b/apps/desktop/tests/services/AgentService/session-history-i18n.test.ts
index bf9a44b4..5e223dd1 100644
--- a/apps/desktop/tests/services/AgentService/session-history-i18n.test.ts
+++ b/apps/desktop/tests/services/AgentService/session-history-i18n.test.ts
@@ -1,5 +1,6 @@
import type { MessageRow } from '@database/queries/messages';
import type { SessionTurnAttemptHistoryRow } from '@database/queries/sessionTurnAttempts';
+import type { SessionTurnContextArtifactHistoryRow } from '@database/queries/sessionTurnContextArtifacts';
import type { SessionTurnHistoryRow } from '@database/queries/sessionTurns';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -95,6 +96,26 @@ function createAttempt(
};
}
+function createContextArtifact(
+ overrides: Partial
+): SessionTurnContextArtifactHistoryRow {
+ return {
+ id: 1,
+ turn_id: 1,
+ prompt_message_id: 10,
+ capsule_id: 'ctx-1',
+ artifact_kind: 'metadata',
+ artifact_path: null,
+ mime_type: null,
+ width: null,
+ height: null,
+ captured_at: BASE_TIME,
+ metadata_json: null,
+ created_at: BASE_TIME,
+ ...overrides,
+ };
+}
+
describe('AgentService session history i18n', () => {
beforeEach(() => {
setLocale('zh-CN');
@@ -207,4 +228,55 @@ describe('AgentService session history i18n', () => {
content: 'Write-Output 设置',
});
});
+
+ it('restores persisted desktop context artifacts onto the prompt message', async () => {
+ const history = await buildSessionHistory({
+ messages: [
+ createMessageRow({
+ id: 10,
+ role: 'user',
+ content: '解释我刚才选中的内容',
+ }),
+ ],
+ turns: [
+ createTurn({
+ id: 1,
+ prompt_message_id: 10,
+ }),
+ ],
+ attempts: [],
+ contextArtifacts: [
+ createContextArtifact({
+ artifact_kind: 'metadata',
+ metadata_json: JSON.stringify({
+ capsuleId: 'ctx-1',
+ capturedAt: BASE_TIME,
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ activeWindowTitle: 'main.rs - TouchAI',
+ }),
+ }),
+ createContextArtifact({
+ id: 2,
+ artifact_kind: 'screenshot',
+ artifact_path: 'E:/TouchAI/data/session-context/ctx-1.png',
+ mime_type: 'image/png',
+ width: 1200,
+ height: 800,
+ }),
+ ],
+ resolveServerName: () => '',
+ });
+
+ expect(history[0]).toMatchObject({
+ role: 'user',
+ desktopContext: {
+ capsuleId: 'ctx-1',
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ activeWindowTitle: 'main.rs - TouchAI',
+ screenshotPath: 'E:/TouchAI/data/session-context/ctx-1.png',
+ screenshotWidth: 1200,
+ screenshotHeight: 800,
+ },
+ });
+ });
});
diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/desktopContext/index.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/desktopContext/index.test.ts
new file mode 100644
index 00000000..18dacb13
--- /dev/null
+++ b/apps/desktop/tests/services/BuiltInToolService/tools/desktopContext/index.test.ts
@@ -0,0 +1,236 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { desktopContextTool } from '@/services/BuiltInToolService/tools/desktopContext';
+import type { BaseBuiltInToolExecutionContext } from '@/services/BuiltInToolService/types';
+import type { BoundDesktopContext } from '@/services/DesktopContextService/types';
+
+const { captureSensitiveMock, createAttachmentMock } = vi.hoisted(() => ({
+ captureSensitiveMock: vi.fn(),
+ createAttachmentMock: vi.fn(),
+}));
+
+vi.mock('@/services/AgentService/infrastructure/attachments', () => ({
+ createAttachment: createAttachmentMock,
+}));
+
+vi.mock('@/services/NativeService', () => ({
+ native: {
+ desktopContext: {
+ captureSensitive: captureSensitiveMock,
+ },
+ },
+}));
+
+const desktopContext: BoundDesktopContext = {
+ id: 'ctx-1',
+ sequence: 1,
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ boundAt: '2026-06-02T12:00:05.000Z',
+ invocationSource: 'shortcut',
+ platform: 'windows',
+ summary: 'Visual Studio Code focused with a persisted desktop screenshot.',
+ activeWindow: null,
+ selectedText: {
+ available: true,
+ source: 'windows-uia-textpattern',
+ text: 'selected text with token=secret-value',
+ textLength: 37,
+ truncated: false,
+ },
+ clipboard: {
+ available: false,
+ snapshotId: null,
+ observedAt: null,
+ text: null,
+ textSummary: null,
+ textLength: 0,
+ imageCount: 0,
+ fileCount: 0,
+ reason: 'Clipboard is empty.',
+ },
+ screenshot: {
+ available: true,
+ path: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1.png',
+ mimeType: 'image/png',
+ width: 1200,
+ height: 800,
+ target: 'active_display',
+ persisted: true,
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ },
+ capabilities: [{ id: 'screenshot', supported: true, method: 'xcap-monitor-capture' }],
+ redactions: [],
+};
+
+const approvedDesktopContext: BoundDesktopContext = {
+ ...desktopContext,
+ selectedText: {
+ available: true,
+ source: 'windows-uia-focused-textpattern',
+ text: 'approved selected text',
+ textLength: 22,
+ truncated: false,
+ },
+ clipboard: {
+ available: true,
+ snapshotId: 'clip-approved',
+ observedAt: 1770000000001,
+ text: 'approved clipboard text',
+ textSummary: 'approved clipboard text',
+ textLength: 23,
+ imageCount: 0,
+ fileCount: 0,
+ },
+ screenshot: {
+ available: true,
+ path: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1-approved.png',
+ mimeType: 'image/png',
+ width: 1600,
+ height: 900,
+ target: 'active_window',
+ persisted: true,
+ capturedAt: '2026-06-02T12:01:00.000Z',
+ },
+};
+
+function createContext(
+ overrides: Partial = {}
+): BaseBuiltInToolExecutionContext {
+ return {
+ callId: 'call-1',
+ iteration: 0,
+ hasExecutedBuiltInTool: () => false,
+ desktopContext,
+ ...overrides,
+ };
+}
+
+describe('desktopContextTool', () => {
+ beforeEach(() => {
+ captureSensitiveMock.mockReset();
+ createAttachmentMock.mockReset();
+ captureSensitiveMock.mockResolvedValue(approvedDesktopContext);
+ createAttachmentMock.mockResolvedValue({
+ id: 'attachment-1',
+ type: 'image',
+ path: approvedDesktopContext.screenshot.path,
+ originPath: approvedDesktopContext.screenshot.path,
+ name: 'ctx-1-approved.png',
+ mimeType: 'image/png',
+ supportStatus: 'supported',
+ });
+ });
+
+ it('does not attach the screenshot for the safe default include set', async () => {
+ const result = await desktopContextTool.execute({}, {}, createContext());
+
+ expect(result.attachments).toBeUndefined();
+ expect(createAttachmentMock).not.toHaveBeenCalled();
+ expect(captureSensitiveMock).not.toHaveBeenCalled();
+ const payload = JSON.parse(result.result);
+ expect(payload).toMatchObject({
+ available: true,
+ summary: desktopContext.summary,
+ selectedText: {
+ available: true,
+ textSummary: 'selected text with token=[REDACTED:secret]',
+ },
+ });
+ expect(payload.selectedText).not.toHaveProperty('fullText');
+ expect(payload).not.toHaveProperty('clipboard');
+ expect(payload).not.toHaveProperty('screenshot');
+ });
+
+ it('does not require approval for the safe default include set', async () => {
+ expect(
+ desktopContextTool.buildApprovalRequest(
+ {},
+ {},
+ 'builtin__get_desktop_context',
+ createContext()
+ )
+ ).toBeNull();
+ });
+
+ it.each([
+ ['selected text full text', ['selected_text.full_text']],
+ ['clipboard', ['clipboard.summary']],
+ ['screenshot', ['screenshot.metadata']],
+ ])('requires approval before reading %s context', async (_label, include) => {
+ const approval = await desktopContextTool.buildApprovalRequest(
+ { include },
+ {},
+ 'builtin__get_desktop_context',
+ createContext()
+ );
+
+ expect(approval).toMatchObject({
+ title: expect.stringContaining('桌面上下文'),
+ reason: expect.stringContaining('发送给模型'),
+ });
+ });
+
+ it('captures approved sensitive context before building the tool payload', async () => {
+ const result = await desktopContextTool.execute(
+ {
+ include: ['selected_text.full_text', 'clipboard.full_text', 'screenshot.image'],
+ screenshotTarget: 'active_window',
+ },
+ {},
+ createContext()
+ );
+
+ expect(captureSensitiveMock).toHaveBeenCalledWith(
+ 'ctx-1',
+ ['selected_text.full_text', 'clipboard.full_text', 'screenshot.image'],
+ 'active_window'
+ );
+ expect(createAttachmentMock).toHaveBeenCalledWith(
+ 'image',
+ 'E:/TouchAI/data/desktop-context/screenshots/ctx-1-approved.png'
+ );
+ expect(result.attachments).toEqual([
+ expect.objectContaining({
+ id: 'attachment-1',
+ type: 'image',
+ path: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1-approved.png',
+ }),
+ ]);
+ expect(JSON.parse(result.result)).toMatchObject({
+ selectedText: {
+ fullText: 'approved selected text',
+ },
+ clipboard: {
+ fullText: 'approved clipboard text',
+ },
+ screenshot: {
+ path: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1-approved.png',
+ },
+ });
+ expect(result.desktopContextArtifact).toMatchObject({
+ id: 'ctx-1',
+ boundAt: '2026-06-02T12:00:05.000Z',
+ screenshot: {
+ path: 'E:/TouchAI/data/desktop-context/screenshots/ctx-1-approved.png',
+ },
+ });
+ });
+
+ it('returns the stale capsule reason when sensitive capture is unavailable', async () => {
+ captureSensitiveMock.mockResolvedValue(null);
+
+ const result = await desktopContextTool.execute(
+ { include: ['selected_text.full_text'] },
+ {},
+ createContext()
+ );
+
+ expect(JSON.parse(result.result)).toMatchObject({
+ selectedText: {
+ available: true,
+ fullText: 'selected text with token=secret-value',
+ },
+ });
+ expect(result.desktopContextArtifact).toBeUndefined();
+ });
+});
diff --git a/apps/desktop/tests/services/DesktopContextService/tool-payload.test.ts b/apps/desktop/tests/services/DesktopContextService/tool-payload.test.ts
new file mode 100644
index 00000000..52e4bb77
--- /dev/null
+++ b/apps/desktop/tests/services/DesktopContextService/tool-payload.test.ts
@@ -0,0 +1,171 @@
+// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildDesktopContextPromptMetadata,
+ buildDesktopContextToolPayload,
+} from '@/services/DesktopContextService/toolPayload';
+import type { BoundDesktopContext } from '@/services/DesktopContextService/types';
+
+const context: BoundDesktopContext = {
+ id: 'ctx-1',
+ sequence: 7,
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ boundAt: '2026-06-02T12:00:05.000Z',
+ invocationSource: 'shortcut',
+ platform: 'windows',
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ activeWindow: {
+ title: 'main.rs - TouchAI',
+ appName: 'Visual Studio Code',
+ processName: 'Code.exe',
+ processId: 123,
+ windowHandle: '0x123',
+ bounds: { x: 0, y: 0, width: 1200, height: 800 },
+ },
+ selectedText: {
+ available: true,
+ source: 'win32-edit-control',
+ text: 'selected compiler error with sk-live-secret-token and Authorization: Bearer abcdefghijklmnopqrstuvwxyz',
+ textLength: 102,
+ truncated: false,
+ },
+ clipboard: {
+ available: true,
+ snapshotId: 'clip-1',
+ observedAt: 1770000000000,
+ text: 'clipboard secret',
+ textSummary: 'clipboard secret',
+ textLength: 16,
+ imageCount: 0,
+ fileCount: 0,
+ },
+ screenshot: {
+ available: true,
+ path: 'E:/TouchAI/data/session-context/ctx-1.png',
+ mimeType: 'image/png',
+ width: 1200,
+ height: 800,
+ target: 'active_display',
+ persisted: true,
+ capturedAt: '2026-06-02T12:00:00.000Z',
+ },
+ capabilities: [{ id: 'screenshot', supported: true, method: 'gdi' }],
+ redactions: [{ field: 'clipboard.fullText', reason: 'not requested by default' }],
+};
+
+describe('buildDesktopContextToolPayload', () => {
+ it('returns unavailable when no capsule is bound to the turn', () => {
+ expect(buildDesktopContextToolPayload(null)).toEqual({
+ available: false,
+ reason: 'No desktop context capsule is bound to this turn.',
+ });
+ });
+
+ it('uses a safe default include set with redacted selected text but no clipboard or screenshot contents', () => {
+ const payload = buildDesktopContextToolPayload(context);
+
+ expect(payload).toMatchObject({
+ available: true,
+ capsuleId: 'ctx-1',
+ summary: context.summary,
+ activeWindow: context.activeWindow,
+ selectedText: {
+ available: true,
+ source: 'win32-edit-control',
+ textSummary:
+ 'selected compiler error with [REDACTED:secret] and Authorization: [REDACTED:secret]',
+ textLength: 102,
+ truncated: false,
+ },
+ capabilities: context.capabilities,
+ });
+ expect(payload.redactions).toEqual(
+ expect.arrayContaining([
+ ...context.redactions,
+ {
+ field: 'selectedText.textSummary',
+ reason: 'Sensitive-looking selected text was redacted before default prompt/tool exposure.',
+ },
+ ])
+ );
+ expect(payload.selectedText).not.toHaveProperty('fullText');
+ expect(payload).not.toHaveProperty('clipboard');
+ expect(payload).not.toHaveProperty('screenshot');
+ });
+
+ it('returns selected text metadata only when explicitly requested', () => {
+ const payload = buildDesktopContextToolPayload(context, {
+ include: ['selected_text.summary'],
+ });
+
+ expect(payload.selectedText).toMatchObject({
+ available: true,
+ source: 'win32-edit-control',
+ textSummary:
+ 'selected compiler error with [REDACTED:secret] and Authorization: [REDACTED:secret]',
+ textLength: 102,
+ truncated: false,
+ });
+ expect(payload.selectedText).not.toHaveProperty('fullText');
+ });
+
+ it('returns clipboard metadata only when explicitly requested', () => {
+ const payload = buildDesktopContextToolPayload(context, {
+ include: ['clipboard.summary'],
+ });
+
+ expect(payload.clipboard).toMatchObject({
+ textSummary: 'clipboard secret',
+ textLength: 16,
+ });
+ expect(payload.clipboard).not.toHaveProperty('fullText');
+ });
+
+ it('returns screenshot metadata only when explicitly requested', () => {
+ const payload = buildDesktopContextToolPayload(context, {
+ include: ['screenshot.metadata'],
+ });
+
+ expect(payload.screenshot).toMatchObject({
+ available: true,
+ width: 1200,
+ height: 800,
+ persisted: true,
+ });
+ expect(payload.screenshot).not.toHaveProperty('path');
+ });
+
+ it('returns sensitive fields only when the include array asks for them', () => {
+ const payload = buildDesktopContextToolPayload(context, {
+ include: ['selected_text.full_text', 'clipboard.full_text', 'screenshot.image'],
+ });
+
+ expect(payload).not.toHaveProperty('summary');
+ expect(payload.selectedText).toMatchObject({
+ fullText:
+ 'selected compiler error with sk-live-secret-token and Authorization: Bearer abcdefghijklmnopqrstuvwxyz',
+ });
+ expect(payload.clipboard).toMatchObject({
+ fullText: 'clipboard secret',
+ });
+ expect(payload.screenshot).toMatchObject({
+ path: 'E:/TouchAI/data/session-context/ctx-1.png',
+ persisted: true,
+ });
+ });
+
+ it('exposes screenshot availability in prompt metadata without leaking the image path', () => {
+ expect(buildDesktopContextPromptMetadata(context)).toMatchObject({
+ capsuleId: 'ctx-1',
+ summary: 'Visual Studio Code focused with selected Rust error text.',
+ selectedTextSummary:
+ 'selected compiler error with [REDACTED:secret] and Authorization: [REDACTED:secret]',
+ screenshotAvailable: true,
+ screenshotPersisted: true,
+ screenshotWidth: 1200,
+ screenshotHeight: 800,
+ });
+ });
+});