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 1/4] 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 2/4] 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 3/4] 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 4/4] 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; } }