From 44eb615280a0ae82043454763a27c1e5fe68a877 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:17:04 +0800 Subject: [PATCH 01/12] feat(desktop): refine session status reminder summaries --- .../src/services/AgentService/task/center.ts | 287 ++++++++++++++++-- .../AgentService/task/center-reminder.test.ts | 249 +++++++++++++++ 2 files changed, 518 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 5bedc233..10c3addd 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import { tt } from '@/i18n'; +import { getLocale, tt } from '@/i18n'; import { eventService } from '@/services/EventService'; import { AppEvent, type SessionStatusReminderPayload } from '@/services/EventService/types'; import type { PendingToolApproval, SessionMessage } from '@/types/session'; @@ -41,6 +41,14 @@ interface MutableSessionTask { const TERMINAL_TASK_RETENTION_MS = 5 * 60 * 1000; const STATUS_REMINDER_MAX_BODY_CHARS = 220; const STATUS_REMINDER_MAX_COMMAND_CHARS = 160; +const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; +const MARKDOWN_REFERENCE_LINK_DEFINITION_PATTERN = + /^\s*\[[^\]]+\]:\s+(?:<[^>\s]+>|(?:[a-z][a-z0-9+.-]*:|\/|\.{1,2}\/|#)[^\s]*)(?:\s+(?:"[^"]*"|'[^']*'|\([^)\n]*\)))?\s*$/gim; +const MARKDOWN_TABLE_DIVIDER_PATTERN = /^\s*\|?(?:\s*:?-+:?\s*\|)+(?:\s*:?-+:?\s*)?\|?\s*$/gm; +const MARKDOWN_EMPHASIS_LEADING_BOUNDARY = `(^|[\\s([{"'“‘(【《])`; +const MARKDOWN_EMPHASIS_TRAILING_BOUNDARY = `(?=$|[\\s,.;:!?,。;:!?、】【)》」』〕〉>\\]}'"”’])`; + +type ReminderTextMode = 'natural' | 'command' | 'summary'; /** 深拷贝任务快照,确保外部订阅者无法直接修改内部状态。 */ function cloneTaskSnapshot(snapshot: SessionTaskSnapshot): SessionTaskSnapshot { @@ -69,26 +77,256 @@ function isTerminalStatus(status: SessionTaskSnapshot['status']): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } -/** 将文本截断到指定字符数,超出部分以省略号结尾。 */ -function truncateReminderText(value: string, maxChars: number): string { +function truncateNotificationText(value: string, maxChars: number): string { if (value.length <= maxChars) { return value; } - return `${value.slice(0, maxChars - 1).trimEnd()}…`; + return `${value.slice(0, maxChars - 3).trimEnd()}...`; +} + +function isEnglishReminderLocale(): boolean { + return getLocale() === 'en-US'; +} + +function getReminderListSeparator(): string { + return isEnglishReminderLocale() ? ', ' : '、'; +} + +function getReminderClauseSeparator(): string { + return isEnglishReminderLocale() ? '; ' : ';'; +} + +function getReminderSentenceSeparator(): string { + return isEnglishReminderLocale() ? '. ' : '。'; +} + +function getReminderColonSeparator(): string { + return isEnglishReminderLocale() ? ': ' : ':'; +} + +function hasTerminalPunctuation(value: string): boolean { + return /[.!?。!?;;::]$/.test(value.trim()); +} + +function stripMarkdownCodeFences(value: string, mode: ReminderTextMode): string { + const replacement = mode === 'command' ? '$1' : mode === 'summary' ? '\n' : '\n$1\n'; + return value + .replace(/```(?:[\w-]+)?\n?([\s\S]*?)```/g, replacement) + .replace(/~~~(?:[\w-]+)?\n?([\s\S]*?)~~~/g, replacement); +} + +function unescapeMarkdownSyntax(value: string): string { + return value.replace(/\\([\\`*_{}[\]()#+.!>-])/g, '$1'); +} + +function stripNaturalMarkdownSyntax(value: string): string { + return value + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(MARKDOWN_REFERENCE_LINK_DEFINITION_PATTERN, ' ') + .replace(/^ {0,3}(?:```|~~~)[\w-]*\s*$/gm, ' ') + .replace(/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/gm, ' ') + .replace(/^ {0,3}#{1,6}\s+/gm, '') + .replace(/^ {0,3}>\s?/gm, '') + .replace(/^ {0,3}(?:[-*+])\s+\[[ xX]\]\s+/gm, '') + .replace(/^ {0,3}(?:[-*+])\s+/gm, '') + .replace(/^ {0,3}\d+[.)]\s+/gm, '') + .replace(MARKDOWN_TABLE_DIVIDER_PATTERN, ' ') + .replace(/(^|\s)`([^`\n]+)`(?=\s|$)/g, '$1$2') + .replace( + new RegExp( + `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}\\*\\*([^\\s][^\\n]*?[^\\s])\\*\\*${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, + 'g' + ), + '$1$2' + ) + .replace( + new RegExp( + `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}__([^\\s][^\\n]*?[^\\s])__${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, + 'g' + ), + '$1$2' + ) + .replace( + new RegExp( + `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}~~([^\\s][^\\n]*?[^\\s])~~${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, + 'g' + ), + '$1$2' + ) + .replace( + new RegExp( + `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}\\*([^\\s][^\\n]*?[^\\s])\\*${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, + 'g' + ), + '$1$2' + ) + .replace( + new RegExp( + `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}_([^\\s][^\\n]*?[^\\s])_${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, + 'g' + ), + '$1$2' + ); +} + +function isPipeSeparatedMarkdownRow(value: string): boolean { + if (!value.includes('|')) { + return false; + } + + const cells = value + .split('|') + .map((cell) => collapseWhitespace(cell)) + .filter(Boolean); + return cells.length >= 2; +} + +function sanitizeReminderSourceText(value: string, mode: ReminderTextMode): string { + let text = value.replace(/\r\n?/g, '\n'); + text = stripMarkdownCodeFences(text, mode); + + if (mode === 'command') { + return text.replace(/(^|\n)`([^`\n]+)`(?=\n|$)/g, '$1$2'); + } + + // Some assistant summaries persist escaped markdown (for example \#\#\# or \*\*title\*\*). + // Strip markdown once, unescape, then strip again so notifications stay plain text. + text = stripNaturalMarkdownSyntax(text); + text = unescapeMarkdownSyntax(text); + return stripNaturalMarkdownSyntax(text); +} + +function collectReminderClauses(value: string, mode: ReminderTextMode): string[] { + const lines = value + .split('\n') + .map((line) => { + if (mode === 'command') { + return line; + } + + const trimmed = line.trim(); + const looksLikeTableRow = trimmed.length > 0 && isPipeSeparatedMarkdownRow(trimmed); + + if (!looksLikeTableRow) { + return line; + } + + return trimmed + .split('|') + .map((cell) => collapseWhitespace(cell)) + .filter(Boolean) + .join(getReminderListSeparator()); + }) + .map((line) => collapseWhitespace(line)) + .filter(Boolean); + + if (mode === 'summary') { + return lines.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); + } + + return lines; +} + +function isShortReminderClause(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || hasTerminalPunctuation(trimmed)) { + return false; + } + + if (trimmed.includes(getReminderListSeparator().trim())) { + return false; + } + + return trimmed.length <= (isEnglishReminderLocale() ? 32 : 24); +} + +function joinReminderSequence(clauses: string[], separator: string): string { + const [firstClause, ...restClauses] = clauses; + if (!firstClause) { + return ''; + } + + let result = firstClause; + for (const clause of restClauses) { + const joiner = hasTerminalPunctuation(result) ? ' ' : separator; + result = `${result}${joiner}${clause}`; + } + + return result; } -/** 规范化空白并截断文本,空字符串返回 null。 */ -function summarizeReminderText( +function joinReminderClauses(clauses: string[], mode: ReminderTextMode): string { + const uniqueClauses: string[] = []; + for (const clause of clauses) { + if (uniqueClauses[uniqueClauses.length - 1] === clause) { + continue; + } + uniqueClauses.push(clause); + } + + if (uniqueClauses.length === 0) { + return ''; + } + + const [firstClause, ...restClauses] = uniqueClauses; + if (!firstClause) { + return ''; + } + + if (restClauses.length === 0) { + return firstClause; + } + + const useTitledSummary = + mode === 'summary' && + !hasTerminalPunctuation(firstClause) && + firstClause.length <= (isEnglishReminderLocale() ? 60 : 40); + const separator = + mode === 'summary' && restClauses.every((clause) => isShortReminderClause(clause)) + ? getReminderListSeparator() + : getReminderClauseSeparator(); + const restText = joinReminderSequence(restClauses, separator); + + if (!useTitledSummary) { + return joinReminderSequence(uniqueClauses, separator); + } + + return `${firstClause}${getReminderColonSeparator()}${restText}`; +} + +function appendReminderClause(base: string, clause: string | null): string { + if (!clause) { + return base; + } + + if (!base) { + return clause; + } + + const separator = hasTerminalPunctuation(base) ? ' ' : getReminderSentenceSeparator(); + return `${base}${separator}${clause}`; +} + +function formatReminderLabelValue(label: string, value: string): string { + return `${label}${getReminderColonSeparator()}${value}`; +} + +function summarizeNotificationText( value: string | null | undefined, - maxChars = STATUS_REMINDER_MAX_BODY_CHARS + maxChars = STATUS_REMINDER_MAX_BODY_CHARS, + mode: ReminderTextMode = 'natural' ) { - const normalized = collapseWhitespace(value ?? ''); + const normalized = joinReminderClauses( + collectReminderClauses(sanitizeReminderSourceText(value ?? '', mode), mode), + mode + ); if (!normalized) { return null; } - return truncateReminderText(normalized, maxChars); + return truncateNotificationText(normalized, maxChars); } /** 从会话历史中提取最后一条 assistant 消息的摘要。 */ @@ -99,7 +337,11 @@ function summarizeLatestAssistantResponse(history: SessionMessage[]): string | n continue; } - const summary = summarizeReminderText(message.content); + const summary = summarizeNotificationText( + message.content, + STATUS_REMINDER_MAX_BODY_CHARS, + 'summary' + ); if (summary) { return summary; } @@ -111,26 +353,35 @@ function summarizeLatestAssistantResponse(history: SessionMessage[]): string | n /** 为等待审批状态构建通知正文,包含摘要和命令预览。 */ function buildWaitingApprovalBody(approval: PendingToolApproval): string { const summary = - summarizeReminderText(approval.reason) ?? - summarizeReminderText(approval.description) ?? - summarizeReminderText(approval.title) ?? + summarizeNotificationText(approval.reason) ?? + summarizeNotificationText(approval.description) ?? + summarizeNotificationText(approval.title) ?? getSessionStatusReminderContent('waiting_approval'); - const commandPreview = summarizeReminderText( + const commandPreview = summarizeNotificationText( approval.command, - STATUS_REMINDER_MAX_COMMAND_CHARS + STATUS_REMINDER_MAX_COMMAND_CHARS, + 'command' ); if (!commandPreview || commandPreview === summary) { return summary; } - return `${summary}\n${commandPreview}`; + return truncateNotificationText( + appendReminderClause(summary, formatReminderLabelValue(tt('命令'), commandPreview)), + STATUS_REMINDER_MAX_BODY_CHARS + ); } function buildWaitingUserQuestionBody( question: NonNullable ): string { - return summarizeReminderText(question.questions[0]?.question) ?? tt('任务正在等待用户回复'); + const summary = summarizeNotificationText(question.questions[0]?.question); + if (summary) { + return summary; + } + + return tt('任务正在等待用户回复'); } /** @@ -158,7 +409,7 @@ export function buildSessionStatusReminder( kind: 'failed', title: tt('任务失败'), body: - summarizeReminderText(snapshot.error) ?? + summarizeNotificationText(snapshot.error) ?? summarizeLatestAssistantResponse(snapshot.sessionHistory) ?? getSessionStatusReminderContent('failed'), approval: null, diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index 29f02af3..e599fecd 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -58,4 +58,253 @@ describe('SessionTaskCenter status reminders', () => { approval: null, }); }); + + it('sanitizes markdown from completed assistant summaries before notifying', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'completed', + sessionHistory: [ + { + id: 'assistant-1', + role: 'assistant', + content: + '# Done\n- Fixed **alert** flow\n- Review [diff](https://example.com)\n`pnpm test`', + parts: [], + timestamp: 1, + }, + ], + }) + ); + + expect(reminder).toEqual({ + kind: 'completed', + title: 'Task completed', + body: 'Done: Fixed alert flow, Review diff, pnpm test', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + + it('sanitizes escaped markdown from completed assistant summaries before notifying', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'completed', + sessionHistory: [ + { + id: 'assistant-escaped', + role: 'assistant', + content: + '\\#\\#\\# \\*\\*🎯 10. 关键实现要点(续)\\*\\* 1. \\*\\*数据预处理\\*\\*:统一图像尺寸(建议224x224)', + parts: [], + timestamp: 2, + }, + ], + }) + ); + + expect(reminder).toEqual({ + kind: 'completed', + title: 'Task completed', + body: '🎯 10. 关键实现要点(续) 1. 数据预处理:统一图像尺寸(建议224x224)', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + + it('prefers sanitized failure text over raw markdown error output', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: '```bash\nnpm run lint\n```\n[logs](https://example.com) failed', + }) + ); + + expect(reminder).toEqual({ + kind: 'failed', + title: 'Task failed', + body: 'npm run lint; logs failed', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + + it('sanitizes approval reminders while keeping a readable command preview', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-1', + messageId: 'assistant-1', + title: 'Need approval', + description: 'Run deployment', + command: '```bash\nnpm run deploy -- --env prod\n```', + riskLabel: 'High risk', + reason: '- Deploy **production** build', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder).toEqual({ + kind: 'waiting_approval', + title: 'Pending', + body: 'Deploy production build. Command: npm run deploy -- --env prod', + approval: { + callId: 'call-1', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }, + }); + }); + + it('keeps bracketed log prefixes that are not markdown reference links', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: '[ERROR]: deployment failed', + }) + ); + + expect(reminder).toEqual({ + kind: 'failed', + title: 'Task failed', + body: '[ERROR]: deployment failed', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + + it('does not strip non-markdown path and glob characters from approval content', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-2', + messageId: 'assistant-2', + title: 'Need approval', + description: 'Inspect __tests__/center.test.ts and packages/**/src', + command: 'rg "__tests__/center.test.ts" packages/**/src', + riskLabel: 'Medium risk', + reason: 'Check __tests__/center.test.ts before touching packages/**/src', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder).toEqual({ + kind: 'waiting_approval', + title: 'Pending', + body: 'Check __tests__/center.test.ts before touching packages/**/src. Command: rg "__tests__/center.test.ts" packages/**/src', + approval: { + callId: 'call-2', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }, + }); + }); + + it('keeps shell pipelines intact in command previews', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-3', + messageId: 'assistant-3', + title: 'Need approval', + description: 'Inspect logs', + command: 'cat app.log | grep ERROR | head -n 20', + riskLabel: 'Low risk', + reason: 'Inspect logs quickly', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder).toEqual({ + kind: 'waiting_approval', + title: 'Pending', + body: 'Inspect logs quickly. Command: cat app.log | grep ERROR | head -n 20', + approval: { + callId: 'call-3', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }, + }); + }); + + it('still flattens markdown table rows into readable notification text', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'completed', + sessionHistory: [ + { + id: 'assistant-2', + role: 'assistant', + content: + '| Step | Result |\n| --- | --- |\n| lint | passed |\n| test | passed |', + parts: [], + timestamp: 2, + }, + ], + }) + ); + + expect(reminder).toEqual({ + kind: 'completed', + title: 'Task completed', + body: 'Step, Result: lint, passed; test, passed', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + + it('turns markdown-heavy completion output into plain-text notification content', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'completed', + sessionHistory: [ + { + id: 'assistant-markdown-heavy', + role: 'assistant', + content: `## 🧠 LangChain Memory(记忆管理)详细实现 + +### **📋 1. Memory 核心概念** +Memory 是 LangChain 中管理对话历史和上下文的组件,负责在多轮对话中保持状态。 + +Memory类型 | 作用 | 适用场景 +--- | --- | --- +BufferMemory | 短期记忆 | 简单聊天`, + parts: [], + timestamp: 3, + }, + ], + }) + ); + + expect(reminder).toEqual({ + kind: 'completed', + title: 'Task completed', + body: '🧠 LangChain Memory(记忆管理)详细实现: 📋 1. Memory 核心概念; Memory 是 LangChain 中管理对话历史和上下文的组件,负责在多轮对话中保持状态。 Memory类型, 作用, 适用场景', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); }); From 804cfe27e6e4bc92da47c073aa981a790a50e3d7 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:27:32 +0800 Subject: [PATCH 02/12] refactor(desktop): parse reminder summaries with markdown tokens --- .../src/services/AgentService/task/center.ts | 314 ++++++++++++------ 1 file changed, 205 insertions(+), 109 deletions(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 10c3addd..5e29a4b4 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -1,5 +1,7 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 +import { getMarkdown } from 'markstream-vue'; + import { getLocale, tt } from '@/i18n'; import { eventService } from '@/services/EventService'; import { AppEvent, type SessionStatusReminderPayload } from '@/services/EventService/types'; @@ -42,13 +44,26 @@ const TERMINAL_TASK_RETENTION_MS = 5 * 60 * 1000; const STATUS_REMINDER_MAX_BODY_CHARS = 220; const STATUS_REMINDER_MAX_COMMAND_CHARS = 160; const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; -const MARKDOWN_REFERENCE_LINK_DEFINITION_PATTERN = - /^\s*\[[^\]]+\]:\s+(?:<[^>\s]+>|(?:[a-z][a-z0-9+.-]*:|\/|\.{1,2}\/|#)[^\s]*)(?:\s+(?:"[^"]*"|'[^']*'|\([^)\n]*\)))?\s*$/gim; -const MARKDOWN_TABLE_DIVIDER_PATTERN = /^\s*\|?(?:\s*:?-+:?\s*\|)+(?:\s*:?-+:?\s*)?\|?\s*$/gm; -const MARKDOWN_EMPHASIS_LEADING_BOUNDARY = `(^|[\\s([{"'“‘(【《])`; -const MARKDOWN_EMPHASIS_TRAILING_BOUNDARY = `(?=$|[\\s,.;:!?,。;:!?、】【)》」』〕〉>\\]}'"”’])`; +const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; +const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; type ReminderTextMode = 'natural' | 'command' | 'summary'; +type ReminderMarkdownToken = { + type: string; + tag?: string; + content?: string; + text?: string; + raw?: string; + markup?: string; + children?: ReminderMarkdownToken[] | null; +}; + +const reminderMarkdownParser = getMarkdown('touchai-reminder-markdown', { + enableContainers: false, + markdownItOptions: { + breaks: true, + }, +}); /** 深拷贝任务快照,确保外部订阅者无法直接修改内部状态。 */ function cloneTaskSnapshot(snapshot: SessionTaskSnapshot): SessionTaskSnapshot { @@ -109,126 +124,205 @@ function hasTerminalPunctuation(value: string): boolean { return /[.!?。!?;;::]$/.test(value.trim()); } -function stripMarkdownCodeFences(value: string, mode: ReminderTextMode): string { - const replacement = mode === 'command' ? '$1' : mode === 'summary' ? '\n' : '\n$1\n'; - return value - .replace(/```(?:[\w-]+)?\n?([\s\S]*?)```/g, replacement) - .replace(/~~~(?:[\w-]+)?\n?([\s\S]*?)~~~/g, replacement); -} - -function unescapeMarkdownSyntax(value: string): string { - return value.replace(/\\([\\`*_{}[\]()#+.!>-])/g, '$1'); -} - -function stripNaturalMarkdownSyntax(value: string): string { - return value - .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(MARKDOWN_REFERENCE_LINK_DEFINITION_PATTERN, ' ') - .replace(/^ {0,3}(?:```|~~~)[\w-]*\s*$/gm, ' ') - .replace(/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/gm, ' ') - .replace(/^ {0,3}#{1,6}\s+/gm, '') - .replace(/^ {0,3}>\s?/gm, '') - .replace(/^ {0,3}(?:[-*+])\s+\[[ xX]\]\s+/gm, '') - .replace(/^ {0,3}(?:[-*+])\s+/gm, '') - .replace(/^ {0,3}\d+[.)]\s+/gm, '') - .replace(MARKDOWN_TABLE_DIVIDER_PATTERN, ' ') - .replace(/(^|\s)`([^`\n]+)`(?=\s|$)/g, '$1$2') - .replace( - new RegExp( - `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}\\*\\*([^\\s][^\\n]*?[^\\s])\\*\\*${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, - 'g' - ), - '$1$2' - ) - .replace( - new RegExp( - `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}__([^\\s][^\\n]*?[^\\s])__${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, - 'g' - ), - '$1$2' - ) - .replace( - new RegExp( - `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}~~([^\\s][^\\n]*?[^\\s])~~${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, - 'g' - ), - '$1$2' - ) - .replace( - new RegExp( - `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}\\*([^\\s][^\\n]*?[^\\s])\\*${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, - 'g' - ), - '$1$2' - ) - .replace( - new RegExp( - `${MARKDOWN_EMPHASIS_LEADING_BOUNDARY}_([^\\s][^\\n]*?[^\\s])_${MARKDOWN_EMPHASIS_TRAILING_BOUNDARY}`, - 'g' - ), - '$1$2' - ); +/** Rehydrate backslash-escaped markdown so token parsing sees the intended text. */ +function unescapeReminderMarkdown(value: string): string { + return value.replace(REMINDER_MARKDOWN_ESCAPE_PATTERN, '$1'); } -function isPipeSeparatedMarkdownRow(value: string): boolean { - if (!value.includes('|')) { - return false; +/** Escape path-like fragments so markdown emphasis markers inside file paths are preserved. */ +function protectPathLikeMarkdownToken(token: string): string { + if (!/[`*_]/.test(token)) { + return token; } - const cells = value - .split('|') - .map((cell) => collapseWhitespace(cell)) - .filter(Boolean); - return cells.length >= 2; + if (/^!?\[[^\]]*]\([^)]+\)$/.test(token)) { + return token; + } + + return token.replace(/([`*_])/g, '\\$1'); } -function sanitizeReminderSourceText(value: string, mode: ReminderTextMode): string { - let text = value.replace(/\r\n?/g, '\n'); - text = stripMarkdownCodeFences(text, mode); +/** Normalize reminder input before markdown parsing and protect path and glob syntax. */ +function prepareReminderMarkdownSource(value: string, mode: ReminderTextMode): string { + const normalized = value.replace(/\r\n?/g, '\n'); + const unescaped = mode === 'command' ? normalized : unescapeReminderMarkdown(normalized); + return unescaped.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, protectPathLikeMarkdownToken); +} - if (mode === 'command') { - return text.replace(/(^|\n)`([^`\n]+)`(?=\n|$)/g, '$1$2'); +/** Reduce inline or block HTML to plain text for notification-safe summaries. */ +function stripHtmlToText(value: string): string { + if (!value) { + return ''; + } + + if (typeof DOMParser !== 'undefined') { + try { + return new DOMParser().parseFromString(value, 'text/html').body.textContent ?? ''; + } catch { + // Fall back to a conservative tag strip when DOM parsing is unavailable. + } } - // Some assistant summaries persist escaped markdown (for example \#\#\# or \*\*title\*\*). - // Strip markdown once, unescape, then strip again so notifications stay plain text. - text = stripNaturalMarkdownSyntax(text); - text = unescapeMarkdownSyntax(text); - return stripNaturalMarkdownSyntax(text); + return value.replace(/<[^>]+>/g, ' '); } -function collectReminderClauses(value: string, mode: ReminderTextMode): string[] { - const lines = value - .split('\n') - .map((line) => { - if (mode === 'command') { - return line; - } +/** Keep command previews copyable by converting typographic quotes back to ASCII. */ +function normalizeCommandTypography(value: string): string { + return value.replace(/[“”]/g, '"').replace(/[‘’]/g, "'"); +} + +/** Flatten inline markdown tokens into readable plain text for reminder clauses. */ +function extractReminderInlineText( + tokens: ReminderMarkdownToken[] | null | undefined, + fallback: string +): string { + if (!tokens?.length) { + return fallback; + } - const trimmed = line.trim(); - const looksLikeTableRow = trimmed.length > 0 && isPipeSeparatedMarkdownRow(trimmed); + let text = ''; + for (const token of tokens) { + switch (token.type) { + case 'text': + case 'code_inline': + text += token.content ?? token.text ?? ''; + break; + case 'softbreak': + case 'hardbreak': + text += '\n'; + break; + case 'html_inline': + text += stripHtmlToText(token.content ?? token.raw ?? ''); + break; + case 'link': + text += + token.text ?? + extractReminderInlineText(token.children, token.content ?? token.raw ?? ''); + break; + default: + if (token.children?.length) { + text += extractReminderInlineText(token.children, ''); + break; + } - if (!looksLikeTableRow) { - return line; - } + if (token.type.endsWith('_open') || token.type.endsWith('_close')) { + break; + } + + text += token.content ?? token.text ?? ''; + break; + } + } - return trimmed - .split('|') - .map((cell) => collapseWhitespace(cell)) - .filter(Boolean) - .join(getReminderListSeparator()); - }) - .map((line) => collapseWhitespace(line)) - .filter(Boolean); + return text || fallback; +} +/** Split normalized text into non-empty clauses for later summary joining. */ +function pushReminderClauses(target: string[], value: string): void { + for (const line of value.split('\n')) { + const clause = collapseWhitespace(line); + if (clause) { + target.push(clause); + } + } +} + +/** Provide a plain-text fallback when markdown tokenization fails. */ +function fallbackReminderClauses(value: string, mode: ReminderTextMode): string[] { + const clauses: string[] = []; + const fallbackText = + mode === 'command' ? normalizeCommandTypography(value) : stripHtmlToText(value); + pushReminderClauses(clauses, fallbackText); if (mode === 'summary') { - return lines.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); + return clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); } - return lines; + return clauses; } +/** Parse reminder markdown into plain-text clauses, including tables and code blocks. */ +function collectReminderClauses(value: string, mode: ReminderTextMode): string[] { + const source = prepareReminderMarkdownSource(value, mode); + if (!source.trim()) { + return []; + } + + try { + const tokens = reminderMarkdownParser.parse(source, {}) as ReminderMarkdownToken[]; + const clauses: string[] = []; + let currentTableRow: string[] | null = null; + let insideTableCell = false; + + for (const token of tokens) { + switch (token.type) { + case 'tr_open': + currentTableRow = []; + break; + case 'tr_close': { + const row = currentTableRow?.filter(Boolean).join(getReminderListSeparator()); + if (row) { + clauses.push(row); + } + currentTableRow = null; + insideTableCell = false; + break; + } + case 'th_open': + case 'td_open': + insideTableCell = true; + break; + case 'th_close': + case 'td_close': + insideTableCell = false; + break; + case 'inline': { + const text = extractReminderInlineText(token.children, token.content ?? ''); + if (!text) { + break; + } + + const normalizedText = + mode === 'command' ? normalizeCommandTypography(text) : text; + + if (insideTableCell && currentTableRow) { + const cell = collapseWhitespace(normalizedText); + if (cell) { + currentTableRow.push(cell); + } + break; + } + + pushReminderClauses(clauses, normalizedText); + break; + } + case 'fence': + case 'code_block': + pushReminderClauses( + clauses, + mode === 'command' + ? normalizeCommandTypography(token.content ?? '') + : (token.content ?? '') + ); + break; + case 'html_block': + pushReminderClauses(clauses, stripHtmlToText(token.content ?? '')); + break; + default: + break; + } + } + + if (mode === 'summary') { + return clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); + } + + return clauses; + } catch { + return fallbackReminderClauses(source, mode); + } +} + +/** Identify short fragments that can be merged into a compact summary line. */ function isShortReminderClause(value: string): boolean { const trimmed = value.trim(); if (!trimmed || hasTerminalPunctuation(trimmed)) { @@ -242,6 +336,7 @@ function isShortReminderClause(value: string): boolean { return trimmed.length <= (isEnglishReminderLocale() ? 32 : 24); } +/** Join clauses without doubling separators after terminal punctuation. */ function joinReminderSequence(clauses: string[], separator: string): string { const [firstClause, ...restClauses] = clauses; if (!firstClause) { @@ -257,6 +352,7 @@ function joinReminderSequence(clauses: string[], separator: string): string { return result; } +/** Build the final reminder sentence with locale-aware separators and summary shaping. */ function joinReminderClauses(clauses: string[], mode: ReminderTextMode): string { const uniqueClauses: string[] = []; for (const clause of clauses) { @@ -296,6 +392,7 @@ function joinReminderClauses(clauses: string[], mode: ReminderTextMode): string return `${firstClause}${getReminderColonSeparator()}${restText}`; } +/** Append an extra reminder fragment while keeping the sentence readable. */ function appendReminderClause(base: string, clause: string | null): string { if (!clause) { return base; @@ -309,19 +406,18 @@ function appendReminderClause(base: string, clause: string | null): string { return `${base}${separator}${clause}`; } +/** Format a labeled reminder fragment such as a command preview. */ function formatReminderLabelValue(label: string, value: string): string { return `${label}${getReminderColonSeparator()}${value}`; } +/** Convert markdown-rich content into a notification-ready plain-text summary. */ function summarizeNotificationText( value: string | null | undefined, maxChars = STATUS_REMINDER_MAX_BODY_CHARS, mode: ReminderTextMode = 'natural' ) { - const normalized = joinReminderClauses( - collectReminderClauses(sanitizeReminderSourceText(value ?? '', mode), mode), - mode - ); + const normalized = joinReminderClauses(collectReminderClauses(value ?? '', mode), mode); if (!normalized) { return null; } From 7308d85aee888f2be026a89c7ee943a448a46c5d Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:13:28 +0800 Subject: [PATCH 03/12] fix(desktop): preserve reminder paths and link tokens --- .../src/services/AgentService/task/center.ts | 68 +++++++++++++++++-- .../AgentService/task/center-reminder.test.ts | 34 ++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 5e29a4b4..7585720b 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -46,8 +46,11 @@ const STATUS_REMINDER_MAX_COMMAND_CHARS = 160; const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; +const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; type ReminderTextMode = 'natural' | 'command' | 'summary'; +// markstream emits standard markdown-it tokens and may also surface a custom +// inline `link` token with pre-flattened label text. type ReminderMarkdownToken = { type: string; tag?: string; @@ -129,8 +132,35 @@ function unescapeReminderMarkdown(value: string): string { return value.replace(REMINDER_MARKDOWN_ESCAPE_PATTERN, '$1'); } +/** Identify tokens that look like filesystem paths or globs rather than markdown escapes. */ +function isReminderPathLikeToken(token: string): boolean { + const core = token.replace(REMINDER_PATH_WRAPPER_TRIM_PATTERN, ''); + const pathSegment = '(?:[A-Za-z0-9._-]+|\\*{1,2})'; + const posixRelativePattern = new RegExp(`^(?:${pathSegment}/)+${pathSegment}$`); + const windowsRelativePattern = new RegExp(`^(?:${pathSegment}\\\\)+${pathSegment}$`); + const posixAbsolutePattern = new RegExp( + `^(?:/|\\.{1,2}/|~/)(?:${pathSegment}/)*${pathSegment}$` + ); + const windowsAbsolutePattern = new RegExp( + `^(?:[A-Za-z]:\\\\|\\.{1,2}\\\\|~\\\\)(?:${pathSegment}\\\\)*${pathSegment}$` + ); + const windowsUncPattern = new RegExp(`^\\\\\\\\${pathSegment}(?:\\\\${pathSegment})+$`); + + return ( + posixRelativePattern.test(core) || + windowsRelativePattern.test(core) || + posixAbsolutePattern.test(core) || + windowsAbsolutePattern.test(core) || + windowsUncPattern.test(core) + ); +} + /** Escape path-like fragments so markdown emphasis markers inside file paths are preserved. */ function protectPathLikeMarkdownToken(token: string): string { + if (!isReminderPathLikeToken(token)) { + return token; + } + if (!/[`*_]/.test(token)) { return token; } @@ -139,14 +169,36 @@ function protectPathLikeMarkdownToken(token: string): string { return token; } - return token.replace(/([`*_])/g, '\\$1'); + return token.replace(/([\\`*_])/g, '\\$1'); +} + +/** Protect path-like fragments before parsing so later markdown cleanup does not corrupt them. */ +function protectReminderPathLikeTokens(value: string): string { + return value.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, protectPathLikeMarkdownToken); } /** Normalize reminder input before markdown parsing and protect path and glob syntax. */ function prepareReminderMarkdownSource(value: string, mode: ReminderTextMode): string { const normalized = value.replace(/\r\n?/g, '\n'); - const unescaped = mode === 'command' ? normalized : unescapeReminderMarkdown(normalized); - return unescaped.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, protectPathLikeMarkdownToken); + if (mode === 'command') { + return protectReminderPathLikeTokens(normalized); + } + + const protectedPaths: string[] = []; + const withPlaceholders = normalized.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, (token) => { + if (!isReminderPathLikeToken(token)) { + return token; + } + + const placeholder = `%%TOUCHAI_REMINDER_PATH_${protectedPaths.length}%%`; + protectedPaths.push(protectPathLikeMarkdownToken(token)); + return placeholder; + }); + const unescaped = unescapeReminderMarkdown(withPlaceholders); + return protectedPaths.reduce( + (text, token, index) => text.replace(`%%TOUCHAI_REMINDER_PATH_${index}%%`, token), + unescaped + ); } /** Reduce inline or block HTML to plain text for notification-safe summaries. */ @@ -195,9 +247,13 @@ function extractReminderInlineText( text += stripHtmlToText(token.content ?? token.raw ?? ''); break; case 'link': - text += - token.text ?? - extractReminderInlineText(token.children, token.content ?? token.raw ?? ''); + text += extractReminderInlineText( + token.children, + token.text ?? token.content ?? '' + ); + break; + case 'link_open': + case 'link_close': break; default: if (token.children?.length) { diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index e599fecd..17f19a2e 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -215,6 +215,40 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('preserves windows paths and escaped markdown markers in approval content', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-2b', + messageId: 'assistant-2b', + title: 'Need approval', + description: 'Inspect C:\\Users\\admin\\__tests__\\center.test.ts', + command: + 'rg "C:\\Users\\admin\\__tests__\\center.test.ts" D:\\work\\packages\\**\\src', + riskLabel: 'Medium risk', + reason: 'Check C:\\Users\\admin\\__tests__\\center.test.ts before touching D:\\work\\packages\\**\\src', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder).toEqual({ + kind: 'waiting_approval', + title: 'Pending', + body: 'Check C:\\Users\\admin\\__tests__\\center.test.ts before touching D:\\work\\packages\\**\\src. Command: rg "C:\\Users\\admin\\__tests__\\center.test.ts" D:\\work\\packages\\**\\src', + approval: { + callId: 'call-2b', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }, + }); + }); + it('keeps shell pipelines intact in command previews', () => { const reminder = buildSessionStatusReminder( createSnapshot({ From a6a708a3dae9420e41338400967ad2cf2038fe0f Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:27:09 +0800 Subject: [PATCH 04/12] refactor(desktop): tighten reminder token typing --- .../src/services/AgentService/task/center.ts | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 7585720b..7aa14586 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -47,19 +47,38 @@ const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; +const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._-]+|\\*{1,2})'; +const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( + `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_WINDOWS_RELATIVE_PATH_PATTERN = new RegExp( + `^(?:${REMINDER_PATH_SEGMENT_PATTERN}\\\\)+${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_POSIX_ABSOLUTE_PATH_PATTERN = new RegExp( + `^(?:/|\\.{1,2}/|~/)(?:${REMINDER_PATH_SEGMENT_PATTERN}/)*${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_WINDOWS_ABSOLUTE_PATH_PATTERN = new RegExp( + `^(?:[A-Za-z]:\\\\|\\.{1,2}\\\\|~\\\\)(?:${REMINDER_PATH_SEGMENT_PATTERN}\\\\)*${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_WINDOWS_UNC_PATH_PATTERN = new RegExp( + `^\\\\\\\\${REMINDER_PATH_SEGMENT_PATTERN}(?:\\\\${REMINDER_PATH_SEGMENT_PATTERN})+$` +); type ReminderTextMode = 'natural' | 'command' | 'summary'; // markstream emits standard markdown-it tokens and may also surface a custom // inline `link` token with pre-flattened label text. -type ReminderMarkdownToken = { +type ReminderMarkdownBaseToken = { type: string; tag?: string; content?: string; - text?: string; - raw?: string; markup?: string; children?: ReminderMarkdownToken[] | null; }; +type ReminderMarkdownLinkToken = ReminderMarkdownBaseToken & { + type: 'link'; + text?: string; +}; +type ReminderMarkdownToken = ReminderMarkdownBaseToken | ReminderMarkdownLinkToken; const reminderMarkdownParser = getMarkdown('touchai-reminder-markdown', { enableContainers: false, @@ -135,23 +154,12 @@ function unescapeReminderMarkdown(value: string): string { /** Identify tokens that look like filesystem paths or globs rather than markdown escapes. */ function isReminderPathLikeToken(token: string): boolean { const core = token.replace(REMINDER_PATH_WRAPPER_TRIM_PATTERN, ''); - const pathSegment = '(?:[A-Za-z0-9._-]+|\\*{1,2})'; - const posixRelativePattern = new RegExp(`^(?:${pathSegment}/)+${pathSegment}$`); - const windowsRelativePattern = new RegExp(`^(?:${pathSegment}\\\\)+${pathSegment}$`); - const posixAbsolutePattern = new RegExp( - `^(?:/|\\.{1,2}/|~/)(?:${pathSegment}/)*${pathSegment}$` - ); - const windowsAbsolutePattern = new RegExp( - `^(?:[A-Za-z]:\\\\|\\.{1,2}\\\\|~\\\\)(?:${pathSegment}\\\\)*${pathSegment}$` - ); - const windowsUncPattern = new RegExp(`^\\\\\\\\${pathSegment}(?:\\\\${pathSegment})+$`); - return ( - posixRelativePattern.test(core) || - windowsRelativePattern.test(core) || - posixAbsolutePattern.test(core) || - windowsAbsolutePattern.test(core) || - windowsUncPattern.test(core) + REMINDER_POSIX_RELATIVE_PATH_PATTERN.test(core) || + REMINDER_WINDOWS_RELATIVE_PATH_PATTERN.test(core) || + REMINDER_POSIX_ABSOLUTE_PATH_PATTERN.test(core) || + REMINDER_WINDOWS_ABSOLUTE_PATH_PATTERN.test(core) || + REMINDER_WINDOWS_UNC_PATH_PATTERN.test(core) ); } @@ -237,19 +245,19 @@ function extractReminderInlineText( switch (token.type) { case 'text': case 'code_inline': - text += token.content ?? token.text ?? ''; + text += token.content ?? ''; break; case 'softbreak': case 'hardbreak': text += '\n'; break; case 'html_inline': - text += stripHtmlToText(token.content ?? token.raw ?? ''); + text += stripHtmlToText(token.content ?? ''); break; case 'link': text += extractReminderInlineText( token.children, - token.text ?? token.content ?? '' + (token as ReminderMarkdownLinkToken).text ?? token.content ?? '' ); break; case 'link_open': @@ -265,7 +273,7 @@ function extractReminderInlineText( break; } - text += token.content ?? token.text ?? ''; + text += token.content ?? ''; break; } } From b5fad44ddd545c4afe99a4cc0e8f1871825fbcd6 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:07:34 +0800 Subject: [PATCH 05/12] fix: support scoped package reminder paths --- .../src/services/AgentService/task/center.ts | 2 +- .../AgentService/task/center-reminder.test.ts | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 7aa14586..397f3934 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -47,7 +47,7 @@ const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; -const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._-]+|\\*{1,2})'; +const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._@-]+|\\*{1,2})'; const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` ); diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index 17f19a2e..705a3c0e 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -215,6 +215,39 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('preserves markdown markers inside scoped package paths in approval content', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-2a', + messageId: 'assistant-2a', + title: 'Need approval', + description: 'Inspect packages/@touchai/core/__tests__/center.test.ts', + command: 'rg "center" packages/@touchai/core/__tests__/center.test.ts', + riskLabel: 'Medium risk', + reason: 'Check packages/@touchai/core/__tests__/center.test.ts before editing', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder).toEqual({ + kind: 'waiting_approval', + title: 'Pending', + body: 'Check packages/@touchai/core/__tests__/center.test.ts before editing. Command: rg "center" packages/@touchai/core/__tests__/center.test.ts', + approval: { + callId: 'call-2a', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }, + }); + }); + it('preserves windows paths and escaped markdown markers in approval content', () => { const reminder = buildSessionStatusReminder( createSnapshot({ From 70516f2bfd36dfe50d17add85e5f5b07b36c5950 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:23:03 +0800 Subject: [PATCH 06/12] fix: preserve approval command previews --- .../src/services/AgentService/task/center.ts | 19 ++++++++++ .../AgentService/task/center-reminder.test.ts | 37 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 397f3934..cf94b830 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -231,6 +231,21 @@ function normalizeCommandTypography(value: string): string { return value.replace(/[“”]/g, '"').replace(/[‘’]/g, "'"); } +/** Keep approval command previews recognizable instead of markdown-cleaned. */ +function summarizeCommandPreview( + value: string | null | undefined, + maxChars: number +): string | null { + const normalized = collapseWhitespace( + normalizeCommandTypography((value ?? '').replace(/\r\n?/g, '\n')) + ); + if (!normalized) { + return null; + } + + return truncateNotificationText(normalized, maxChars); +} + /** Flatten inline markdown tokens into readable plain text for reminder clauses. */ function extractReminderInlineText( tokens: ReminderMarkdownToken[] | null | undefined, @@ -481,6 +496,10 @@ function summarizeNotificationText( maxChars = STATUS_REMINDER_MAX_BODY_CHARS, mode: ReminderTextMode = 'natural' ) { + if (mode === 'command') { + return summarizeCommandPreview(value, maxChars); + } + const normalized = joinReminderClauses(collectReminderClauses(value ?? '', mode), mode); if (!normalized) { return null; diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index 705a3c0e..6ef5c43e 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -131,7 +131,7 @@ describe('SessionTaskCenter status reminders', () => { }); }); - it('sanitizes approval reminders while keeping a readable command preview', () => { + it('sanitizes approval reminders while keeping a literal command preview', () => { const reminder = buildSessionStatusReminder( createSnapshot({ status: 'waiting_approval', @@ -155,7 +155,7 @@ describe('SessionTaskCenter status reminders', () => { expect(reminder).toEqual({ kind: 'waiting_approval', title: 'Pending', - body: 'Deploy production build. Command: npm run deploy -- --env prod', + body: 'Deploy production build. Command: ```bash npm run deploy -- --env prod ```', approval: { callId: 'call-1', approveLabel: 'Approve', @@ -315,6 +315,39 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('preserves markdown-looking shell syntax in command previews', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-3a', + messageId: 'assistant-3a', + title: 'Need approval', + description: 'Review shell redirection', + command: 'cat <out.txt\necho \u201c\u201d\n>out.txt', + riskLabel: 'Medium risk', + reason: 'Review shell redirection', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder).toEqual({ + kind: 'waiting_approval', + title: 'Pending', + body: 'Review shell redirection. Command: cat <out.txt echo "" >out.txt', + approval: { + callId: 'call-3a', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }, + }); + }); + it('still flattens markdown table rows into readable notification text', () => { const reminder = buildSessionStatusReminder( createSnapshot({ From 53b12e073447ee65d2b75a4ccf5e4ce1c8bcc523 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:59:17 +0800 Subject: [PATCH 07/12] fix: tighten reminder markdown sanitization --- .../src/services/AgentService/task/center.ts | 234 +++++++++++++++--- .../AgentService/task/center-reminder.test.ts | 61 +++++ 2 files changed, 256 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index cf94b830..284c51d6 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -79,6 +79,52 @@ type ReminderMarkdownLinkToken = ReminderMarkdownBaseToken & { text?: string; }; type ReminderMarkdownToken = ReminderMarkdownBaseToken | ReminderMarkdownLinkToken; +type ReminderInlineHtmlTag = { + hasAttributes: boolean; + isClosing: boolean; + isSelfClosing: boolean; + tagName: string; +}; + +const REMINDER_INLINE_LINE_BREAK_HTML_TAGS = new Set(['br', 'hr']); +const REMINDER_STRIPPABLE_INLINE_HTML_TAGS = new Set([ + 'a', + 'abbr', + 'b', + 'bdi', + 'bdo', + 'cite', + 'code', + 'data', + 'del', + 'dfn', + 'div', + 'em', + 'i', + 'ins', + 'kbd', + 'li', + 'mark', + 'p', + 'pre', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'ul', + 'ol', + 'var', + 'wbr', +]); const reminderMarkdownParser = getMarkdown('touchai-reminder-markdown', { enableContainers: false, @@ -180,18 +226,9 @@ function protectPathLikeMarkdownToken(token: string): string { return token.replace(/([\\`*_])/g, '\\$1'); } -/** Protect path-like fragments before parsing so later markdown cleanup does not corrupt them. */ -function protectReminderPathLikeTokens(value: string): string { - return value.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, protectPathLikeMarkdownToken); -} - /** Normalize reminder input before markdown parsing and protect path and glob syntax. */ -function prepareReminderMarkdownSource(value: string, mode: ReminderTextMode): string { +function prepareReminderMarkdownSource(value: string): string { const normalized = value.replace(/\r\n?/g, '\n'); - if (mode === 'command') { - return protectReminderPathLikeTokens(normalized); - } - const protectedPaths: string[] = []; const withPlaceholders = normalized.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, (token) => { if (!isReminderPathLikeToken(token)) { @@ -209,7 +246,7 @@ function prepareReminderMarkdownSource(value: string, mode: ReminderTextMode): s ); } -/** Reduce inline or block HTML to plain text for notification-safe summaries. */ +/** Reduce block HTML to plain text for notification-safe summaries. */ function stripHtmlToText(value: string): string { if (!value) { return ''; @@ -226,6 +263,118 @@ function stripHtmlToText(value: string): string { return value.replace(/<[^>]+>/g, ' '); } +function parseReminderInlineHtmlTag(value: string): ReminderInlineHtmlTag | null { + const match = value.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)>$/); + if (!match) { + return null; + } + + const suffix = match[3] ?? ''; + const normalizedSuffix = suffix.replace(/\/\s*$/, ''); + return { + hasAttributes: /\S/.test(normalizedSuffix), + isClosing: Boolean(match[1]), + isSelfClosing: /\/\s*$/.test(suffix), + tagName: match[2]?.toLowerCase() ?? '', + }; +} + +function hasMatchingReminderInlineClosingTag( + tokens: ReminderMarkdownToken[], + startIndex: number, + tagName: string +): boolean { + let depth = 0; + for (let index = startIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token?.type !== 'html_inline') { + continue; + } + + const parsedTag = parseReminderInlineHtmlTag(token.content ?? ''); + if (!parsedTag || parsedTag.tagName !== tagName) { + continue; + } + + if (!parsedTag.isClosing) { + depth += 1; + continue; + } + + if (depth === 0) { + return true; + } + + depth -= 1; + } + + return false; +} + +function normalizeReminderFallbackHtml(value: string): string { + let normalized = value + .replace(//gi, '\n') + .replace(//gi, '\n') + .replace(//gi, ''); + + for (const tagName of REMINDER_STRIPPABLE_INLINE_HTML_TAGS) { + if (REMINDER_INLINE_LINE_BREAK_HTML_TAGS.has(tagName)) { + continue; + } + + normalized = normalized.replace(new RegExp(`]*>`, 'gi'), ''); + } + + return normalized; +} + +function normalizeReminderInlineHtmlToken( + tokenContent: string, + tokens: ReminderMarkdownToken[], + index: number, + openTags: Map +): string { + const parsedTag = parseReminderInlineHtmlTag(tokenContent); + if (!parsedTag) { + return tokenContent; + } + + if (REMINDER_INLINE_LINE_BREAK_HTML_TAGS.has(parsedTag.tagName)) { + return '\n'; + } + + const shouldStripTag = + REMINDER_STRIPPABLE_INLINE_HTML_TAGS.has(parsedTag.tagName) || parsedTag.hasAttributes; + if (!shouldStripTag) { + return tokenContent; + } + + if (parsedTag.isClosing) { + const openCount = openTags.get(parsedTag.tagName) ?? 0; + if (openCount <= 0) { + return tokenContent; + } + + if (openCount === 1) { + openTags.delete(parsedTag.tagName); + } else { + openTags.set(parsedTag.tagName, openCount - 1); + } + return ''; + } + + if (parsedTag.isSelfClosing) { + return ''; + } + + if (!hasMatchingReminderInlineClosingTag(tokens, index, parsedTag.tagName)) { + return tokenContent; + } + + openTags.set(parsedTag.tagName, (openTags.get(parsedTag.tagName) ?? 0) + 1); + return ''; +} + /** Keep command previews copyable by converting typographic quotes back to ASCII. */ function normalizeCommandTypography(value: string): string { return value.replace(/[“”]/g, '"').replace(/[‘’]/g, "'"); @@ -256,7 +405,13 @@ function extractReminderInlineText( } let text = ''; - for (const token of tokens) { + const openInlineHtmlTags = new Map(); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + switch (token.type) { case 'text': case 'code_inline': @@ -267,7 +422,12 @@ function extractReminderInlineText( text += '\n'; break; case 'html_inline': - text += stripHtmlToText(token.content ?? ''); + text += normalizeReminderInlineHtmlToken( + token.content ?? '', + tokens, + index, + openInlineHtmlTags + ); break; case 'link': text += extractReminderInlineText( @@ -306,22 +466,20 @@ function pushReminderClauses(target: string[], value: string): void { } } -/** Provide a plain-text fallback when markdown tokenization fails. */ -function fallbackReminderClauses(value: string, mode: ReminderTextMode): string[] { +/** Provide a conservative fallback when markdown tokenization fails. */ +function fallbackReminderClauses(value: string): string[] { const clauses: string[] = []; - const fallbackText = - mode === 'command' ? normalizeCommandTypography(value) : stripHtmlToText(value); + const fallbackText = normalizeReminderFallbackHtml(value); pushReminderClauses(clauses, fallbackText); - if (mode === 'summary') { - return clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); - } - return clauses; } /** Parse reminder markdown into plain-text clauses, including tables and code blocks. */ -function collectReminderClauses(value: string, mode: ReminderTextMode): string[] { - const source = prepareReminderMarkdownSource(value, mode); +function collectReminderClauses( + value: string, + mode: Extract +): string[] { + const source = prepareReminderMarkdownSource(value); if (!source.trim()) { return []; } @@ -360,28 +518,20 @@ function collectReminderClauses(value: string, mode: ReminderTextMode): string[] break; } - const normalizedText = - mode === 'command' ? normalizeCommandTypography(text) : text; - if (insideTableCell && currentTableRow) { - const cell = collapseWhitespace(normalizedText); + const cell = collapseWhitespace(text); if (cell) { currentTableRow.push(cell); } break; } - pushReminderClauses(clauses, normalizedText); + pushReminderClauses(clauses, text); break; } case 'fence': case 'code_block': - pushReminderClauses( - clauses, - mode === 'command' - ? normalizeCommandTypography(token.content ?? '') - : (token.content ?? '') - ); + pushReminderClauses(clauses, token.content ?? ''); break; case 'html_block': pushReminderClauses(clauses, stripHtmlToText(token.content ?? '')); @@ -397,7 +547,8 @@ function collectReminderClauses(value: string, mode: ReminderTextMode): string[] return clauses; } catch { - return fallbackReminderClauses(source, mode); + const clauses = fallbackReminderClauses(source); + return mode === 'summary' ? clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES) : clauses; } } @@ -546,10 +697,15 @@ function buildWaitingApprovalBody(approval: PendingToolApproval): string { return summary; } - return truncateNotificationText( - appendReminderClause(summary, formatReminderLabelValue(tt('命令'), commandPreview)), - STATUS_REMINDER_MAX_BODY_CHARS - ); + const commandClause = formatReminderLabelValue(tt('命令'), commandPreview); + const reservedSuffixBudget = getReminderSentenceSeparator().length + commandClause.length; + const remainingSummaryBudget = STATUS_REMINDER_MAX_BODY_CHARS - reservedSuffixBudget; + if (remainingSummaryBudget <= 0) { + return truncateNotificationText(commandClause, STATUS_REMINDER_MAX_BODY_CHARS); + } + + const summaryPreview = truncateNotificationText(summary, remainingSummaryBudget); + return appendReminderClause(summaryPreview, commandClause); } function buildWaitingUserQuestionBody( diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index 6ef5c43e..fdb6aa6d 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -131,6 +131,42 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('keeps inline angle-bracket diagnostics in failure notifications', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: 'Assertion failed: expected to equal <h1>', + }) + ); + + expect(reminder).toEqual({ + kind: 'failed', + title: 'Task failed', + body: 'Assertion failed: expected <title> to equal <h1>', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + + it('strips real inline html tags while preserving readable failure text', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: 'Failure: <strong>prod</strong><br>See <a href="https://example.com">logs</a>', + }) + ); + + expect(reminder).toEqual({ + kind: 'failed', + title: 'Task failed', + body: 'Failure: prod; See logs', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + it('sanitizes approval reminders while keeping a literal command preview', () => { const reminder = buildSessionStatusReminder( createSnapshot({ @@ -164,6 +200,31 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('keeps command previews visible when approval reasons are long', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'waiting_approval', + pendingToolApproval: { + callId: 'call-1a', + messageId: 'assistant-1a', + title: 'Need approval', + description: 'Run deployment', + command: 'npm run deploy -- --env prod', + riskLabel: 'High risk', + reason: 'Deploy production build after validating migrations, smoke tests, asset upload checks, rollout annotations, and environment-specific configuration values for the release candidate branch.', + approveLabel: 'Approve', + rejectLabel: 'Reject', + enterHint: 'Enter to approve', + escHint: 'Esc to reject', + keyboardApproveAt: 1, + }, + }) + ); + + expect(reminder?.body).toContain('Command: npm run deploy -- --env prod'); + expect(reminder?.body?.length).toBeLessThanOrEqual(220); + }); + it('keeps bracketed log prefixes that are not markdown reference links', () => { const reminder = buildSessionStatusReminder( createSnapshot({ From 558d048b661c4bb0d58f5fa4144a9fa1596d2e68 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:22:04 +0800 Subject: [PATCH 08/12] refactor(desktop): extract reminder text formatting --- .../src/services/AgentService/task/center.ts | 668 +----------------- apps/desktop/src/utils/reminderText.ts | 652 +++++++++++++++++ .../AgentService/task/center-reminder.test.ts | 31 +- 3 files changed, 686 insertions(+), 665 deletions(-) create mode 100644 apps/desktop/src/utils/reminderText.ts diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 284c51d6..9aa7a9ac 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -1,13 +1,15 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import { getMarkdown } from 'markstream-vue'; - -import { getLocale, tt } from '@/i18n'; +import { tt } from '@/i18n'; import { eventService } from '@/services/EventService'; import { AppEvent, type SessionStatusReminderPayload } from '@/services/EventService/types'; -import type { PendingToolApproval, SessionMessage } from '@/types/session'; +import { + buildWaitingApprovalBody, + buildWaitingUserQuestionBody, + summarizeLatestAssistantResponse, + summarizeNotificationText, +} from '@/utils/reminderText'; import { getSessionStatusReminderContent } from '@/utils/session'; -import { collapseWhitespace } from '@/utils/text'; import { AiError, AiErrorCode } from '../contracts/errors'; import type { ConversationRuntimeEnvironment, TurnEvent } from '../execution'; @@ -41,99 +43,6 @@ interface MutableSessionTask { } const TERMINAL_TASK_RETENTION_MS = 5 * 60 * 1000; -const STATUS_REMINDER_MAX_BODY_CHARS = 220; -const STATUS_REMINDER_MAX_COMMAND_CHARS = 160; -const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; -const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; -const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; -const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; -const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._@-]+|\\*{1,2})'; -const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( - `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` -); -const REMINDER_WINDOWS_RELATIVE_PATH_PATTERN = new RegExp( - `^(?:${REMINDER_PATH_SEGMENT_PATTERN}\\\\)+${REMINDER_PATH_SEGMENT_PATTERN}$` -); -const REMINDER_POSIX_ABSOLUTE_PATH_PATTERN = new RegExp( - `^(?:/|\\.{1,2}/|~/)(?:${REMINDER_PATH_SEGMENT_PATTERN}/)*${REMINDER_PATH_SEGMENT_PATTERN}$` -); -const REMINDER_WINDOWS_ABSOLUTE_PATH_PATTERN = new RegExp( - `^(?:[A-Za-z]:\\\\|\\.{1,2}\\\\|~\\\\)(?:${REMINDER_PATH_SEGMENT_PATTERN}\\\\)*${REMINDER_PATH_SEGMENT_PATTERN}$` -); -const REMINDER_WINDOWS_UNC_PATH_PATTERN = new RegExp( - `^\\\\\\\\${REMINDER_PATH_SEGMENT_PATTERN}(?:\\\\${REMINDER_PATH_SEGMENT_PATTERN})+$` -); - -type ReminderTextMode = 'natural' | 'command' | 'summary'; -// markstream emits standard markdown-it tokens and may also surface a custom -// inline `link` token with pre-flattened label text. -type ReminderMarkdownBaseToken = { - type: string; - tag?: string; - content?: string; - markup?: string; - children?: ReminderMarkdownToken[] | null; -}; -type ReminderMarkdownLinkToken = ReminderMarkdownBaseToken & { - type: 'link'; - text?: string; -}; -type ReminderMarkdownToken = ReminderMarkdownBaseToken | ReminderMarkdownLinkToken; -type ReminderInlineHtmlTag = { - hasAttributes: boolean; - isClosing: boolean; - isSelfClosing: boolean; - tagName: string; -}; - -const REMINDER_INLINE_LINE_BREAK_HTML_TAGS = new Set(['br', 'hr']); -const REMINDER_STRIPPABLE_INLINE_HTML_TAGS = new Set([ - 'a', - 'abbr', - 'b', - 'bdi', - 'bdo', - 'cite', - 'code', - 'data', - 'del', - 'dfn', - 'div', - 'em', - 'i', - 'ins', - 'kbd', - 'li', - 'mark', - 'p', - 'pre', - 'q', - 'rp', - 'rt', - 'ruby', - 's', - 'samp', - 'small', - 'span', - 'strong', - 'sub', - 'sup', - 'time', - 'u', - 'ul', - 'ol', - 'var', - 'wbr', -]); - -const reminderMarkdownParser = getMarkdown('touchai-reminder-markdown', { - enableContainers: false, - markdownItOptions: { - breaks: true, - }, -}); - -/** 深拷贝任务快照,确保外部订阅者无法直接修改内部状态。 */ function cloneTaskSnapshot(snapshot: SessionTaskSnapshot): SessionTaskSnapshot { return cloneTaskValue(snapshot); } @@ -160,569 +69,6 @@ function isTerminalStatus(status: SessionTaskSnapshot['status']): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } -function truncateNotificationText(value: string, maxChars: number): string { - if (value.length <= maxChars) { - return value; - } - - return `${value.slice(0, maxChars - 3).trimEnd()}...`; -} - -function isEnglishReminderLocale(): boolean { - return getLocale() === 'en-US'; -} - -function getReminderListSeparator(): string { - return isEnglishReminderLocale() ? ', ' : '、'; -} - -function getReminderClauseSeparator(): string { - return isEnglishReminderLocale() ? '; ' : ';'; -} - -function getReminderSentenceSeparator(): string { - return isEnglishReminderLocale() ? '. ' : '。'; -} - -function getReminderColonSeparator(): string { - return isEnglishReminderLocale() ? ': ' : ':'; -} - -function hasTerminalPunctuation(value: string): boolean { - return /[.!?。!?;;::]$/.test(value.trim()); -} - -/** Rehydrate backslash-escaped markdown so token parsing sees the intended text. */ -function unescapeReminderMarkdown(value: string): string { - return value.replace(REMINDER_MARKDOWN_ESCAPE_PATTERN, '$1'); -} - -/** Identify tokens that look like filesystem paths or globs rather than markdown escapes. */ -function isReminderPathLikeToken(token: string): boolean { - const core = token.replace(REMINDER_PATH_WRAPPER_TRIM_PATTERN, ''); - return ( - REMINDER_POSIX_RELATIVE_PATH_PATTERN.test(core) || - REMINDER_WINDOWS_RELATIVE_PATH_PATTERN.test(core) || - REMINDER_POSIX_ABSOLUTE_PATH_PATTERN.test(core) || - REMINDER_WINDOWS_ABSOLUTE_PATH_PATTERN.test(core) || - REMINDER_WINDOWS_UNC_PATH_PATTERN.test(core) - ); -} - -/** Escape path-like fragments so markdown emphasis markers inside file paths are preserved. */ -function protectPathLikeMarkdownToken(token: string): string { - if (!isReminderPathLikeToken(token)) { - return token; - } - - if (!/[`*_]/.test(token)) { - return token; - } - - if (/^!?\[[^\]]*]\([^)]+\)$/.test(token)) { - return token; - } - - return token.replace(/([\\`*_])/g, '\\$1'); -} - -/** Normalize reminder input before markdown parsing and protect path and glob syntax. */ -function prepareReminderMarkdownSource(value: string): string { - const normalized = value.replace(/\r\n?/g, '\n'); - const protectedPaths: string[] = []; - const withPlaceholders = normalized.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, (token) => { - if (!isReminderPathLikeToken(token)) { - return token; - } - - const placeholder = `%%TOUCHAI_REMINDER_PATH_${protectedPaths.length}%%`; - protectedPaths.push(protectPathLikeMarkdownToken(token)); - return placeholder; - }); - const unescaped = unescapeReminderMarkdown(withPlaceholders); - return protectedPaths.reduce( - (text, token, index) => text.replace(`%%TOUCHAI_REMINDER_PATH_${index}%%`, token), - unescaped - ); -} - -/** Reduce block HTML to plain text for notification-safe summaries. */ -function stripHtmlToText(value: string): string { - if (!value) { - return ''; - } - - if (typeof DOMParser !== 'undefined') { - try { - return new DOMParser().parseFromString(value, 'text/html').body.textContent ?? ''; - } catch { - // Fall back to a conservative tag strip when DOM parsing is unavailable. - } - } - - return value.replace(/<[^>]+>/g, ' '); -} - -function parseReminderInlineHtmlTag(value: string): ReminderInlineHtmlTag | null { - const match = value.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)>$/); - if (!match) { - return null; - } - - const suffix = match[3] ?? ''; - const normalizedSuffix = suffix.replace(/\/\s*$/, ''); - return { - hasAttributes: /\S/.test(normalizedSuffix), - isClosing: Boolean(match[1]), - isSelfClosing: /\/\s*$/.test(suffix), - tagName: match[2]?.toLowerCase() ?? '', - }; -} - -function hasMatchingReminderInlineClosingTag( - tokens: ReminderMarkdownToken[], - startIndex: number, - tagName: string -): boolean { - let depth = 0; - for (let index = startIndex + 1; index < tokens.length; index += 1) { - const token = tokens[index]; - if (token?.type !== 'html_inline') { - continue; - } - - const parsedTag = parseReminderInlineHtmlTag(token.content ?? ''); - if (!parsedTag || parsedTag.tagName !== tagName) { - continue; - } - - if (!parsedTag.isClosing) { - depth += 1; - continue; - } - - if (depth === 0) { - return true; - } - - depth -= 1; - } - - return false; -} - -function normalizeReminderFallbackHtml(value: string): string { - let normalized = value - .replace(/<br\s*\/?>/gi, '\n') - .replace(/<hr\s*\/?>/gi, '\n') - .replace(/<wbr\s*\/?>/gi, ''); - - for (const tagName of REMINDER_STRIPPABLE_INLINE_HTML_TAGS) { - if (REMINDER_INLINE_LINE_BREAK_HTML_TAGS.has(tagName)) { - continue; - } - - normalized = normalized.replace(new RegExp(`</?${tagName}\\b[^>]*>`, 'gi'), ''); - } - - return normalized; -} - -function normalizeReminderInlineHtmlToken( - tokenContent: string, - tokens: ReminderMarkdownToken[], - index: number, - openTags: Map<string, number> -): string { - const parsedTag = parseReminderInlineHtmlTag(tokenContent); - if (!parsedTag) { - return tokenContent; - } - - if (REMINDER_INLINE_LINE_BREAK_HTML_TAGS.has(parsedTag.tagName)) { - return '\n'; - } - - const shouldStripTag = - REMINDER_STRIPPABLE_INLINE_HTML_TAGS.has(parsedTag.tagName) || parsedTag.hasAttributes; - if (!shouldStripTag) { - return tokenContent; - } - - if (parsedTag.isClosing) { - const openCount = openTags.get(parsedTag.tagName) ?? 0; - if (openCount <= 0) { - return tokenContent; - } - - if (openCount === 1) { - openTags.delete(parsedTag.tagName); - } else { - openTags.set(parsedTag.tagName, openCount - 1); - } - return ''; - } - - if (parsedTag.isSelfClosing) { - return ''; - } - - if (!hasMatchingReminderInlineClosingTag(tokens, index, parsedTag.tagName)) { - return tokenContent; - } - - openTags.set(parsedTag.tagName, (openTags.get(parsedTag.tagName) ?? 0) + 1); - return ''; -} - -/** Keep command previews copyable by converting typographic quotes back to ASCII. */ -function normalizeCommandTypography(value: string): string { - return value.replace(/[“”]/g, '"').replace(/[‘’]/g, "'"); -} - -/** Keep approval command previews recognizable instead of markdown-cleaned. */ -function summarizeCommandPreview( - value: string | null | undefined, - maxChars: number -): string | null { - const normalized = collapseWhitespace( - normalizeCommandTypography((value ?? '').replace(/\r\n?/g, '\n')) - ); - if (!normalized) { - return null; - } - - return truncateNotificationText(normalized, maxChars); -} - -/** Flatten inline markdown tokens into readable plain text for reminder clauses. */ -function extractReminderInlineText( - tokens: ReminderMarkdownToken[] | null | undefined, - fallback: string -): string { - if (!tokens?.length) { - return fallback; - } - - let text = ''; - const openInlineHtmlTags = new Map<string, number>(); - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index]; - if (!token) { - continue; - } - - switch (token.type) { - case 'text': - case 'code_inline': - text += token.content ?? ''; - break; - case 'softbreak': - case 'hardbreak': - text += '\n'; - break; - case 'html_inline': - text += normalizeReminderInlineHtmlToken( - token.content ?? '', - tokens, - index, - openInlineHtmlTags - ); - break; - case 'link': - text += extractReminderInlineText( - token.children, - (token as ReminderMarkdownLinkToken).text ?? token.content ?? '' - ); - break; - case 'link_open': - case 'link_close': - break; - default: - if (token.children?.length) { - text += extractReminderInlineText(token.children, ''); - break; - } - - if (token.type.endsWith('_open') || token.type.endsWith('_close')) { - break; - } - - text += token.content ?? ''; - break; - } - } - - return text || fallback; -} - -/** Split normalized text into non-empty clauses for later summary joining. */ -function pushReminderClauses(target: string[], value: string): void { - for (const line of value.split('\n')) { - const clause = collapseWhitespace(line); - if (clause) { - target.push(clause); - } - } -} - -/** Provide a conservative fallback when markdown tokenization fails. */ -function fallbackReminderClauses(value: string): string[] { - const clauses: string[] = []; - const fallbackText = normalizeReminderFallbackHtml(value); - pushReminderClauses(clauses, fallbackText); - return clauses; -} - -/** Parse reminder markdown into plain-text clauses, including tables and code blocks. */ -function collectReminderClauses( - value: string, - mode: Extract<ReminderTextMode, 'natural' | 'summary'> -): string[] { - const source = prepareReminderMarkdownSource(value); - if (!source.trim()) { - return []; - } - - try { - const tokens = reminderMarkdownParser.parse(source, {}) as ReminderMarkdownToken[]; - const clauses: string[] = []; - let currentTableRow: string[] | null = null; - let insideTableCell = false; - - for (const token of tokens) { - switch (token.type) { - case 'tr_open': - currentTableRow = []; - break; - case 'tr_close': { - const row = currentTableRow?.filter(Boolean).join(getReminderListSeparator()); - if (row) { - clauses.push(row); - } - currentTableRow = null; - insideTableCell = false; - break; - } - case 'th_open': - case 'td_open': - insideTableCell = true; - break; - case 'th_close': - case 'td_close': - insideTableCell = false; - break; - case 'inline': { - const text = extractReminderInlineText(token.children, token.content ?? ''); - if (!text) { - break; - } - - if (insideTableCell && currentTableRow) { - const cell = collapseWhitespace(text); - if (cell) { - currentTableRow.push(cell); - } - break; - } - - pushReminderClauses(clauses, text); - break; - } - case 'fence': - case 'code_block': - pushReminderClauses(clauses, token.content ?? ''); - break; - case 'html_block': - pushReminderClauses(clauses, stripHtmlToText(token.content ?? '')); - break; - default: - break; - } - } - - if (mode === 'summary') { - return clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); - } - - return clauses; - } catch { - const clauses = fallbackReminderClauses(source); - return mode === 'summary' ? clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES) : clauses; - } -} - -/** Identify short fragments that can be merged into a compact summary line. */ -function isShortReminderClause(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed || hasTerminalPunctuation(trimmed)) { - return false; - } - - if (trimmed.includes(getReminderListSeparator().trim())) { - return false; - } - - return trimmed.length <= (isEnglishReminderLocale() ? 32 : 24); -} - -/** Join clauses without doubling separators after terminal punctuation. */ -function joinReminderSequence(clauses: string[], separator: string): string { - const [firstClause, ...restClauses] = clauses; - if (!firstClause) { - return ''; - } - - let result = firstClause; - for (const clause of restClauses) { - const joiner = hasTerminalPunctuation(result) ? ' ' : separator; - result = `${result}${joiner}${clause}`; - } - - return result; -} - -/** Build the final reminder sentence with locale-aware separators and summary shaping. */ -function joinReminderClauses(clauses: string[], mode: ReminderTextMode): string { - const uniqueClauses: string[] = []; - for (const clause of clauses) { - if (uniqueClauses[uniqueClauses.length - 1] === clause) { - continue; - } - uniqueClauses.push(clause); - } - - if (uniqueClauses.length === 0) { - return ''; - } - - const [firstClause, ...restClauses] = uniqueClauses; - if (!firstClause) { - return ''; - } - - if (restClauses.length === 0) { - return firstClause; - } - - const useTitledSummary = - mode === 'summary' && - !hasTerminalPunctuation(firstClause) && - firstClause.length <= (isEnglishReminderLocale() ? 60 : 40); - const separator = - mode === 'summary' && restClauses.every((clause) => isShortReminderClause(clause)) - ? getReminderListSeparator() - : getReminderClauseSeparator(); - const restText = joinReminderSequence(restClauses, separator); - - if (!useTitledSummary) { - return joinReminderSequence(uniqueClauses, separator); - } - - return `${firstClause}${getReminderColonSeparator()}${restText}`; -} - -/** Append an extra reminder fragment while keeping the sentence readable. */ -function appendReminderClause(base: string, clause: string | null): string { - if (!clause) { - return base; - } - - if (!base) { - return clause; - } - - const separator = hasTerminalPunctuation(base) ? ' ' : getReminderSentenceSeparator(); - return `${base}${separator}${clause}`; -} - -/** Format a labeled reminder fragment such as a command preview. */ -function formatReminderLabelValue(label: string, value: string): string { - return `${label}${getReminderColonSeparator()}${value}`; -} - -/** Convert markdown-rich content into a notification-ready plain-text summary. */ -function summarizeNotificationText( - value: string | null | undefined, - maxChars = STATUS_REMINDER_MAX_BODY_CHARS, - mode: ReminderTextMode = 'natural' -) { - if (mode === 'command') { - return summarizeCommandPreview(value, maxChars); - } - - const normalized = joinReminderClauses(collectReminderClauses(value ?? '', mode), mode); - if (!normalized) { - return null; - } - - return truncateNotificationText(normalized, maxChars); -} - -/** 从会话历史中提取最后一条 assistant 消息的摘要。 */ -function summarizeLatestAssistantResponse(history: SessionMessage[]): string | null { - for (let index = history.length - 1; index >= 0; index -= 1) { - const message = history[index]; - if (message?.role !== 'assistant') { - continue; - } - - const summary = summarizeNotificationText( - message.content, - STATUS_REMINDER_MAX_BODY_CHARS, - 'summary' - ); - if (summary) { - return summary; - } - } - - return null; -} - -/** 为等待审批状态构建通知正文,包含摘要和命令预览。 */ -function buildWaitingApprovalBody(approval: PendingToolApproval): string { - const summary = - summarizeNotificationText(approval.reason) ?? - summarizeNotificationText(approval.description) ?? - summarizeNotificationText(approval.title) ?? - getSessionStatusReminderContent('waiting_approval'); - const commandPreview = summarizeNotificationText( - approval.command, - STATUS_REMINDER_MAX_COMMAND_CHARS, - 'command' - ); - - if (!commandPreview || commandPreview === summary) { - return summary; - } - - const commandClause = formatReminderLabelValue(tt('命令'), commandPreview); - const reservedSuffixBudget = getReminderSentenceSeparator().length + commandClause.length; - const remainingSummaryBudget = STATUS_REMINDER_MAX_BODY_CHARS - reservedSuffixBudget; - if (remainingSummaryBudget <= 0) { - return truncateNotificationText(commandClause, STATUS_REMINDER_MAX_BODY_CHARS); - } - - const summaryPreview = truncateNotificationText(summary, remainingSummaryBudget); - return appendReminderClause(summaryPreview, commandClause); -} - -function buildWaitingUserQuestionBody( - question: NonNullable<SessionTaskSnapshot['pendingUserQuestion']> -): string { - const summary = summarizeNotificationText(question.questions[0]?.question); - if (summary) { - return summary; - } - - return tt('任务正在等待用户回复'); -} - -/** - * 根据任务快照构建状态提醒负载。 - * 仅在 completed、failed、waiting_approval 三种状态下生成提醒,其余返回 null。 - */ export function buildSessionStatusReminder( snapshot: SessionTaskSnapshot ): SessionStatusReminderPayload | null { diff --git a/apps/desktop/src/utils/reminderText.ts b/apps/desktop/src/utils/reminderText.ts new file mode 100644 index 00000000..3ea837ea --- /dev/null +++ b/apps/desktop/src/utils/reminderText.ts @@ -0,0 +1,652 @@ +import { getMarkdown } from 'markstream-vue'; + +import { getLocale, tt } from '@/i18n'; +import type { SessionTaskSnapshot } from '@/services/AgentService/task/types'; +import type { PendingToolApproval, SessionMessage } from '@/types/session'; +import { getSessionStatusReminderContent } from '@/utils/session'; +import { collapseWhitespace } from '@/utils/text'; + +const STATUS_REMINDER_MAX_BODY_CHARS = 220; +const STATUS_REMINDER_MAX_COMMAND_CHARS = 160; +const STATUS_REMINDER_MAX_SUMMARY_LINES = 4; +const REMINDER_MARKDOWN_SCAN_MAX_CHARS = 4096; +const REMINDER_SUMMARY_FIRST_CLAUSE_MAX_CHARS_EN = 60; +const REMINDER_SUMMARY_FIRST_CLAUSE_MAX_CHARS_DEFAULT = 40; +const REMINDER_SHORT_CLAUSE_MAX_CHARS_EN = 32; +const REMINDER_SHORT_CLAUSE_MAX_CHARS_DEFAULT = 24; + +const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; +const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; +const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; +const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._@-]+|\\*{1,2})'; +const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( + `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_WINDOWS_RELATIVE_PATH_PATTERN = new RegExp( + `^(?:${REMINDER_PATH_SEGMENT_PATTERN}\\\\)+${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_POSIX_ABSOLUTE_PATH_PATTERN = new RegExp( + `^(?:/|\\.{1,2}/|~/)(?:${REMINDER_PATH_SEGMENT_PATTERN}/)*${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_WINDOWS_ABSOLUTE_PATH_PATTERN = new RegExp( + `^(?:[A-Za-z]:\\\\|\\.{1,2}\\\\|~\\\\)(?:${REMINDER_PATH_SEGMENT_PATTERN}\\\\)*${REMINDER_PATH_SEGMENT_PATTERN}$` +); +const REMINDER_WINDOWS_UNC_PATH_PATTERN = new RegExp( + `^\\\\\\\\${REMINDER_PATH_SEGMENT_PATTERN}(?:\\\\${REMINDER_PATH_SEGMENT_PATTERN})+$` +); +const REMINDER_COMMAND_FENCE_PATTERN = /^\s*(```|~~~)[^\r\n`~]*\r?\n([\s\S]*?)\r?\n\1\s*$/; + +type ReminderTextMode = 'natural' | 'command' | 'summary'; + +type ReminderMarkdownBaseToken = { + type: string; + content?: string; + children?: ReminderMarkdownToken[] | null; +}; + +type ReminderMarkdownLinkToken = ReminderMarkdownBaseToken & { + type: 'link'; + text?: string; +}; + +type ReminderMarkdownToken = ReminderMarkdownBaseToken | ReminderMarkdownLinkToken; + +type ReminderInlineHtmlTag = { + hasAttributes: boolean; + isClosing: boolean; + isSelfClosing: boolean; + tagName: string; +}; + +const REMINDER_INLINE_LINE_BREAK_HTML_TAGS = new Set(['br', 'hr']); +const REMINDER_INLINE_ZERO_WIDTH_HTML_TAGS = new Set(['wbr']); + +const reminderMarkdownParser = getMarkdown('touchai-reminder-markdown', { + enableContainers: false, + markdownItOptions: { + breaks: true, + }, +}); + +function truncateNotificationText(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + + return `${value.slice(0, maxChars - 3).trimEnd()}...`; +} + +function isEnglishReminderLocale(): boolean { + return /^en(?:-|$)/i.test(getLocale()); +} + +function getReminderListSeparator(): string { + return isEnglishReminderLocale() ? ', ' : '\u3001'; +} + +function getReminderClauseSeparator(): string { + return isEnglishReminderLocale() ? '; ' : '\uFF1B'; +} + +function getReminderSentenceSeparator(): string { + return isEnglishReminderLocale() ? '. ' : '\u3002'; +} + +function getReminderColonSeparator(): string { + return isEnglishReminderLocale() ? ': ' : '\uFF1A'; +} + +function getReminderSummaryFirstClauseMaxChars(): number { + return isEnglishReminderLocale() + ? REMINDER_SUMMARY_FIRST_CLAUSE_MAX_CHARS_EN + : REMINDER_SUMMARY_FIRST_CLAUSE_MAX_CHARS_DEFAULT; +} + +function getReminderShortClauseMaxChars(): number { + return isEnglishReminderLocale() + ? REMINDER_SHORT_CLAUSE_MAX_CHARS_EN + : REMINDER_SHORT_CLAUSE_MAX_CHARS_DEFAULT; +} + +/** Detect sentence-ending punctuation so reminder clauses can join naturally. */ +function hasTerminalPunctuation(value: string): boolean { + return /[.!?\u3002\uFF01\uFF1F;:\uFF1B\uFF1A]$/.test(value.trim()); +} + +function limitReminderMarkdownSource(value: string): string { + return value.length <= REMINDER_MARKDOWN_SCAN_MAX_CHARS + ? value + : value.slice(0, REMINDER_MARKDOWN_SCAN_MAX_CHARS); +} + +function unescapeReminderMarkdown(value: string): string { + return value.replace(REMINDER_MARKDOWN_ESCAPE_PATTERN, '$1'); +} + +function isReminderPathLikeToken(token: string): boolean { + const core = token.replace(REMINDER_PATH_WRAPPER_TRIM_PATTERN, ''); + return ( + REMINDER_POSIX_RELATIVE_PATH_PATTERN.test(core) || + REMINDER_WINDOWS_RELATIVE_PATH_PATTERN.test(core) || + REMINDER_POSIX_ABSOLUTE_PATH_PATTERN.test(core) || + REMINDER_WINDOWS_ABSOLUTE_PATH_PATTERN.test(core) || + REMINDER_WINDOWS_UNC_PATH_PATTERN.test(core) + ); +} + +function protectPathLikeMarkdownToken(token: string): string { + if (!isReminderPathLikeToken(token)) { + return token; + } + + if (!/[`*_]/.test(token)) { + return token; + } + + if (/^!?\[[^\]]*]\([^)]+\)$/.test(token)) { + return token; + } + + return token.replace(/([\\`*_])/g, '\\$1'); +} + +/** + * Bound regex work to a small prefix and preserve path-like markdown fragments + * so emphasis markers inside file paths survive plain-text conversion. + */ +function prepareReminderMarkdownSource(value: string): string { + const normalized = limitReminderMarkdownSource(value.replace(/\r\n?/g, '\n')); + const protectedPaths: string[] = []; + const withPlaceholders = normalized.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, (token) => { + if (!isReminderPathLikeToken(token)) { + return token; + } + + const placeholder = `%%TOUCHAI_REMINDER_PATH_${protectedPaths.length}%%`; + protectedPaths.push(protectPathLikeMarkdownToken(token)); + return placeholder; + }); + const unescaped = unescapeReminderMarkdown(withPlaceholders); + return protectedPaths.reduce( + (text, token, index) => text.replace(`%%TOUCHAI_REMINDER_PATH_${index}%%`, token), + unescaped + ); +} + +/** Keep fallback HTML cleanup conservative so diagnostics like `<title>` survive. */ +function stripHtmlToText(value: string): string { + if (!value) { + return ''; + } + + if (typeof DOMParser !== 'undefined') { + try { + return new DOMParser().parseFromString(value, 'text/html').body.textContent ?? ''; + } catch { + // Fall back to a conservative tag strip when DOM parsing is unavailable. + } + } + + return value.replace(/<[^>]+>/g, ' '); +} + +/** Parse a single markdown-it html_inline token into tag metadata when it is real tag syntax. */ +function parseReminderInlineHtmlTag(value: string): ReminderInlineHtmlTag | null { + const match = value.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)>$/); + if (!match) { + return null; + } + + const suffix = match[3] ?? ''; + const normalizedSuffix = suffix.replace(/\/\s*$/, ''); + return { + hasAttributes: /\S/.test(normalizedSuffix), + isClosing: Boolean(match[1]), + isSelfClosing: /\/\s*$/.test(suffix), + tagName: match[2]?.toLowerCase() ?? '', + }; +} + +/** + * Reverse-scan inline HTML tokens once so opening tags can be stripped in O(n) + * when a matching closing tag appears later in the same inline fragment. + */ +function collectPairedReminderInlineHtmlOpenings(tokens: ReminderMarkdownToken[]): Set<number> { + const pairedOpeningIndexes = new Set<number>(); + const closingCounts = new Map<string, number>(); + + for (let index = tokens.length - 1; index >= 0; index -= 1) { + const token = tokens[index]; + if (token?.type !== 'html_inline') { + continue; + } + + const parsedTag = parseReminderInlineHtmlTag(token.content ?? ''); + if (!parsedTag || parsedTag.isSelfClosing) { + continue; + } + + if (parsedTag.isClosing) { + closingCounts.set(parsedTag.tagName, (closingCounts.get(parsedTag.tagName) ?? 0) + 1); + continue; + } + + const closingCount = closingCounts.get(parsedTag.tagName) ?? 0; + if (closingCount <= 0) { + continue; + } + + pairedOpeningIndexes.add(index); + if (closingCount === 1) { + closingCounts.delete(parsedTag.tagName); + } else { + closingCounts.set(parsedTag.tagName, closingCount - 1); + } + } + + return pairedOpeningIndexes; +} + +/** Strip only obvious HTML markup in the fallback path and keep literal angle-bracket text. */ +function normalizeReminderFallbackHtml(value: string): string { + let normalized = value + .replace(/<br\s*\/?>/gi, '\n') + .replace(/<hr\s*\/?>/gi, '\n') + .replace(/<wbr\s*\/?>/gi, ''); + + normalized = normalized.replace(/<([A-Za-z][A-Za-z0-9:-]*)\s*>(?=[\s\S]*<\/\1\s*>)/g, ''); + normalized = normalized.replace(/<\/[A-Za-z][A-Za-z0-9:-]*\s*>/g, ''); + normalized = normalized.replace(/<[A-Za-z][A-Za-z0-9:-]*\b[^>]*\/>/g, ''); + normalized = normalized.replace(/<[A-Za-z][A-Za-z0-9:-]*\b[^>]*\s+[^>]*>/g, ''); + + return normalized; +} + +function normalizeReminderInlineHtmlToken( + tokenContent: string, + index: number, + pairedOpeningIndexes: Set<number>, + openTags: Map<string, number> +): string { + const parsedTag = parseReminderInlineHtmlTag(tokenContent); + if (!parsedTag) { + return tokenContent; + } + + if (REMINDER_INLINE_LINE_BREAK_HTML_TAGS.has(parsedTag.tagName)) { + return '\n'; + } + + if (REMINDER_INLINE_ZERO_WIDTH_HTML_TAGS.has(parsedTag.tagName)) { + return ''; + } + + if (parsedTag.isClosing) { + const openCount = openTags.get(parsedTag.tagName) ?? 0; + if (openCount <= 0) { + return tokenContent; + } + + if (openCount === 1) { + openTags.delete(parsedTag.tagName); + } else { + openTags.set(parsedTag.tagName, openCount - 1); + } + return ''; + } + + if (parsedTag.isSelfClosing) { + return parsedTag.hasAttributes ? '' : tokenContent; + } + + const shouldStripOpening = parsedTag.hasAttributes || pairedOpeningIndexes.has(index); + if (!shouldStripOpening) { + return tokenContent; + } + + openTags.set(parsedTag.tagName, (openTags.get(parsedTag.tagName) ?? 0) + 1); + return ''; +} + +function normalizeCommandTypography(value: string): string { + return value.replace(/[\u201c\u201d]/g, '"').replace(/[\u2018\u2019]/g, "'"); +} + +function unwrapReminderCommandFence(value: string): string { + const match = value.match(REMINDER_COMMAND_FENCE_PATTERN); + return match?.[2] ?? value; +} + +function summarizeCommandPreview( + value: string | null | undefined, + maxChars: number +): string | null { + const normalized = collapseWhitespace( + normalizeCommandTypography( + unwrapReminderCommandFence((value ?? '').replace(/\r\n?/g, '\n')) + ) + ); + if (!normalized) { + return null; + } + + return truncateNotificationText(normalized, maxChars); +} + +function extractReminderInlineText( + tokens: ReminderMarkdownToken[] | null | undefined, + fallback: string +): string { + if (!tokens?.length) { + return fallback; + } + + let text = ''; + const openInlineHtmlTags = new Map<string, number>(); + const pairedOpeningIndexes = collectPairedReminderInlineHtmlOpenings(tokens); + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + + switch (token.type) { + case 'text': + case 'code_inline': + text += token.content ?? ''; + break; + case 'softbreak': + case 'hardbreak': + text += '\n'; + break; + case 'html_inline': + text += normalizeReminderInlineHtmlToken( + token.content ?? '', + index, + pairedOpeningIndexes, + openInlineHtmlTags + ); + break; + case 'link': + text += extractReminderInlineText( + token.children, + (token as ReminderMarkdownLinkToken).text ?? token.content ?? '' + ); + break; + case 'link_open': + case 'link_close': + break; + default: + if (token.children?.length) { + text += extractReminderInlineText(token.children, ''); + break; + } + + if (token.type.endsWith('_open') || token.type.endsWith('_close')) { + break; + } + + text += token.content ?? ''; + break; + } + } + + return text || fallback; +} + +function pushReminderClauses(target: string[], value: string): void { + for (const line of value.split('\n')) { + const clause = collapseWhitespace(line); + if (clause) { + target.push(clause); + } + } +} + +function fallbackReminderClauses(value: string): string[] { + const clauses: string[] = []; + pushReminderClauses(clauses, normalizeReminderFallbackHtml(value)); + return clauses; +} + +function collectReminderClauses( + value: string, + mode: Extract<ReminderTextMode, 'natural' | 'summary'> +): string[] { + const source = prepareReminderMarkdownSource(value); + if (!source.trim()) { + return []; + } + + try { + const tokens = reminderMarkdownParser.parse(source, {}) as ReminderMarkdownToken[]; + const clauses: string[] = []; + let currentTableRow: string[] | null = null; + let insideTableCell = false; + + for (const token of tokens) { + switch (token.type) { + case 'tr_open': + currentTableRow = []; + break; + case 'tr_close': { + const row = currentTableRow?.filter(Boolean).join(getReminderListSeparator()); + if (row) { + clauses.push(row); + } + currentTableRow = null; + insideTableCell = false; + break; + } + case 'th_open': + case 'td_open': + insideTableCell = true; + break; + case 'th_close': + case 'td_close': + insideTableCell = false; + break; + case 'inline': { + const text = extractReminderInlineText(token.children, token.content ?? ''); + if (!text) { + break; + } + + if (insideTableCell && currentTableRow) { + const cell = collapseWhitespace(text); + if (cell) { + currentTableRow.push(cell); + } + break; + } + + pushReminderClauses(clauses, text); + break; + } + case 'fence': + case 'code_block': + pushReminderClauses(clauses, token.content ?? ''); + break; + case 'html_block': + pushReminderClauses(clauses, stripHtmlToText(token.content ?? '')); + break; + default: + break; + } + } + + if (mode === 'summary') { + return clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES); + } + + return clauses; + } catch { + const clauses = fallbackReminderClauses(source); + return mode === 'summary' ? clauses.slice(0, STATUS_REMINDER_MAX_SUMMARY_LINES) : clauses; + } +} + +/** Short clauses can be rendered with a lighter separator for more compact summaries. */ +function isShortReminderClause(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || hasTerminalPunctuation(trimmed)) { + return false; + } + + if (trimmed.includes(getReminderListSeparator().trim())) { + return false; + } + + return trimmed.length <= getReminderShortClauseMaxChars(); +} + +/** Reuse sentence punctuation instead of doubling separators after completed clauses. */ +function joinReminderSequence(clauses: string[], separator: string): string { + const [firstClause, ...restClauses] = clauses; + if (!firstClause) { + return ''; + } + + let result = firstClause; + for (const clause of restClauses) { + const joiner = hasTerminalPunctuation(result) ? ' ' : separator; + result = `${result}${joiner}${clause}`; + } + + return result; +} + +function joinReminderClauses(clauses: string[], mode: ReminderTextMode): string { + const uniqueClauses: string[] = []; + for (const clause of clauses) { + // Only collapse adjacent duplicates so repeated later details still survive intact. + if (uniqueClauses[uniqueClauses.length - 1] === clause) { + continue; + } + uniqueClauses.push(clause); + } + + if (uniqueClauses.length === 0) { + return ''; + } + + const [firstClause, ...restClauses] = uniqueClauses; + if (!firstClause) { + return ''; + } + + if (restClauses.length === 0) { + return firstClause; + } + + const useTitledSummary = + mode === 'summary' && + !hasTerminalPunctuation(firstClause) && + firstClause.length <= getReminderSummaryFirstClauseMaxChars(); + const separator = + mode === 'summary' && restClauses.every((clause) => isShortReminderClause(clause)) + ? getReminderListSeparator() + : getReminderClauseSeparator(); + const restText = joinReminderSequence(restClauses, separator); + + if (!useTitledSummary) { + return joinReminderSequence(uniqueClauses, separator); + } + + return `${firstClause}${getReminderColonSeparator()}${restText}`; +} + +function appendReminderClause(base: string, clause: string | null): string { + if (!clause) { + return base; + } + + if (!base) { + return clause; + } + + const separator = hasTerminalPunctuation(base) ? ' ' : getReminderSentenceSeparator(); + return `${base}${separator}${clause}`; +} + +function formatReminderLabelValue(label: string, value: string): string { + return `${label}${getReminderColonSeparator()}${value}`; +} + +export function summarizeNotificationText( + value: string | null | undefined, + maxChars = STATUS_REMINDER_MAX_BODY_CHARS, + mode: ReminderTextMode = 'natural' +): string | null { + if (mode === 'command') { + return summarizeCommandPreview(value, maxChars); + } + + const normalized = joinReminderClauses(collectReminderClauses(value ?? '', mode), mode); + if (!normalized) { + return null; + } + + return truncateNotificationText(normalized, maxChars); +} + +export function summarizeLatestAssistantResponse(history: SessionMessage[]): string | null { + for (let index = history.length - 1; index >= 0; index -= 1) { + const message = history[index]; + if (message?.role !== 'assistant') { + continue; + } + + const summary = summarizeNotificationText( + message.content, + STATUS_REMINDER_MAX_BODY_CHARS, + 'summary' + ); + if (summary) { + return summary; + } + } + + return null; +} + +/** Reserve notification budget for the command preview so approval context stays visible. */ +export function buildWaitingApprovalBody(approval: PendingToolApproval): string { + const summary = + summarizeNotificationText(approval.reason) ?? + summarizeNotificationText(approval.description) ?? + summarizeNotificationText(approval.title) ?? + getSessionStatusReminderContent('waiting_approval'); + const commandPreview = summarizeNotificationText( + approval.command, + STATUS_REMINDER_MAX_COMMAND_CHARS, + 'command' + ); + + if (!commandPreview || commandPreview === summary) { + return summary; + } + + const commandClause = formatReminderLabelValue(tt('命令'), commandPreview); + const reservedSuffixBudget = getReminderSentenceSeparator().length + commandClause.length; + const remainingSummaryBudget = STATUS_REMINDER_MAX_BODY_CHARS - reservedSuffixBudget; + if (remainingSummaryBudget <= 0) { + return truncateNotificationText(commandClause, STATUS_REMINDER_MAX_BODY_CHARS); + } + + const summaryPreview = truncateNotificationText(summary, remainingSummaryBudget); + return appendReminderClause(summaryPreview, commandClause); +} + +/** Use the first pending question as the waiting reminder, with locale-aware fallback text. */ +export function buildWaitingUserQuestionBody( + question: NonNullable<SessionTaskSnapshot['pendingUserQuestion']> +): string { + const summary = summarizeNotificationText(question.questions[0]?.question); + if (summary) { + return summary; + } + + return tt('任务正在等待用户回复'); +} diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index fdb6aa6d..db031620 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setLocale } from '@/i18n'; +import * as i18n from '@/i18n'; import { buildSessionStatusReminder } from '@/services/AgentService/task/center'; import type { SessionTaskSnapshot } from '@/services/AgentService/task/types'; @@ -29,7 +29,30 @@ function createSnapshot(overrides: Partial<SessionTaskSnapshot> = {}): SessionTa describe('SessionTaskCenter status reminders', () => { beforeEach(() => { - setLocale('en-US'); + vi.restoreAllMocks(); + i18n.setLocale('en-US'); + }); + + it('treats non-US english locales as english for reminder separators', () => { + vi.spyOn(i18n, 'getLocale').mockReturnValue('en-GB'); + + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'completed', + sessionHistory: [ + { + id: 'assistant-en-gb', + role: 'assistant', + content: + '| Step | Result |\n| --- | --- |\n| lint | passed |\n| test | passed |', + parts: [], + timestamp: 1, + }, + ], + }) + ); + + expect(reminder?.body).toBe('Step, Result: lint, passed; test, passed'); }); it('creates an open reminder when a background task waits for a user question', () => { @@ -191,7 +214,7 @@ describe('SessionTaskCenter status reminders', () => { expect(reminder).toEqual({ kind: 'waiting_approval', title: 'Pending', - body: 'Deploy production build. Command: ```bash npm run deploy -- --env prod ```', + body: 'Deploy production build. Command: npm run deploy -- --env prod', approval: { callId: 'call-1', approveLabel: 'Approve', From b22a310f6c75de670e6650ff329770a345d78547 Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:24:19 +0800 Subject: [PATCH 09/12] fix(desktop): strip dangerous reminder html blocks --- apps/desktop/src/utils/reminderText.ts | 22 +++++++++++++++---- .../AgentService/task/center-reminder.test.ts | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/utils/reminderText.ts b/apps/desktop/src/utils/reminderText.ts index 3ea837ea..7bedb228 100644 --- a/apps/desktop/src/utils/reminderText.ts +++ b/apps/desktop/src/utils/reminderText.ts @@ -18,6 +18,8 @@ const REMINDER_SHORT_CLAUSE_MAX_CHARS_DEFAULT = 24; const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; +const REMINDER_DANGEROUS_HTML_BLOCK_PATTERN = + /<\s*(script|style|iframe|object|embed|template)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi; const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._@-]+|\\*{1,2})'; const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` @@ -123,6 +125,14 @@ function unescapeReminderMarkdown(value: string): string { return value.replace(REMINDER_MARKDOWN_ESCAPE_PATTERN, '$1'); } +/** + * Notifications are rendered as plain text today, but paired script-like HTML + * blocks still have no value in reminder summaries and are safer to remove. + */ +function stripDangerousReminderHtmlBlocks(value: string): string { + return value.replace(REMINDER_DANGEROUS_HTML_BLOCK_PATTERN, ' '); +} + function isReminderPathLikeToken(token: string): boolean { const core = token.replace(REMINDER_PATH_WRAPPER_TRIM_PATTERN, ''); return ( @@ -155,7 +165,9 @@ function protectPathLikeMarkdownToken(token: string): string { * so emphasis markers inside file paths survive plain-text conversion. */ function prepareReminderMarkdownSource(value: string): string { - const normalized = limitReminderMarkdownSource(value.replace(/\r\n?/g, '\n')); + const normalized = limitReminderMarkdownSource( + stripDangerousReminderHtmlBlocks(value.replace(/\r\n?/g, '\n')) + ); const protectedPaths: string[] = []; const withPlaceholders = normalized.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, (token) => { if (!isReminderPathLikeToken(token)) { @@ -179,15 +191,17 @@ function stripHtmlToText(value: string): string { return ''; } + const sanitized = stripDangerousReminderHtmlBlocks(value); + if (typeof DOMParser !== 'undefined') { try { - return new DOMParser().parseFromString(value, 'text/html').body.textContent ?? ''; + return new DOMParser().parseFromString(sanitized, 'text/html').body.textContent ?? ''; } catch { // Fall back to a conservative tag strip when DOM parsing is unavailable. } } - return value.replace(/<[^>]+>/g, ' '); + return sanitized.replace(/<[^>]+>/g, ' '); } /** Parse a single markdown-it html_inline token into tag metadata when it is real tag syntax. */ @@ -249,7 +263,7 @@ function collectPairedReminderInlineHtmlOpenings(tokens: ReminderMarkdownToken[] /** Strip only obvious HTML markup in the fallback path and keep literal angle-bracket text. */ function normalizeReminderFallbackHtml(value: string): string { - let normalized = value + let normalized = stripDangerousReminderHtmlBlocks(value) .replace(/<br\s*\/?>/gi, '\n') .replace(/<hr\s*\/?>/gi, '\n') .replace(/<wbr\s*\/?>/gi, ''); diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index db031620..f02e0dc6 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -190,6 +190,24 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('drops script-like html blocks from failure notifications', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: 'Failure<script>alert(1)</script><style>body{display:none}</style>See logs', + }) + ); + + expect(reminder).toEqual({ + kind: 'failed', + title: 'Task failed', + body: 'Failure See logs', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + it('sanitizes approval reminders while keeping a literal command preview', () => { const reminder = buildSessionStatusReminder( createSnapshot({ From 6a655c4ec97bfa1a612e17b7072e79de1549c7ec Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:51:42 +0800 Subject: [PATCH 10/12] fix(desktop): strip standalone dangerous reminder tags --- apps/desktop/src/utils/reminderText.ts | 31 ++++++++++++++----- .../AgentService/task/center-reminder.test.ts | 18 +++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/utils/reminderText.ts b/apps/desktop/src/utils/reminderText.ts index 7bedb228..4f9dd4fa 100644 --- a/apps/desktop/src/utils/reminderText.ts +++ b/apps/desktop/src/utils/reminderText.ts @@ -18,8 +18,18 @@ const REMINDER_SHORT_CLAUSE_MAX_CHARS_DEFAULT = 24; const REMINDER_MARKDOWN_ESCAPE_PATTERN = /\\([\\`*_{}[\]()#+.!>-])/g; const REMINDER_PATH_LIKE_TOKEN_PATTERN = /\S*[\\/]\S*/g; const REMINDER_PATH_WRAPPER_TRIM_PATTERN = /^[("'[{]+|[)"'\],.;:!?}]+$/g; +const REMINDER_DANGEROUS_HTML_TAG_NAMES = [ + 'script', + 'style', + 'iframe', + 'object', + 'embed', + 'template', +]; const REMINDER_DANGEROUS_HTML_BLOCK_PATTERN = /<\s*(script|style|iframe|object|embed|template)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi; +const REMINDER_DANGEROUS_HTML_TAG_PATTERN = + /<\s*(?:\/\s*)?(script|style|iframe|object|embed|template)\b[^>]*>/gi; const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._@-]+|\\*{1,2})'; const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` @@ -61,6 +71,7 @@ type ReminderInlineHtmlTag = { }; const REMINDER_INLINE_LINE_BREAK_HTML_TAGS = new Set(['br', 'hr']); +const REMINDER_DANGEROUS_INLINE_HTML_TAGS = new Set(REMINDER_DANGEROUS_HTML_TAG_NAMES); const REMINDER_INLINE_ZERO_WIDTH_HTML_TAGS = new Set(['wbr']); const reminderMarkdownParser = getMarkdown('touchai-reminder-markdown', { @@ -126,11 +137,13 @@ function unescapeReminderMarkdown(value: string): string { } /** - * Notifications are rendered as plain text today, but paired script-like HTML - * blocks still have no value in reminder summaries and are safer to remove. + * Notifications are rendered as plain text today, but script-like HTML + * fragments still have no value in reminder summaries and are safer to remove. */ -function stripDangerousReminderHtmlBlocks(value: string): string { - return value.replace(REMINDER_DANGEROUS_HTML_BLOCK_PATTERN, ' '); +function stripDangerousReminderHtml(value: string): string { + return value + .replace(REMINDER_DANGEROUS_HTML_BLOCK_PATTERN, ' ') + .replace(REMINDER_DANGEROUS_HTML_TAG_PATTERN, ' '); } function isReminderPathLikeToken(token: string): boolean { @@ -166,7 +179,7 @@ function protectPathLikeMarkdownToken(token: string): string { */ function prepareReminderMarkdownSource(value: string): string { const normalized = limitReminderMarkdownSource( - stripDangerousReminderHtmlBlocks(value.replace(/\r\n?/g, '\n')) + stripDangerousReminderHtml(value.replace(/\r\n?/g, '\n')) ); const protectedPaths: string[] = []; const withPlaceholders = normalized.replace(REMINDER_PATH_LIKE_TOKEN_PATTERN, (token) => { @@ -191,7 +204,7 @@ function stripHtmlToText(value: string): string { return ''; } - const sanitized = stripDangerousReminderHtmlBlocks(value); + const sanitized = stripDangerousReminderHtml(value); if (typeof DOMParser !== 'undefined') { try { @@ -263,7 +276,7 @@ function collectPairedReminderInlineHtmlOpenings(tokens: ReminderMarkdownToken[] /** Strip only obvious HTML markup in the fallback path and keep literal angle-bracket text. */ function normalizeReminderFallbackHtml(value: string): string { - let normalized = stripDangerousReminderHtmlBlocks(value) + let normalized = stripDangerousReminderHtml(value) .replace(/<br\s*\/?>/gi, '\n') .replace(/<hr\s*\/?>/gi, '\n') .replace(/<wbr\s*\/?>/gi, ''); @@ -295,6 +308,10 @@ function normalizeReminderInlineHtmlToken( return ''; } + if (REMINDER_DANGEROUS_INLINE_HTML_TAGS.has(parsedTag.tagName)) { + return ''; + } + if (parsedTag.isClosing) { const openCount = openTags.get(parsedTag.tagName) ?? 0; if (openCount <= 0) { diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index f02e0dc6..e4a5e76b 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -208,6 +208,24 @@ describe('SessionTaskCenter status reminders', () => { }); }); + it('drops standalone dangerous html tags from failure notifications', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: 'Failure<script>alert(1)</script></script><iframe>See logs', + }) + ); + + expect(reminder).toEqual({ + kind: 'failed', + title: 'Task failed', + body: 'Failure See logs', + approval: null, + replyPlaceholder: 'Reply to TouchAI', + replyLabel: 'Reply', + }); + }); + it('sanitizes approval reminders while keeping a literal command preview', () => { const reminder = buildSessionStatusReminder( createSnapshot({ From c73b741b9a24b09b4a4f448b35abf0a97e0b753f Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:02:33 +0800 Subject: [PATCH 11/12] test(desktop): type mock non-us english locale --- .../tests/services/AgentService/task/center-reminder.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts index e4a5e76b..894d07df 100644 --- a/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts +++ b/apps/desktop/tests/services/AgentService/task/center-reminder.test.ts @@ -34,7 +34,9 @@ describe('SessionTaskCenter status reminders', () => { }); it('treats non-US english locales as english for reminder separators', () => { - vi.spyOn(i18n, 'getLocale').mockReturnValue('en-GB'); + vi.spyOn(i18n, 'getLocale').mockImplementation( + () => 'en-GB' as ReturnType<typeof i18n.getLocale> + ); const reminder = buildSessionStatusReminder( createSnapshot({ From 04a3f375c724725afccd421a6be9b472afc0425a Mon Sep 17 00:00:00 2001 From: snowjuly <165046762+snowjuly@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:24:53 +0800 Subject: [PATCH 12/12] fix(desktop): avoid regex html sanitization --- apps/desktop/src/utils/reminderText.ts | 269 +++++++++++++++++++++++-- 1 file changed, 249 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/utils/reminderText.ts b/apps/desktop/src/utils/reminderText.ts index 4f9dd4fa..996ccd25 100644 --- a/apps/desktop/src/utils/reminderText.ts +++ b/apps/desktop/src/utils/reminderText.ts @@ -26,10 +26,6 @@ const REMINDER_DANGEROUS_HTML_TAG_NAMES = [ 'embed', 'template', ]; -const REMINDER_DANGEROUS_HTML_BLOCK_PATTERN = - /<\s*(script|style|iframe|object|embed|template)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi; -const REMINDER_DANGEROUS_HTML_TAG_PATTERN = - /<\s*(?:\/\s*)?(script|style|iframe|object|embed|template)\b[^>]*>/gi; const REMINDER_PATH_SEGMENT_PATTERN = '(?:[A-Za-z0-9._@-]+|\\*{1,2})'; const REMINDER_POSIX_RELATIVE_PATH_PATTERN = new RegExp( `^(?:${REMINDER_PATH_SEGMENT_PATTERN}/)+${REMINDER_PATH_SEGMENT_PATTERN}$` @@ -70,6 +66,19 @@ type ReminderInlineHtmlTag = { tagName: string; }; +type ReminderCompleteHtmlTag = { + endIndex: number; + raw: string; +}; + +type ReminderDangerousHtmlTag = { + endIndex: number; + isClosing: boolean; + isComplete: boolean; + isSelfClosing: boolean; + tagName: string; +}; + const REMINDER_INLINE_LINE_BREAK_HTML_TAGS = new Set(['br', 'hr']); const REMINDER_DANGEROUS_INLINE_HTML_TAGS = new Set(REMINDER_DANGEROUS_HTML_TAG_NAMES); const REMINDER_INLINE_ZERO_WIDTH_HTML_TAGS = new Set(['wbr']); @@ -136,14 +145,244 @@ function unescapeReminderMarkdown(value: string): string { return value.replace(REMINDER_MARKDOWN_ESCAPE_PATTERN, '$1'); } +function isReminderAsciiWhitespace(value: string): boolean { + return value === ' ' || value === '\n' || value === '\r' || value === '\t' || value === '\f'; +} + +function isReminderHtmlTagNameStart(value: string): boolean { + const code = value.charCodeAt(0); + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); +} + +function isReminderHtmlTagNameChar(value: string): boolean { + const code = value.charCodeAt(0); + return ( + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + value === ':' || + value === '-' + ); +} + +function readReminderHtmlTagName( + value: string, + startIndex: number +): { isClosing: boolean; nameEndIndex: number; tagName: string } | null { + if (value[startIndex] !== '<') { + return null; + } + + let index = startIndex + 1; + while (index < value.length && isReminderAsciiWhitespace(value[index] ?? '')) { + index += 1; + } + + let isClosing = false; + if (value[index] === '/') { + isClosing = true; + index += 1; + while (index < value.length && isReminderAsciiWhitespace(value[index] ?? '')) { + index += 1; + } + } + + const nameStartIndex = index; + if (!isReminderHtmlTagNameStart(value[index] ?? '')) { + return null; + } + + index += 1; + while (index < value.length && isReminderHtmlTagNameChar(value[index] ?? '')) { + index += 1; + } + + const nextChar = value[index]; + if (nextChar && !isReminderAsciiWhitespace(nextChar) && nextChar !== '/' && nextChar !== '>') { + return null; + } + + return { + isClosing, + nameEndIndex: index, + tagName: value.slice(nameStartIndex, index).toLowerCase(), + }; +} + +function isReminderHtmlTagSelfClosing(value: string, closingBracketIndex: number): boolean { + let index = closingBracketIndex - 1; + while (index >= 0 && isReminderAsciiWhitespace(value[index] ?? '')) { + index -= 1; + } + + return value[index] === '/'; +} + +function readDangerousReminderHtmlTag( + value: string, + startIndex: number +): ReminderDangerousHtmlTag | null { + const tagName = readReminderHtmlTagName(value, startIndex); + if (!tagName || !REMINDER_DANGEROUS_INLINE_HTML_TAGS.has(tagName.tagName)) { + return null; + } + + const closingBracketIndex = value.indexOf('>', tagName.nameEndIndex); + if (closingBracketIndex === -1) { + return { + endIndex: tagName.nameEndIndex, + isClosing: tagName.isClosing, + isComplete: false, + isSelfClosing: false, + tagName: tagName.tagName, + }; + } + + return { + endIndex: closingBracketIndex + 1, + isClosing: tagName.isClosing, + isComplete: true, + isSelfClosing: isReminderHtmlTagSelfClosing(value, closingBracketIndex), + tagName: tagName.tagName, + }; +} + +function findDangerousReminderHtmlBlockEnd( + value: string, + startIndex: number, + tagName: string +): number | null { + let depth = 0; + let index = startIndex; + + while (index < value.length) { + const tagStartIndex = value.indexOf('<', index); + if (tagStartIndex === -1) { + return null; + } + + const tag = readDangerousReminderHtmlTag(value, tagStartIndex); + if (!tag || tag.tagName !== tagName || !tag.isComplete) { + index = tagStartIndex + 1; + continue; + } + + if (tag.isClosing) { + if (depth === 0) { + return tag.endIndex; + } + + depth -= 1; + } else if (!tag.isSelfClosing) { + depth += 1; + } + + index = tag.endIndex; + } + + return null; +} + /** * Notifications are rendered as plain text today, but script-like HTML * fragments still have no value in reminder summaries and are safer to remove. */ function stripDangerousReminderHtml(value: string): string { - return value - .replace(REMINDER_DANGEROUS_HTML_BLOCK_PATTERN, ' ') - .replace(REMINDER_DANGEROUS_HTML_TAG_PATTERN, ' '); + let result = ''; + let index = 0; + + while (index < value.length) { + const tagStartIndex = value.indexOf('<', index); + if (tagStartIndex === -1) { + result += value.slice(index); + break; + } + + result += value.slice(index, tagStartIndex); + const tag = readDangerousReminderHtmlTag(value, tagStartIndex); + if (!tag) { + result += '<'; + index = tagStartIndex + 1; + continue; + } + + result += ' '; + if (!tag.isClosing && !tag.isSelfClosing && tag.isComplete) { + index = + findDangerousReminderHtmlBlockEnd(value, tag.endIndex, tag.tagName) ?? tag.endIndex; + continue; + } + + index = tag.endIndex; + } + + return result; +} + +function readCompleteReminderHtmlTag( + value: string, + startIndex: number +): ReminderCompleteHtmlTag | null { + if (!readReminderHtmlTagName(value, startIndex)) { + return null; + } + + const closingBracketIndex = value.indexOf('>', startIndex + 1); + if (closingBracketIndex === -1) { + return null; + } + + return { + endIndex: closingBracketIndex + 1, + raw: value.slice(startIndex, closingBracketIndex + 1), + }; +} + +function normalizeReminderCompleteHtmlTag(tag: string): string { + const parsedTag = parseReminderInlineHtmlTag(tag); + if (!parsedTag) { + return tag; + } + + if (REMINDER_INLINE_LINE_BREAK_HTML_TAGS.has(parsedTag.tagName)) { + return '\n'; + } + + if ( + REMINDER_INLINE_ZERO_WIDTH_HTML_TAGS.has(parsedTag.tagName) || + REMINDER_DANGEROUS_INLINE_HTML_TAGS.has(parsedTag.tagName) + ) { + return ''; + } + + return ''; +} + +function normalizeReminderHtmlFragments(value: string): string { + const sanitized = stripDangerousReminderHtml(value); + let result = ''; + let index = 0; + + while (index < sanitized.length) { + const tagStartIndex = sanitized.indexOf('<', index); + if (tagStartIndex === -1) { + result += sanitized.slice(index); + break; + } + + result += sanitized.slice(index, tagStartIndex); + const tag = readCompleteReminderHtmlTag(sanitized, tagStartIndex); + if (!tag) { + result += '<'; + index = tagStartIndex + 1; + continue; + } + + result += normalizeReminderCompleteHtmlTag(tag.raw); + index = tag.endIndex; + } + + return result; } function isReminderPathLikeToken(token: string): boolean { @@ -214,7 +453,7 @@ function stripHtmlToText(value: string): string { } } - return sanitized.replace(/<[^>]+>/g, ' '); + return normalizeReminderHtmlFragments(sanitized); } /** Parse a single markdown-it html_inline token into tag metadata when it is real tag syntax. */ @@ -274,19 +513,9 @@ function collectPairedReminderInlineHtmlOpenings(tokens: ReminderMarkdownToken[] return pairedOpeningIndexes; } -/** Strip only obvious HTML markup in the fallback path and keep literal angle-bracket text. */ +/** Strip complete HTML tags in the fallback path and keep incomplete diagnostics literal. */ function normalizeReminderFallbackHtml(value: string): string { - let normalized = stripDangerousReminderHtml(value) - .replace(/<br\s*\/?>/gi, '\n') - .replace(/<hr\s*\/?>/gi, '\n') - .replace(/<wbr\s*\/?>/gi, ''); - - normalized = normalized.replace(/<([A-Za-z][A-Za-z0-9:-]*)\s*>(?=[\s\S]*<\/\1\s*>)/g, ''); - normalized = normalized.replace(/<\/[A-Za-z][A-Za-z0-9:-]*\s*>/g, ''); - normalized = normalized.replace(/<[A-Za-z][A-Za-z0-9:-]*\b[^>]*\/>/g, ''); - normalized = normalized.replace(/<[A-Za-z][A-Za-z0-9:-]*\b[^>]*\s+[^>]*>/g, ''); - - return normalized; + return normalizeReminderHtmlFragments(value); } function normalizeReminderInlineHtmlToken(