From 1e2009387d1fc32b2f8f4adb1d91cbc6c8a69dea Mon Sep 17 00:00:00 2001 From: hy Date: Thu, 23 Apr 2026 17:41:08 +0800 Subject: [PATCH] feat: enhance docx handling with style text support and review highlights --- src/main/services/ai/human/prompts/mouth.ts | 1 + .../ai/human/shared/historyRenderers.ts | 128 ++++++++++++++++-- .../ai/human/tools/docx/review/replayTree.ts | 34 ++++- .../tools/docx/review/reviewSerializer.ts | 56 +++++++- .../ai/human/tools/docx/review/utils.ts | 8 +- .../services/ai/human/tools/docx/types.ts | 2 + .../docx-style-text-review-highlight.test.ts | 122 +++++++++++++++++ tests/unit/ai/mouth-ready-to-deliver.test.ts | 117 ++++++++++++++++ 8 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 tests/ai/tool/docx/docx-style-text-review-highlight.test.ts diff --git a/src/main/services/ai/human/prompts/mouth.ts b/src/main/services/ai/human/prompts/mouth.ts index 546b3623..05074cf8 100644 --- a/src/main/services/ai/human/prompts/mouth.ts +++ b/src/main/services/ai/human/prompts/mouth.ts @@ -13,6 +13,7 @@ export const buildMouthPrompt = (input: { state: HumanState; ctx?: AiRunContext - 你的职责是根据context_information的信息,面向用户交付最终结果,而不是复述系统内部过程。 - 优先回答用户真正关心的内容,优先使用更自然、更容易理解的表达。 - your_notes、history、delivery_guidance 和 runtime_artifacts 主要用于核对事实和补充细节,不要求按原样转述。 + - 严格按照当前上下文信息回答,不得编造、补写或改写为上下文中未明确给出的已执行结果。 - 如果提供了额外的交付说明,优先将其转化为用户容易理解的最终答复,并确保相关状态被明确说明。 - 回答必须忠实于当前已经成立的信息;信息不足时直接说明,不补写未确认的结果。 - 如果需要使用不来自当前上下文的通用知识,在结尾单独写“补充说明:...”,明确说明来源不是当前本地资料。 diff --git a/src/main/services/ai/human/shared/historyRenderers.ts b/src/main/services/ai/human/shared/historyRenderers.ts index bc90f677..9a69cf02 100644 --- a/src/main/services/ai/human/shared/historyRenderers.ts +++ b/src/main/services/ai/human/shared/historyRenderers.ts @@ -340,7 +340,58 @@ const summarizeArrayTexts = (value: JsonValue | undefined, key: string): string[ .filter((item): item is string => Boolean(item && item.length > 0)) } -const summarizeToolResultForMouth = (result: HumanHistoryToolResult): string => { +const MOUTH_DOCX_TEXT_LIMIT = 420 +const MOUTH_DOCX_TABLE_MAX_ROWS = 4 + +const clipText = (value: string, maxChars: number): string => { + const normalized = value.replace(/\s+/g, ' ').trim() + if (!normalized) return '' + if (normalized.length <= maxChars) return normalized + return `${normalized.slice(0, maxChars - 1).trim()}…` +} + +const renderMouthToolArgValue = (value: JsonValue | undefined): string => { + if (value === undefined) return 'none' + return JSON.stringify(value, null, 2) +} + +const buildDocxWriteFact = (input: { + toolName: HumanHistoryToolResult['toolName'] + action?: string + target?: string + contentLabel: string + content: JsonValue | undefined + extraLines?: string[] +}): string => { + const header = [`\`${input.toolName}\` 已完成实际写入。`] + if (input.action) header.push(`action=${input.action}`) + if (input.target) header.push(`target=${input.target}`) + const contentText = + typeof input.content === 'string' + ? clipText(input.content, MOUTH_DOCX_TEXT_LIMIT) + : renderMouthToolArgValue(input.content) + return [ + header.join(';'), + ` - ${input.contentLabel}:`, + contentText + .split('\n') + .map((line) => ` ${line}`) + .join('\n'), + ...(input.extraLines || []).map((line) => ` - ${line}`) + ].join('\n') +} + +const buildTableRowsPreview = (rows: JsonValue | undefined): JsonValue | undefined => { + if (!Array.isArray(rows)) return rows + if (rows.length <= MOUTH_DOCX_TABLE_MAX_ROWS) return rows + return [...rows.slice(0, MOUTH_DOCX_TABLE_MAX_ROWS), `... 其余 ${String(rows.length - MOUTH_DOCX_TABLE_MAX_ROWS)} 行省略`] +} + +const summarizeToolResultForMouth = (input: { + result: HumanHistoryToolResult + call?: ToolCallParam +}): string => { + const { result, call } = input if (result.diagnosticText) { return `\`${result.toolName}\` 未完成:${result.diagnosticText}` } @@ -371,17 +422,67 @@ const summarizeToolResultForMouth = (result: HumanHistoryToolResult): string => ? `\`${result.toolName}\` 概览到 ${String(count)} 个块:${blocks.join(' / ')}` : `\`${result.toolName}\` 概览到 ${String(count)} 个块。` } - case 'docx_text_edit': case 'docx_paragraph_edit': - case 'docx_heading_edit': - case 'docx_table_edit': + case 'docx_heading_edit': { + return buildDocxWriteFact({ + toolName: result.toolName, + action: typeof call?.args.action === 'string' ? call.args.action : undefined, + target: + typeof call?.args.blockId === 'string' + ? `blockId=${call.args.blockId}` + : typeof call?.args.anchorBlockId === 'string' + ? `anchorBlockId=${call.args.anchorBlockId}` + : undefined, + contentLabel: '实际写入文本', + content: typeof call?.args.text === 'string' ? call.args.text : undefined + }) + } + case 'docx_table_edit': { + const rawRows = Array.isArray(call?.args.rows) ? call.args.rows : undefined + const rows = buildTableRowsPreview(rawRows) + return buildDocxWriteFact({ + toolName: result.toolName, + action: typeof call?.args.action === 'string' ? call.args.action : undefined, + target: + typeof call?.args.blockId === 'string' + ? `blockId=${call.args.blockId}` + : typeof call?.args.anchorBlockId === 'string' + ? `anchorBlockId=${call.args.anchorBlockId}` + : undefined, + contentLabel: '实际写入 rows', + content: rows, + extraLines: Array.isArray(rawRows) && rawRows.length > MOUTH_DOCX_TABLE_MAX_ROWS + ? [`原始表格总行数=${String(rawRows.length)}`] + : undefined + }) + } + case 'docx_text_edit': { + return buildDocxWriteFact({ + toolName: result.toolName, + target: + Array.isArray(call?.args.blockIds) && call.args.blockIds.length > 0 + ? `blockIds=${renderMouthToolArgValue(call.args.blockIds)}` + : undefined, + contentLabel: '实际替换参数', + content: { + query: typeof call?.args.query === 'string' ? call.args.query : undefined, + replace: typeof call?.args.replace === 'string' ? call.args.replace : undefined + } + }) + } case 'docx_text_style_edit': { - const summary = readStringField(result.data, 'summary') - const changedBlocks = Array.isArray(result.data.changedBlocks) ? result.data.changedBlocks.length : 0 - if (summary) return `\`${result.toolName}\`:${summary}` - return changedBlocks > 0 - ? `\`${result.toolName}\` 已形成 ${String(changedBlocks)} 处修改。` - : `\`${result.toolName}\` 已完成。` + return buildDocxWriteFact({ + toolName: result.toolName, + target: + typeof call?.args.blockId === 'string' + ? `blockId=${call.args.blockId}` + : undefined, + contentLabel: '实际样式参数', + content: { + query: typeof call?.args.query === 'string' ? call.args.query : undefined, + style: isJsonObject(call?.args.style) ? call.args.style : undefined + } + }) } default: { const summary = @@ -410,7 +511,12 @@ const renderMouthRoundFacts = (record: HumanRoundRecord): string[] => { return facts } case 'hand': { - const facts = record.execution.toolResults.map(summarizeToolResultForMouth) + const facts = record.execution.toolResults.map((result, index) => + summarizeToolResultForMouth({ + result, + call: record.execution.toolUsePlan.calls[index] + }) + ) if (record.execution.watchHint) { facts.push(`收敛提示:${summarizeWatchHint(record.execution.watchHint)}`) } diff --git a/src/main/services/ai/human/tools/docx/review/replayTree.ts b/src/main/services/ai/human/tools/docx/review/replayTree.ts index 3f91a5cc..de7381c4 100644 --- a/src/main/services/ai/human/tools/docx/review/replayTree.ts +++ b/src/main/services/ai/human/tools/docx/review/replayTree.ts @@ -98,6 +98,7 @@ const buildNode = (input: { spec: DocxBlockSpec createdRevisionRef: string changedRevisionRef?: string + reviewQuery?: string }): DocxReplayNode => ({ nodeId: `${input.reviewState}:${input.blockId}:${input.createdRevisionRef}:${input.changedRevisionRef || 'active'}`, blockId: input.blockId, @@ -110,7 +111,8 @@ const buildNode = (input: { currentBlockId: input.currentBlockId, spec: input.spec, createdRevisionRef: input.createdRevisionRef, - changedRevisionRef: input.changedRevisionRef + changedRevisionRef: input.changedRevisionRef, + reviewQuery: input.reviewQuery }) const reindexBodyNodes = (session: DocxReplaySession): void => { @@ -255,7 +257,8 @@ const cloneNodeAsOldRevision = ( nodeId: `${node.nodeId}:${reviewState}:${revisionRef}`, currentBlockId: undefined, reviewState, - changedRevisionRef: revisionRef + changedRevisionRef: revisionRef, + reviewQuery: undefined }) const buildCurrentNodeFromSnapshot = ( @@ -427,10 +430,34 @@ export const applyRevisionToReplaySession = (input: { return } + if (op.type === 'style_text') { + const snapshotBlock = resolveSnapshotBlockForMutation({ + session, + nextSnapshot, + createdBlocks, + targetBlockId + }) + if (!snapshotBlock) { + targetNode.reviewState = 'formatted' + targetNode.changedRevisionRef = revisionRef + targetNode.reviewQuery = op.query + session.activeSnapshot = nextSnapshot + return + } + + targetNode.reviewState = 'formatted' + targetNode.currentBlockId = snapshotBlock.blockId + targetNode.bodyIndex = snapshotBlock.bodyIndex + targetNode.spec = snapshotBlock.spec + targetNode.changedRevisionRef = revisionRef + targetNode.reviewQuery = op.query + session.activeSnapshot = nextSnapshot + return + } + const oldState = op.type === 'replace_block' || op.type === 'replace_text' || - op.type === 'style_text' || op.type === 'update_table_cell' ? 'replaced-old' : 'deleted' @@ -478,6 +505,7 @@ export const projectReplayNodes = ( if (kind === 'review') return true return ( node.reviewState === 'normal' || + node.reviewState === 'formatted' || node.reviewState === 'inserted' || node.reviewState === 'replaced-new' ) diff --git a/src/main/services/ai/human/tools/docx/review/reviewSerializer.ts b/src/main/services/ai/human/tools/docx/review/reviewSerializer.ts index 9bf65c4a..7bd8c244 100644 --- a/src/main/services/ai/human/tools/docx/review/reviewSerializer.ts +++ b/src/main/services/ai/human/tools/docx/review/reviewSerializer.ts @@ -16,7 +16,7 @@ import { projectCommittedView, projectReviewView } from './viewProjector' const WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' -type RevisionKind = 'inserted' | 'deleted' +type RevisionKind = 'inserted' | 'deleted' | 'formatted' const ensureDirForFile = async (filePath: string): Promise => { await fs.mkdir(path.dirname(filePath), { recursive: true }) @@ -56,6 +56,14 @@ const styleRunForRevision = (run: Element, kind: RevisionKind): void => { return } + if (kind === 'formatted') { + const shade = firstChildElement(rPr, 'shd') || run.ownerDocument.createElementNS(WORD_NS, 'w:shd') + setWordAttribute(shade, 'val', 'clear') + setWordAttribute(shade, 'fill', 'CFFAFE') + if (!shade.parentNode) rPr.appendChild(shade) + return + } + const color = run.ownerDocument.createElementNS(WORD_NS, 'w:color') const shade = run.ownerDocument.createElementNS(WORD_NS, 'w:shd') setWordAttribute(color, 'val', '166534') @@ -79,6 +87,48 @@ const applyRevisionToRuns = (element: Element, kind: RevisionKind): void => { }) } +const getRunText = (run: Element): string => + Array.from({ length: run.childNodes.length }) + .map((_, index) => run.childNodes.item(index)) + .filter(Boolean) + .map((child) => { + if (!isElementNode(child)) return child.textContent || '' + if (child.localName === 't') return child.textContent || '' + return getRunText(child) + }) + .join('') + +const collectRunElements = (element: Element): Element[] => { + const result: Element[] = [] + const stack: Element[] = [element] + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + if (current.localName === 'r') { + result.push(current) + continue + } + Array.from({ length: current.childNodes.length }) + .map((_, index) => current.childNodes.item(index)) + .filter(isElementNode) + .forEach((child) => stack.push(child)) + } + return result +} + +const applyFormattedMarker = (element: Element, query?: string): void => { + const normalizedQuery = String(query || '').trim() + const runs = collectRunElements(element) + if (runs.length === 0) return + + const matchedRuns = + normalizedQuery.length > 0 + ? runs.filter((run) => getRunText(run).includes(normalizedQuery)) + : runs + const targetRuns = matchedRuns.length > 0 ? matchedRuns : runs + targetRuns.forEach((run) => styleRunForRevision(run, 'formatted')) +} + const createTextRun = ( doc: Document, text: string, @@ -293,6 +343,10 @@ const resolveElementForNode = (input: { } const applyReviewMarker = (element: Element, node: DocxReplayNode): void => { + if (node.reviewState === 'formatted') { + applyFormattedMarker(element, node.reviewQuery) + return + } if (node.reviewState === 'inserted' || node.reviewState === 'replaced-new') { applyRevisionToRuns(element, 'inserted') return diff --git a/src/main/services/ai/human/tools/docx/review/utils.ts b/src/main/services/ai/human/tools/docx/review/utils.ts index e1f743fd..d88bafa7 100644 --- a/src/main/services/ai/human/tools/docx/review/utils.ts +++ b/src/main/services/ai/human/tools/docx/review/utils.ts @@ -45,6 +45,10 @@ export const getDocxChangeKind = (op: DocxOp): FileReviewChange['change_kind'] = op.type === 'update_table_cell' ? 'table_cell' : 'block' export const buildDocxChangeTitle = (op: DocxOp, change: DocxChangedBlock): string => { + if (op.type === 'style_text') { + const query = String(op.query || '').trim() + return query ? `更新文本样式: ${query}` : '更新文本样式' + } const baseText = String(change.afterText || change.beforeText).trim() const previewRaw = clipText(baseText, 28).trim() const preview = previewRaw.length < baseText.length ? `${previewRaw}...` : previewRaw @@ -52,7 +56,9 @@ export const buildDocxChangeTitle = (op: DocxOp, change: DocxChangedBlock): stri } export const buildDocxChangeSummary = (op: DocxOp, change: DocxChangedBlock): string => - `${describeDocxOp(op)} ${change.blockId}` + op.type === 'style_text' + ? `${describeDocxOp(op)} ${change.blockId}${String(op.query || '').trim() ? `: ${String(op.query).trim()}` : ''}` + : `${describeDocxOp(op)} ${change.blockId}` export const buildDocxChangeLocator = (change: DocxChangedBlock): JsonObject => ({ blockId: change.blockId, diff --git a/src/main/services/ai/human/tools/docx/types.ts b/src/main/services/ai/human/tools/docx/types.ts index 68bcf980..a8cd1cf8 100644 --- a/src/main/services/ai/human/tools/docx/types.ts +++ b/src/main/services/ai/human/tools/docx/types.ts @@ -267,6 +267,7 @@ export type DocxReplaySession = { export type DocxReviewState = | 'normal' + | 'formatted' | 'inserted' | 'deleted' | 'replaced-old' @@ -285,6 +286,7 @@ export type DocxReplayNode = { spec: DocxBlockSpec createdRevisionRef: string changedRevisionRef?: string + reviewQuery?: string } export type DocxViewArtifact = { diff --git a/tests/ai/tool/docx/docx-style-text-review-highlight.test.ts b/tests/ai/tool/docx/docx-style-text-review-highlight.test.ts new file mode 100644 index 00000000..d4ae5148 --- /dev/null +++ b/tests/ai/tool/docx/docx-style-text-review-highlight.test.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs/promises' +import { XMLSerializer } from '@xmldom/xmldom' +import { describe, it } from 'vitest' +import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/blockIndex' +import { + createDocxToolHarness, + writeRichTestDocxFile +} from '../../../harness/backend/aiToolHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' +import type { DocxMutationResult, DocxOutlineResult } from './testUtils' + +describe('ai tool docx backend', () => { + it('docx-style-text-review-highlight', async () => { + await runBackendCase({ + caseId: 'docx-style-text-review-highlight', + harnessMode: 'tool-real', + domain: 'ai', + suite: 'tool', + groups: ['docx'], + description: '验证 style_text 会在 review artifact 中以独立浅青色高亮显示,并且 committed artifact 不保留该高亮。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + await writeRichTestDocxFile(runtime.workspaceRoot, 'style-highlight.docx', [ + { + type: 'paragraph', + paragraph: { + runs: [{ text: '负责人:Alice 将在周五完成复核。' }] + } + } + ]) + + runtime.fileReviewService.beginRun({ + runId: runtime.runId, + sessionId: 'docx-style-highlight-session', + inputText: '把 Alice 标成重点' + }) + + const harness = createDocxToolHarness(runtime) + try { + const outline = await harness.invoke('docx_outline', { + sourcePath: 'style-highlight.docx' + }) + const paragraphBlockId = + outline.blocks.find( + (block) => block.type === 'paragraph' && block.preview.includes('Alice') + )?.blockId || '' + const styled = await harness.invoke('docx_text_style_edit', { + sourcePath: 'style-highlight.docx', + blockId: paragraphBlockId, + query: 'Alice', + style: { + bold: true, + color: '2563EB' + } + }) + const details = await runtime.fileReviewService.finalizeRunReview({ + runId: runtime.runId, + workspaceRootPath: runtime.workspaceRoot + }) + const detail = details[0] + if ( + !detail || + !detail.review.draft_artifact_path || + !detail.review.committed_artifact_path + ) { + throw new Error('expected generated review and committed artifacts') + } + + const reviewBuffer = await fs.readFile(detail.review.draft_artifact_path) + const committedBuffer = await fs.readFile(detail.review.committed_artifact_path) + const reviewBundle = await loadDocxBundle(reviewBuffer) + const committedBundle = await loadDocxBundle(committedBuffer) + + return { + styled, + reviewPath: detail.review.draft_artifact_path, + committedPath: detail.review.committed_artifact_path, + reviewXml: new XMLSerializer().serializeToString(reviewBundle.documentDom), + committedXml: new XMLSerializer().serializeToString(committedBundle.documentDom), + changeTitles: detail.changes.map((change) => change.title), + changeSummaries: detail.changes.map((change) => change.summary) + } + } finally { + await harness.release() + } + }, + assert: async (result, runtime) => { + runtime.assertThat( + result.styled.changedBlocks.some((block) => block.action === 'style_text') && + result.changeTitles.includes('更新文本样式: Alice') && + result.changeSummaries.some((summary) => summary.includes('更新文本样式') && summary.includes('Alice')), + { + name: 'style-text-review-summary-uses-style-specific-copy', + message: 'style_text 进入 review 记录后,应使用独立的样式修改标题和摘要。', + data: { + changedBlocks: result.styled.changedBlocks, + changeTitles: result.changeTitles, + changeSummaries: result.changeSummaries + } + } + ) + + runtime.assertThat( + result.reviewXml.includes('CFFAFE') && !result.committedXml.includes('CFFAFE'), + { + name: 'style-text-review-highlight-stays-review-only', + message: 'style_text 应只在 review artifact 中带浅青色高亮,committed artifact 不应保留该显示层标记。', + data: { + reviewPath: result.reviewPath, + committedPath: result.committedPath, + reviewHasHighlight: result.reviewXml.includes('CFFAFE'), + committedHasHighlight: result.committedXml.includes('CFFAFE'), + reviewXmlSnippet: result.reviewXml.slice(0, 1200), + committedXmlSnippet: result.committedXml.slice(0, 1200) + } + } + ) + } + }) + }) +}) diff --git a/tests/unit/ai/mouth-ready-to-deliver.test.ts b/tests/unit/ai/mouth-ready-to-deliver.test.ts index b12f3d01..4a2e981b 100644 --- a/tests/unit/ai/mouth-ready-to-deliver.test.ts +++ b/tests/unit/ai/mouth-ready-to-deliver.test.ts @@ -119,6 +119,7 @@ describe('ai unit mouth checklist guard', () => { expect(prompt).toContain('只输出最终 markdown 正文') expect(prompt).toContain('如果需要使用不来自当前上下文的通用知识') expect(prompt).toContain('补充说明') + expect(prompt).toContain('严格按照当前上下文信息回答,不得编造') }) it('keeps mouth user-context blocks data-only', () => { @@ -324,6 +325,122 @@ describe('ai unit mouth checklist guard', () => { expect(prompt.user).not.toContain('fromBlockId') }) + it('renders actual docx write args in mouth history for paragraph and table edits', () => { + const initialState = createInitialHumanState({ + taskId: 'task-6', + userInput: '把主稿补充完整并插入表格' + }) + const history = appendRoundRecord({ + state: initialState, + decision: { + decision: { + summary: '写入主稿段落和表格', + reason: '当前已经明确知道要写入的正文和表格内容', + expectedOutcome: '让 mouth 严格依据真实写入内容回答' + }, + nextAction: { + action: 'hand', + toolUsePlan: { + mode: 'serial', + calls: [ + { + toolName: 'docx_paragraph_edit', + args: { + sourcePath: 'prd.docx', + action: 'replace_text', + blockId: 'b:31', + text: '每个探究环节应包含四个核心设计要素:探究目标、探究素材、学生活动设计、环节推进条件。' + } + }, + { + toolName: 'docx_table_edit', + args: { + sourcePath: 'prd.docx', + action: 'insert_after', + anchorBlockId: 'b:32', + rows: [ + ['设计要素', '关键内容', '互动形式示例'], + ['探究目标', '明确学生需达成的认知目标', '情境引入、问题聚焦、概念生成'], + ['探究素材', '提供视频、图片、数据等支撑材料', '耐克迁移视频、生产地图、成本数据表'] + ] + } + } + ] + } + } + }, + execution: buildHandExecution({ + toolUsePlan: { + mode: 'serial', + calls: [ + { + toolName: 'docx_paragraph_edit', + args: { + sourcePath: 'prd.docx', + action: 'replace_text', + blockId: 'b:31', + text: '每个探究环节应包含四个核心设计要素:探究目标、探究素材、学生活动设计、环节推进条件。' + } + }, + { + toolName: 'docx_table_edit', + args: { + sourcePath: 'prd.docx', + action: 'insert_after', + anchorBlockId: 'b:32', + rows: [ + ['设计要素', '关键内容', '互动形式示例'], + ['探究目标', '明确学生需达成的认知目标', '情境引入、问题聚焦、概念生成'], + ['探究素材', '提供视频、图片、数据等支撑材料', '耐克迁移视频、生产地图、成本数据表'] + ] + } + } + ] + }, + toolResults: [ + { + toolName: 'docx_paragraph_edit', + ok: true, + code: 'ok', + data: { + ok: true, + code: 'ok', + summary: '段落replace_text 完成,共 1 处变更' + } + }, + { + toolName: 'docx_table_edit', + ok: true, + code: 'ok', + data: { + ok: true, + code: 'ok', + summary: '表格insert_after 完成,共 1 处变更' + } + } + ] + }), + createdAt: 3 + }) + + const prompt = buildMouthPrompt({ + state: { + ...initialState, + memory: { + ...initialState.memory, + history + } + } + }) + + expect(prompt.user).toContain('`docx_paragraph_edit` 已完成实际写入。') + expect(prompt.user).toContain('每个探究环节应包含四个核心设计要素') + expect(prompt.user).toContain('`docx_table_edit` 已完成实际写入。') + expect(prompt.user).toContain('设计要素') + expect(prompt.user).toContain('互动形式示例') + expect(prompt.user).toContain('耐克迁移视频') + }) + it('renders delivery guidance when present', () => { const initialState = createInitialHumanState({ taskId: 'task-5',