diff --git a/apps/desktop/src/services/AgentService/task/center.ts b/apps/desktop/src/services/AgentService/task/center.ts index 5bedc233..9aa7a9ac 100644 --- a/apps/desktop/src/services/AgentService/task/center.ts +++ b/apps/desktop/src/services/AgentService/task/center.ts @@ -3,9 +3,13 @@ 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'; @@ -39,10 +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; - -/** 深拷贝任务快照,确保外部订阅者无法直接修改内部状态。 */ function cloneTaskSnapshot(snapshot: SessionTaskSnapshot): SessionTaskSnapshot { return cloneTaskValue(snapshot); } @@ -69,74 +69,6 @@ function isTerminalStatus(status: SessionTaskSnapshot['status']): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } -/** 将文本截断到指定字符数,超出部分以省略号结尾。 */ -function truncateReminderText(value: string, maxChars: number): string { - if (value.length <= maxChars) { - return value; - } - - return `${value.slice(0, maxChars - 1).trimEnd()}…`; -} - -/** 规范化空白并截断文本,空字符串返回 null。 */ -function summarizeReminderText( - value: string | null | undefined, - maxChars = STATUS_REMINDER_MAX_BODY_CHARS -) { - const normalized = collapseWhitespace(value ?? ''); - if (!normalized) { - return null; - } - - return truncateReminderText(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 = summarizeReminderText(message.content); - if (summary) { - return summary; - } - } - - return null; -} - -/** 为等待审批状态构建通知正文,包含摘要和命令预览。 */ -function buildWaitingApprovalBody(approval: PendingToolApproval): string { - const summary = - summarizeReminderText(approval.reason) ?? - summarizeReminderText(approval.description) ?? - summarizeReminderText(approval.title) ?? - getSessionStatusReminderContent('waiting_approval'); - const commandPreview = summarizeReminderText( - approval.command, - STATUS_REMINDER_MAX_COMMAND_CHARS - ); - - if (!commandPreview || commandPreview === summary) { - return summary; - } - - return `${summary}\n${commandPreview}`; -} - -function buildWaitingUserQuestionBody( - question: NonNullable -): string { - return summarizeReminderText(question.questions[0]?.question) ?? tt('任务正在等待用户回复'); -} - -/** - * 根据任务快照构建状态提醒负载。 - * 仅在 completed、failed、waiting_approval 三种状态下生成提醒,其余返回 null。 - */ export function buildSessionStatusReminder( snapshot: SessionTaskSnapshot ): SessionStatusReminderPayload | null { @@ -158,7 +90,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/src/utils/reminderText.ts b/apps/desktop/src/utils/reminderText.ts new file mode 100644 index 00000000..996ccd25 --- /dev/null +++ b/apps/desktop/src/utils/reminderText.ts @@ -0,0 +1,912 @@ +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_DANGEROUS_HTML_TAG_NAMES = [ + 'script', + 'style', + 'iframe', + 'object', + 'embed', + 'template', +]; +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; +}; + +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']); + +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 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 { + 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 { + 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( + stripDangerousReminderHtml(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 `` survive. */ +function stripHtmlToText(value: string): string { + if (!value) { + return ''; + } + + const sanitized = stripDangerousReminderHtml(value); + + if (typeof DOMParser !== 'undefined') { + try { + return new DOMParser().parseFromString(sanitized, 'text/html').body.textContent ?? ''; + } catch { + // Fall back to a conservative tag strip when DOM parsing is unavailable. + } + } + + return normalizeReminderHtmlFragments(sanitized); +} + +/** 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 complete HTML tags in the fallback path and keep incomplete diagnostics literal. */ +function normalizeReminderFallbackHtml(value: string): string { + return normalizeReminderHtmlFragments(value); +} + +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 (REMINDER_DANGEROUS_INLINE_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 29f02af3..894d07df 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,32 @@ 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').mockImplementation( + () => 'en-GB' as ReturnType<typeof i18n.getLocale> + ); + + 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', () => { @@ -58,4 +83,450 @@ 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('keeps inline angle-bracket diagnostics in failure notifications', () => { + const reminder = buildSessionStatusReminder( + createSnapshot({ + status: 'failed', + error: 'Assertion failed: expected <title> 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('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('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({ + 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 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({ + 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('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({ + 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({ + 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('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 <<EOF>out.txt\necho \u201c<tag>\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 <<EOF>out.txt echo "<tag>" >out.txt', + approval: { + callId: 'call-3a', + 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', + }); + }); });