From f472f6168631de7ba78fca1970a599ae24c7c19d Mon Sep 17 00:00:00 2001 From: hy Date: Fri, 22 May 2026 11:36:11 +0800 Subject: [PATCH] Add tests for docx tool functionality and revise existing tests - Implement tests for tracking active revision stats in docx replay sessions. - Add validation for immediate outline updates after writing in docx files. - Create tests for handling multiple writes across different docx files in a single hand session. - Ensure that reading, writing, and reading again from the same docx file is allowed. - Remove outdated tests related to serial snapshot-bound editing and successor chains. - Introduce tests to validate restrictions on multiple writes to the same docx file. - Verify stable targeting of table cells during mixed paragraph and table operations. --- AGENTS.md | 1 + doc/ai-design/data-definition-and-flow.md | 2 +- doc/ai-design/implementation-spec.md | 2 +- doc/test-plan/ai-test.md | 60 +-- src/main/services/ai/human/constants.ts | 3 +- .../services/ai/human/node/handNode/index.ts | 356 +++--------------- src/main/services/ai/human/prompts/brain.ts | 1 + .../ai/human/prompts/decisionHints.ts | 11 +- src/main/services/ai/human/schema.ts | 1 + src/main/services/ai/human/skills.ts | 4 +- .../services/ai/human/tools/docx/runtime.ts | 66 +++- ...replay-stats-track-active-revision.test.ts | 94 +++++ ...hen-outline-percent-sees-new-block.test.ts | 84 +++++ ...x-multi-file-single-write-allowed.test.ts} | 109 +++--- ...-same-file-read-write-read-allowed.test.ts | 108 ++++++ ...cx-serial-snapshot-bound-edit-plan.test.ts | 134 ------- .../hand-docx-serial-successor-chain.test.ts | 144 ------- ...x-single-file-multi-write-invalid.test.ts} | 56 ++- ...hand-docx-table-cell-stable-target.test.ts | 137 ------- .../hand/hand-docx-table-mixed-chain.test.ts | 162 -------- 20 files changed, 535 insertions(+), 1000 deletions(-) create mode 100644 tests/ai/tool/docx/docx-replay-stats-track-active-revision.test.ts create mode 100644 tests/ai/tool/docx/docx-write-then-outline-percent-sees-new-block.test.ts rename tests/ai/tool/hand/{hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts => hand-docx-multi-file-single-write-allowed.test.ts} (54%) create mode 100644 tests/ai/tool/hand/hand-docx-same-file-read-write-read-allowed.test.ts delete mode 100644 tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan.test.ts delete mode 100644 tests/ai/tool/hand/hand-docx-serial-successor-chain.test.ts rename tests/ai/tool/hand/{hand-docx-batch-write-limit.test.ts => hand-docx-single-file-multi-write-invalid.test.ts} (54%) delete mode 100644 tests/ai/tool/hand/hand-docx-table-cell-stable-target.test.ts delete mode 100644 tests/ai/tool/hand/hand-docx-table-mixed-chain.test.ts diff --git a/AGENTS.md b/AGENTS.md index c19b3dae..6ab4e6cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ ### 1.4 测试相关 - `doc/test-plan/ai-test.md`:涉及 AI 测试范围、正式 case 设计或执行摘要维护时阅读。 - `doc/test-plan/ai-test.md`:涉及 AI 测试范围、正式 case 设计、terminal 只读/审批策略或执行摘要维护时阅读。 +- `doc/test-plan/ai-test.md`:涉及 docx hand 单文件写限制、写后 readback 一致性或 replay stats 相关 formal case 时阅读。 - `doc/test-plan/settings-test.md`:涉及 Settings 测试范围或测试边界时阅读。 - `doc/test.md`:涉及测试体系、runner、日志或正式测试流程时阅读。 diff --git a/doc/ai-design/data-definition-and-flow.md b/doc/ai-design/data-definition-and-flow.md index 03903978..7a341696 100644 --- a/doc/ai-design/data-definition-and-flow.md +++ b/doc/ai-design/data-definition-and-flow.md @@ -600,7 +600,7 @@ type HumanDecisionHint = | 'tool_use_plan_limit_exceeded' | 'parallel_requires_read_only' | 'parallel_limit_exceeded' - | 'docx_batch_write_limit_exceeded' + | 'docx_single_file_write_limit_exceeded' // 导致非法的工具名。 offendingToolNames: string[] // 若是数量超限,则记录本次调用数。 diff --git a/doc/ai-design/implementation-spec.md b/doc/ai-design/implementation-spec.md index db22ceb9..edd24e19 100644 --- a/doc/ai-design/implementation-spec.md +++ b/doc/ai-design/implementation-spec.md @@ -99,7 +99,7 @@ stateDiagram-v2 - 约束:不调用模型;`parallel` 只允许只读且并行安全的工具。对 `terminal_run` 来说,是否只读且并行安全必须由统一正式只读指令集决定。 - 对 `serial` hand 计划,正式实现需要先做 approval preflight:遍历本轮 `toolUsePlan.calls`,收集所有未命中正式 readonly 指令集的 `terminal_run`,生成一个批量审批事件并暂停本轮执行;只要该批次未全部 `approve`,本批命令一条都不执行,并通过 `hand_approval_blocked` 回到 `brain`。 - hand 对 `toolUsePlan` 先执行总长度保护;当前正式实现要求单轮计划总调用数不超过 `HUMAN_TOOL_USE_PLAN_LIMIT`,超过时直接返回 `hand_plan_invalid(reason=tool_use_plan_limit_exceeded)`,而不是继续进入串行/并行细分校验。 -- 对 docx 串行写,hand 需要执行单轮批次保护;docx 写调用数仍受独立的 docx batch limit 约束,超过时直接返回 `hand_plan_invalid(reason=docx_batch_write_limit_exceeded)`,而不是继续落工具。docx 写工具会在结果中返回 `postWriteReadback.regions` 作为写后复核区域,brain 可基于该结果判断是否继续编辑或额外读取。 +- 对 docx 串行写,hand 需要执行同文件写保护;若同一 `sourcePath` 在一轮内出现多个 docx 写调用,直接返回 `hand_plan_invalid(reason=docx_single_file_write_limit_exceeded)`,而不是继续落工具。docx 写工具会在结果中返回 `postWriteReadback.regions` 作为写后复核区域,brain 可基于该结果判断是否继续编辑或额外读取。 - tool 执行失败时,hand 应优先保留原始校验/运行时错误文本,并通过 `diagnostic.message` 回流给 brain 与历史渲染层;不要把错误再压缩成额外的预设分类。 ### 5.4 `mouth` diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 69265eb3..75fa46f7 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -440,55 +440,55 @@ - Oracle:工具结果按顺序成功返回,写工具结果包含 `postWriteReadback.regions`,且不产生旧写后阻断 decision hint。 - Not:不验证复杂定位恢复。 -##### `hand-docx-batch-write-limit` +##### `hand-docx-single-file-multi-write-invalid` - 测试类型:`backend` - 实现状态:`已实现` -- What:验证 docx 串行批量写超过单批上限时,会在执行前被 `hand` 识别为非法 plan 并阻止执行。 -- Why:单轮 docx 串行写存在明确上限,超过上限应在真正落工具前收口,避免生成超长连续写批次。 -- How:构造超过 5 个纯段落写入的 serial plan。 -- Oracle:返回 `hand_plan_invalid`,`reason=docx_batch_write_limit_exceeded`,并带上 `callCount` 与 `limit`。 -- Not:不验证后续额外读取或分批续写策略。 +- What:验证同一 `sourcePath` 一轮 hand 中出现第 2 次 docx 写时,会在执行前被判为非法 plan。 +- Why:单文件多步写兼容逻辑已移除,正式约束改为“同文件每轮最多一次写”。 +- How:构造同一文件上的 `docx_heading_edit -> docx_paragraph_edit` serial plan。 +- Oracle:返回 `hand_plan_invalid`,`reason=docx_single_file_write_limit_exceeded`,并带稳定 `decisionHint`、违规工具名和触发文件。 +- Not:不验证后续轮次如何改写计划。 -##### `hand-docx-serial-successor-chain` +##### `hand-docx-multi-file-single-write-allowed` - 测试类型:`backend` - 实现状态:`已实现` -- What:验证同批 docx 串行写中,`replace_text` 后继续对同一原锚点连续 `insert_after` 仍能完成。 -- Why:这是“旧 blockId 失效后靠 successor/live resolve 继续”的核心回归。 -- How:真实执行 `replace_text -> insert_after -> insert_after`,后两步都沿原始锚点继续追加。 -- Oracle:三步全部成功,新段落顺序正确,且每次写工具结果包含 `postWriteReadback.regions`。 -- Not:不验证表格恢复。 +- What:验证一轮 hand 内对不同文件各写一次仍允许通过。 +- Why:执行模型只收缩“同文件多写”,不影响多文件 batch 的效率收益。 +- How:真实执行 `fileA:docx_text_edit -> fileB:docx_text_edit`。 +- Oracle:两次写都成功,两个文件各自推进 revision,且最新 outline 可读到更新后的内容。 +- Not:不验证跨文件更多组合。 -##### `hand-docx-table-cell-stable-target` +##### `hand-docx-same-file-read-write-read-allowed` - 测试类型:`backend` - 实现状态:`已实现` -- What:验证前序结构插入后,旧 `cellBlockId` 仍能恢复到新的 table cell 并继续写入。 -- Why:表格 cell 是批量串行写里最容易因 body block 漂移而误命中的高风险目标。 -- How:真实执行 `insert_before paragraph -> docx_table_edit(update_cell)`。 -- Oracle:第二步成功命中 `table_cell` 并更新内容,不会误落到 `table` body block,也不会静默跳过。 -- Not:不验证更复杂的表格重排。 +- What:验证同一文件在一轮中执行 `read -> write -> read` 仍允许通过。 +- Why:本次限制只针对“同一文件多次写”,不应误伤围绕单次写的读回确认流程。 +- How:真实执行 `docx_outline -> docx_text_edit -> docx_read_blocks`。 +- Oracle:三步都成功,最后一次读取能看到写后内容,且不返回 plan invalid。 +- Not:不验证额外的多次写。 -##### `hand-docx-table-mixed-chain` +##### `docx-write-then-outline-percent-sees-new-block` - 测试类型:`backend` - 实现状态:`已实现` -- What:验证混合段落与表格的同批串行写,在 `insert_before`、`update_cell`、`insert_after` 组合下仍能稳定完成。 -- Why:这条 case 同时覆盖表格 cell 恢复与 continuation 方向保护,是更接近真实风险周报的链式写路径。 -- How:真实执行 `insert_before paragraph -> update_cell -> insert_after paragraph`,其中前后插入共用同一原锚点。 -- Oracle:前插内容位于原锚点前,后插内容位于原锚点后,表格 cell 正确更新,且写工具结果包含 `postWriteReadback.regions`。 -- Not:不验证 halted decision hint 分支。 +- What:验证 docx 写后立刻使用 `docx_outline(percent)` 读取当前 working draft 时,能看到新增块。 +- Why:这直接覆盖 replay session stats 未同步导致 percent 漏块的回归问题。 +- How:真实执行一次 `docx_heading_edit`,随后立刻用 `ranges=[{type:'percent',fromPercent:0,toPercent:100}]` 读取。 +- Oracle:outline 返回新增块,且 replay session 的 `stats.blockCount` 等于当前 active revision 的真实块数。 +- Not:不验证更细的分页策略。 -##### `hand-docx-serial-snapshot-bound-edit-plan-invalid-target` +##### `docx-replay-stats-track-active-revision` - 测试类型:`backend` - 实现状态:`已实现` -- What:验证同批写中若后续目标已被前序操作删除且无法安全恢复,会停机而不是误写。 -- Why:fail-closed 停机是批量串行写安全闭环的核心下界。 -- How:真实执行 `delete -> replace_text`,第二步继续引用已删除的原目标。 -- Oracle:前一步保留成功,第二步返回 `invalid_input`,并产出 `hand_docx_batch_write_halted`。 -- Not:不验证 brain 下一轮如何恢复。 +- What:验证 replay session 暴露的 `stats` 会随着 revision 前进持续对齐当前 active snapshot。 +- Why:`docx_outline(percent)`、写后读回与后续只读工具都依赖这份 stats 一致性。 +- How:真实执行“插入一段 -> 再改写该段”,分别检查 `registerRevision` 后 replay session 的 `stats.blockCount`。 +- Oracle:每次 revision 之后 `stats.blockCount` 都与当前真实 working draft 一致,不残留旧版本统计。 +- Not:不验证 imageCount 等与 replay snapshot 无关的计数来源。 ##### `docx-multi-round-created-block-edit` diff --git a/src/main/services/ai/human/constants.ts b/src/main/services/ai/human/constants.ts index d789540d..bf0771de 100644 --- a/src/main/services/ai/human/constants.ts +++ b/src/main/services/ai/human/constants.ts @@ -46,7 +46,7 @@ export const HUMAN_HAND_PLAN_INVALID_REASONS = [ 'tool_use_plan_limit_exceeded', 'parallel_requires_read_only', 'parallel_limit_exceeded', - 'docx_batch_write_limit_exceeded' + 'docx_single_file_write_limit_exceeded' ] as const export const HUMAN_DECISION_HINT_CODES = [ 'learning_no_effect', @@ -63,7 +63,6 @@ export const HUMAN_DECISION_HINT_CODES = [ export const HUMAN_MAX_ACTIVE_TOOLS = 20 export const HUMAN_DEFAULT_TOOL_CALL_LIMIT = 40 export const HUMAN_MAX_PARALLEL_TOOL_CALLS = 6 -export const HUMAN_DOCX_BATCH_WRITE_LIMIT = 5 export const HUMAN_TOOL_USE_PLAN_LIMIT = 5 export const DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS = 200_000 export const CONTEXT_COMPACTION_TRIGGER_RATIO = 0.8 diff --git a/src/main/services/ai/human/node/handNode/index.ts b/src/main/services/ai/human/node/handNode/index.ts index 1b3147ee..a3487d1b 100644 --- a/src/main/services/ai/human/node/handNode/index.ts +++ b/src/main/services/ai/human/node/handNode/index.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto' import type { JsonObject } from '../../../../../../shared/types' import { HUMAN_DEFAULT_TOOL_CALL_LIMIT, - HUMAN_DOCX_BATCH_WRITE_LIMIT, HUMAN_MAX_PARALLEL_TOOL_CALLS, HUMAN_TOOL_USE_PLAN_LIMIT } from '../../constants' @@ -28,18 +27,15 @@ import { executeToolCall } from './toolExecutor' import { returnToBrain, returnToBrainAfterTrace, returnToBrainWithDecisionHint } from '../utils/stateTransitions' const DOCX_SERIAL_WRITE_TOOL_NAMES = new Set([ + 'docx_create', 'docx_paragraph_edit', 'docx_heading_edit', 'docx_table_edit', 'docx_text_edit', 'docx_text_style_edit', - 'docx_delete' + 'docx_delete', + 'docx_rollback' ]) -type DocxContinuationAction = 'insert_before' | 'insert_after' -type DocxAnchorContinuation = { - action: DocxContinuationAction - blockId: string -} const isDocxSerialWriteCall = (toolName: string): boolean => DOCX_SERIAL_WRITE_TOOL_NAMES.has(toolName) const isTerminalRunCall = (toolName: string): boolean => toolName === 'terminal_run' @@ -50,35 +46,6 @@ const readSourcePathArg = (args: JsonObject): string | undefined => const readTerminalCommandArg = (args: JsonObject): string | undefined => typeof args.command === 'string' && args.command.trim().length > 0 ? args.command.trim() : undefined -const readDocxContinuationAction = (toolName: string, args: JsonObject): DocxContinuationAction | undefined => { - if ( - toolName !== 'docx_paragraph_edit' && - toolName !== 'docx_heading_edit' && - toolName !== 'docx_table_edit' - ) { - return undefined - } - return args.action === 'insert_before' || args.action === 'insert_after' ? args.action : undefined -} - -const rewriteDocxArgsWithResolvedBlocks = (input: { - args: JsonObject - resolvedLocators: Array<{ - field: 'blockId' | 'anchorBlockId' | 'cellBlockId' | 'blockIds' - blockIds: string[] - }> -}): JsonObject => { - const nextArgs: JsonObject = { ...input.args } - input.resolvedLocators.forEach((locator) => { - if (locator.field === 'blockIds') { - nextArgs.blockIds = locator.blockIds - return - } - nextArgs[locator.field] = locator.blockIds[0] - }) - return nextArgs -} - export const getToolExecutionMeta = (input: { toolName: string args: JsonObject @@ -110,6 +77,7 @@ const createHandPlanInvalidDecisionHint = (input: { mode: ToolUsePlan['mode'] reason: Extract['detail']['reason'] offendingToolNames: string[] + sourcePaths?: string[] callCount?: number limit?: number }): HumanDecisionHint => ({ @@ -118,6 +86,7 @@ const createHandPlanInvalidDecisionHint = (input: { mode: input.mode, reason: input.reason, offendingToolNames: input.offendingToolNames, + sourcePaths: input.sourcePaths, callCount: input.callCount, limit: input.limit } @@ -131,20 +100,40 @@ const buildToolLimitDecisionHint = (toolCallCount: number, limit: number): Human } }) +const findSameFileMultiWriteDecisionHint = ( + toolUsePlan: ToolUsePlan +): HumanDecisionHint | undefined => { + if (toolUsePlan.mode !== 'serial') return undefined + + const mutatingCallsBySourcePath = new Map() + toolUsePlan.calls.forEach((call) => { + if (!isDocxSerialWriteCall(call.toolName)) return + const sourcePath = readSourcePathArg(call.args) + if (!sourcePath) return + const existingCalls = mutatingCallsBySourcePath.get(sourcePath) || [] + mutatingCallsBySourcePath.set(sourcePath, [...existingCalls, call]) + }) + + const offendingEntries = [...mutatingCallsBySourcePath.entries()].filter( + ([, calls]) => calls.length > 1 + ) + if (offendingEntries.length === 0) return undefined + + return createHandPlanInvalidDecisionHint({ + mode: 'serial', + reason: 'docx_single_file_write_limit_exceeded', + offendingToolNames: offendingEntries.flatMap(([, calls]) => calls.map((call) => call.toolName)), + sourcePaths: offendingEntries.map(([sourcePath]) => sourcePath), + callCount: offendingEntries.reduce((total, [, calls]) => total + calls.length, 0), + limit: 1 + }) +} + const validateToolUsePlan = (toolUsePlan: ToolUsePlan): HumanDecisionHint | undefined => { if (toolUsePlan.mode === 'serial') { - const docxWriteCalls = toolUsePlan.calls.filter((item) => isDocxSerialWriteCall(item.toolName)) - if (docxWriteCalls.length > 0) { - const limit = HUMAN_DOCX_BATCH_WRITE_LIMIT - if (docxWriteCalls.length > limit) { - return createHandPlanInvalidDecisionHint({ - mode: 'serial', - reason: 'docx_batch_write_limit_exceeded', - offendingToolNames: docxWriteCalls.map((item) => item.toolName), - callCount: docxWriteCalls.length, - limit - }) - } + const sameFileMultiWriteDecisionHint = findSameFileMultiWriteDecisionHint(toolUsePlan) + if (sameFileMultiWriteDecisionHint) { + return sameFileMultiWriteDecisionHint } } if (toolUsePlan.calls.length > HUMAN_TOOL_USE_PLAN_LIMIT) { @@ -289,267 +278,18 @@ const executeToolUsePlan = async (input: { ) ) } - - const executeDocxWriteTransactionSegment = async ( - segmentCalls: ToolUsePlan['calls'] - ): Promise => { - const runtime = input.cache.docxRuntime - const firstSourcePath = readSourcePathArg(segmentCalls[0]?.args || {}) - if (!runtime || !firstSourcePath) { - const fallbackResults: ToolCallResult[] = [] - for (const segmentCall of segmentCalls) { - fallbackResults.push( - await executeToolCall({ - ctx: input.ctx, - cache: input.cache, - taskId: input.taskId, - learnedSkillIds: input.learnedSkillIds, - toolName: segmentCall.toolName, - args: segmentCall.args - }) - ) - } - return fallbackResults - } - - const replay = await runtime.ensureReplaySession({ - toolName: segmentCalls[0].toolName, - toolCallId: `human-hand-docx-serial:${input.taskId}`, - sourcePath: firstSourcePath, - reason: 'edit' - }) - if ('failure' in replay) { - return [ - { - toolName: segmentCalls[0].toolName, - ok: false, - code: replay.failure.code, - data: replay.failure, - diagnostic: { - message: replay.failure.error - }, - resourceContext: replay.failure.resourceContext - } - ] - } - - const resolved = await runtime.resolveSnapshotTargets({ - sourcePath: replay.sourcePath, - baseRevisionRef: replay.currentRevisionRef, - toolCalls: segmentCalls.map((call) => ({ - toolName: call.toolName, - args: call.args - })) - }) - if (!resolved.ok) { - return [ - { - toolName: segmentCalls[0].toolName, - ok: false, - code: 'invalid_input', - data: { - ok: false, - code: 'invalid_input', - error: resolved.error - }, - diagnostic: { - message: resolved.error - }, - resourceContext: runtime.buildResourceContext({ - sourcePath: replay.sourcePath, - effectKind: 'mutated', - currentRevisionRef: replay.currentRevisionRef - }) - } - ] - } - - const segmentResults: ToolCallResult[] = [] - const completedItems: string[] = [] - const anchorContinuationMap = new Map() - for (let callIndex = 0; callIndex < segmentCalls.length; callIndex += 1) { - const segmentCall = segmentCalls[callIndex] - const resolvedCall = resolved.resolvedCalls[callIndex] - try { - const argsWithContinuation = { ...segmentCall.args } - const continuationAction = readDocxContinuationAction(segmentCall.toolName, segmentCall.args) - const continuationEntry = - typeof argsWithContinuation.anchorBlockId === 'string' && - anchorContinuationMap.has(argsWithContinuation.anchorBlockId) - ? anchorContinuationMap.get(argsWithContinuation.anchorBlockId) - : undefined - const continuationAnchor = - continuationEntry && continuationEntry.action === continuationAction - ? continuationEntry.blockId - : undefined - if ( - (segmentCall.toolName === 'docx_paragraph_edit' || - segmentCall.toolName === 'docx_heading_edit' || - segmentCall.toolName === 'docx_table_edit') && - continuationAnchor - ) { - argsWithContinuation.anchorBlockId = continuationAnchor - } - const resolvedLocators = resolvedCall.locators.map((locator) => ({ - field: locator.field, - blockIds: continuationAnchor && locator.field === 'anchorBlockId' - ? [continuationAnchor] - : (() => { - const current = runtime.resolveLiveBlockIdsFromStableLocators({ - sourcePath: replay.sourcePath, - field: locator.field, - stableLocators: locator.stableLocators - }) - if (!current.ok) { - throw new Error(current.error) - } - return current.blockIds - })() - })) - const nextResult = await executeToolCall({ - ctx: input.ctx, - cache: input.cache, - taskId: input.taskId, - learnedSkillIds: input.learnedSkillIds, - toolName: segmentCall.toolName, - args: rewriteDocxArgsWithResolvedBlocks({ - args: argsWithContinuation, - resolvedLocators - }) - }) - segmentResults.push(nextResult) - if (nextResult.ok) { - completedItems.push(`${segmentCall.toolName}#${String(callIndex + 1)}`) - const readbackRegions = - typeof nextResult.data === 'object' && - nextResult.data && - !Array.isArray(nextResult.data) && - Array.isArray((nextResult.data as { postWriteReadback?: { regions?: Array<{ blocks?: Array<{ blockId?: string; type?: string }> }> } }).postWriteReadback?.regions) - ? (nextResult.data as { postWriteReadback?: { regions?: Array<{ blocks?: Array<{ blockId?: string; type?: string }> }> } }).postWriteReadback?.regions || [] - : [] - const lastCreatedBlockId = - readbackRegions - .flatMap((region) => region.blocks || []) - .filter((block) => block.type !== 'table_cell') - .slice(-1)[0]?.blockId || - readbackRegions.flatMap((region) => region.blocks || []).slice(-1)[0]?.blockId - if ( - lastCreatedBlockId && - typeof segmentCall.args.anchorBlockId === 'string' && - segmentCall.args.anchorBlockId.length > 0 && - continuationAction - ) { - anchorContinuationMap.set(segmentCall.args.anchorBlockId, { - action: continuationAction, - blockId: lastCreatedBlockId - }) - } - continue - } - - nextResult.data = { - ...(typeof nextResult.data === 'object' && nextResult.data !== null && !Array.isArray(nextResult.data) - ? nextResult.data - : {}), - decisionHint: { - code: 'hand_docx_batch_write_halted', - detail: { - sourcePath: replay.sourcePath, - lastSafeRevisionRef: - runtime.getReplaySession(replay.sourcePath)?.activeRevisionRef || replay.currentRevisionRef, - haltedAtCallIndex: callIndex, - completedCallCount: completedItems.length, - totalCallCount: segmentCalls.length, - haltReason: 'locator_confidence_too_low', - haltSummary: nextResult.diagnostic?.message || 'docx 批量写因定位置信度不足而停止。', - completedItems, - pendingItems: segmentCalls - .slice(callIndex + 1) - .map((call, pendingIndex) => `${call.toolName}#${String(callIndex + pendingIndex + 2)}`), - failedItem: `${segmentCall.toolName}#${String(callIndex + 1)}`, - recoveryContext: `当前工作稿已停在 ${runtime.getReplaySession(replay.sourcePath)?.activeRevisionRef || replay.currentRevisionRef};已完成写入保留,未完成写入未执行。` - } - } - } - break - } catch (error) { - segmentResults.push({ - toolName: segmentCall.toolName, - ok: false, - code: 'invalid_input', - data: { - ok: false, - code: 'invalid_input', - error: error instanceof Error ? error.message : String(error), - decisionHint: { - code: 'hand_docx_batch_write_halted', - detail: { - sourcePath: replay.sourcePath, - lastSafeRevisionRef: - runtime.getReplaySession(replay.sourcePath)?.activeRevisionRef || replay.currentRevisionRef, - haltedAtCallIndex: callIndex, - completedCallCount: completedItems.length, - totalCallCount: segmentCalls.length, - haltReason: 'locator_confidence_too_low', - haltSummary: error instanceof Error ? error.message : String(error), - completedItems, - pendingItems: segmentCalls - .slice(callIndex + 1) - .map((call, pendingIndex) => `${call.toolName}#${String(callIndex + pendingIndex + 2)}`), - failedItem: `${segmentCall.toolName}#${String(callIndex + 1)}`, - recoveryContext: `当前工作稿已停在 ${runtime.getReplaySession(replay.sourcePath)?.activeRevisionRef || replay.currentRevisionRef};已完成写入保留,未完成写入未执行。` - } - } - }, - diagnostic: { - message: error instanceof Error ? error.message : String(error) - }, - resourceContext: runtime.buildResourceContext({ - sourcePath: replay.sourcePath, - effectKind: 'mutated', - currentRevisionRef: runtime.getReplaySession(replay.sourcePath)?.activeRevisionRef - }) - }) - break - } - } - return segmentResults - } - const results: ToolCallResult[] = [] - for (let index = 0; index < input.toolUsePlan.calls.length; ) { - const toolCall = input.toolUsePlan.calls[index] - const sourcePath = readSourcePathArg(toolCall.args) - - if (sourcePath && isDocxSerialWriteCall(toolCall.toolName)) { - const segmentCalls = [toolCall] - let cursor = index + 1 - while (cursor < input.toolUsePlan.calls.length) { - const candidate = input.toolUsePlan.calls[cursor] - if ( - !isDocxSerialWriteCall(candidate.toolName) || - readSourcePathArg(candidate.args) !== sourcePath - ) { - break - } - segmentCalls.push(candidate) - cursor += 1 - } - results.push(...(await executeDocxWriteTransactionSegment(segmentCalls))) - index = cursor - } else { - results.push( - await executeToolCall({ - ctx: input.ctx, - cache: input.cache, - taskId: input.taskId, - learnedSkillIds: input.learnedSkillIds, - toolName: toolCall.toolName, - args: toolCall.args - }) - ) - index += 1 - } + for (const toolCall of input.toolUsePlan.calls) { + results.push( + await executeToolCall({ + ctx: input.ctx, + cache: input.cache, + taskId: input.taskId, + learnedSkillIds: input.learnedSkillIds, + toolName: toolCall.toolName, + args: toolCall.args + }) + ) if ( input.toolUsePlan.mode === 'serial' && diff --git a/src/main/services/ai/human/prompts/brain.ts b/src/main/services/ai/human/prompts/brain.ts index 14919513..42fc4538 100644 --- a/src/main/services/ai/human/prompts/brain.ts +++ b/src/main/services/ai/human/prompts/brain.ts @@ -165,6 +165,7 @@ export const buildBrainPrompt = (input: { - toolUsePlan.calls[*].toolName:必须严格使用当前工具说明里的精确名称。 - toolUsePlan.calls[*].args:必须是对象,字段名、必填参数和参数层级都必须与工具说明一致。 - toolUsePlan最大只允许规划${HUMAN_TOOL_USE_PLAN_LIMIT}个。 + - 禁止在一轮 hand 中出现对同一个文件多次写工具调用。 nextAction为mouth时: - 若完成用户诉求时引用了本地资料,或引用了不来自本地资料的知识以及外部资料必须在 nextAction.references 中列出这些资料,并说明来源定位与它支撑了最终交付的哪些地方。 diff --git a/src/main/services/ai/human/prompts/decisionHints.ts b/src/main/services/ai/human/prompts/decisionHints.ts index 02660199..360886b3 100644 --- a/src/main/services/ai/human/prompts/decisionHints.ts +++ b/src/main/services/ai/human/prompts/decisionHints.ts @@ -29,6 +29,10 @@ export const summarizeDecisionHint = (hint: HumanDecisionHint): string => { ? `hand 的 toolUsePlan 超出计划长度上限;tools=${renderStringList( hint.detail.offendingToolNames )}。` + : hint.detail.reason === 'docx_single_file_write_limit_exceeded' + ? `hand 的 docx 写计划在同一文件内包含多次写;sourcePaths=${renderStringList( + hint.detail.sourcePaths || [] + )};tools=${renderStringList(hint.detail.offendingToolNames)}。` : `hand 的 parallel toolUsePlan 含不可并行工具;tools=${renderStringList( hint.detail.offendingToolNames )}。` @@ -80,13 +84,18 @@ export const renderDecisionHintForBrain = (hint: HumanDecisionHint): string => { `mode=${hint.detail.mode}`, `reason=${hint.detail.reason}`, `offendingToolNames=${renderStringList(hint.detail.offendingToolNames)}`, + hint.detail.sourcePaths + ? `sourcePaths=${renderStringList(hint.detail.sourcePaths)}` + : undefined, hint.detail.limit ? `parallelLimit=${String(hint.detail.limit)}` : undefined, hint.detail.callCount ? `requestedCallCount=${String(hint.detail.callCount)}` : undefined, hint.detail.reason === 'parallel_requires_read_only' ? '如果包含写操作、需要最新执行结果,或工具不是只读并行安全,请改为 mode=serial。' : hint.detail.reason === 'tool_use_plan_limit_exceeded' ? '请缩减本轮计划中的工具调用数量,再重新决策。' - : '请缩减并行读取工具数量,或改为 mode=serial。' + : hint.detail.reason === 'docx_single_file_write_limit_exceeded' + ? '同一文件一轮只能出现一次 docx 写工具调用。请把同类内容合并进单次写调用,不要预判未来 blockId;如需继续编辑,应先看写后回读,再到后续轮次决定下一步。' + : '请缩减并行读取工具数量,或改为 mode=serial。' ] .filter(Boolean) .join('\n') diff --git a/src/main/services/ai/human/schema.ts b/src/main/services/ai/human/schema.ts index 10825d9b..d74d8d2c 100644 --- a/src/main/services/ai/human/schema.ts +++ b/src/main/services/ai/human/schema.ts @@ -148,6 +148,7 @@ export const humanDecisionHintSchema = z.discriminatedUnion('code', [ mode: humanToolUsePlanModeSchema, reason: humanHandPlanInvalidReasonSchema, offendingToolNames: z.array(z.string().min(1)), + sourcePaths: z.array(z.string().min(1)).optional(), callCount: z.number().int().min(1).optional(), limit: z.number().int().min(1).optional() }) diff --git a/src/main/services/ai/human/skills.ts b/src/main/services/ai/human/skills.ts index 1cd5ae0e..10f8d0b2 100644 --- a/src/main/services/ai/human/skills.ts +++ b/src/main/services/ai/human/skills.ts @@ -1,7 +1,6 @@ import { BUILTIN_RUNTIME_SKILL_IDS, EXTERNAL_RUNTIME_SKILL_IDS, - HUMAN_DOCX_BATCH_WRITE_LIMIT, LEARNABLE_RUNTIME_SKILL_IDS, RUNTIME_SKILL_IDS } from './constants' @@ -36,7 +35,8 @@ export const SKILL_DEFINITIONS: Record = { source: 'external', countsTowardToolBudget: true, title: 'docx-write', - description: `能力:创建 docx 文件内容,插入段落、标题、表格,替换文本,调整文本样式,删除完整块,切换编辑 revision。\n结果:生成新的 docx 编辑 revision,并返回变更信息与写后回读信息。\n边界:写入作用于当前任务内的编辑链路,最终落盘由 review / commit 链路处理;单轮连续 docx 写操作最多只能规划 ${HUMAN_DOCX_BATCH_WRITE_LIMIT} 个。`, + description: + '能力:创建 docx 文件内容,插入段落、标题、表格,替换文本,调整文本样式,删除完整块,切换编辑 revision。\n结果:生成新的 docx 编辑 revision,并返回变更信息与写后回读信息。\n边界:写入作用于当前任务内的编辑链路,最终落盘由 review / commit 链路处理。', whenToUse: '适用于需要改变 docx 内容或编辑 revision 状态的任务。', toolNames: [ 'docx_create', diff --git a/src/main/services/ai/human/tools/docx/runtime.ts b/src/main/services/ai/human/tools/docx/runtime.ts index 45217ae3..ff0cca17 100644 --- a/src/main/services/ai/human/tools/docx/runtime.ts +++ b/src/main/services/ai/human/tools/docx/runtime.ts @@ -787,17 +787,64 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool }) } + const buildOpenStatsFromReplaySnapshot = (snapshot: DocxReplaySnapshot): DocxOpenStats => { + const paragraphCount = snapshot.blocks.filter((block) => block.kind === 'paragraph').length + const tableCount = snapshot.blocks.filter((block) => block.kind === 'table').length + const charCount = snapshot.blocks.reduce((total, block) => { + if (block.kind === 'paragraph') { + return total + block.spec.runs.reduce((runTotal, run) => runTotal + run.text.length, 0) + } + return ( + total + + block.spec.rows.reduce( + (rowTotal, row) => + rowTotal + + row.reduce( + (cellTotal, cell) => + cellTotal + + cell.paragraphs.reduce( + (paragraphTotal, paragraph) => + paragraphTotal + + paragraph.runs.reduce((runTotal, run) => runTotal + run.text.length, 0), + 0 + ), + 0 + ), + 0 + ) + ) + }, 0) + + return { + blockCount: snapshot.blocks.length, + paragraphCount, + tableCount, + imageCount: 0, + charCount + } + } + + const syncReplaySessionStats = (session: DocxReplaySession): void => { + session.stats = { + ...buildOpenStatsFromReplaySnapshot(session.activeSnapshot), + imageCount: session.stats.imageCount + } + } + const buildReadSessionResultFromReplay = ( sourcePath: string, session: DocxReplaySession - ): Exclude => ({ - sourcePath, - docId: session.activeDocId, - currentRevisionRef: session.activeRevisionRef, - meta: session.meta, - stats: session.stats, - replaySession: session - }) + ): Exclude => { + syncReplaySessionStats(session) + return { + sourcePath, + docId: session.activeDocId, + currentRevisionRef: session.activeRevisionRef, + meta: session.meta, + stats: session.stats, + replaySession: session + } + } const buildReplaySessionResult = ( sourcePath: string, @@ -1272,6 +1319,7 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool }) session.activeDocId = params.nextDocId session.activeRevisionRef = currentRevisionRef + syncReplaySessionStats(session) return { currentRevisionRef, previousRevisionRef @@ -1427,6 +1475,7 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool session.activeSnapshot = baseline.activeSnapshot session.nodeById = baseline.nodeById session.bodyNodeIds = baseline.bodyNodeIds + syncReplaySessionStats(session) } const activateRevision = async (params: { @@ -1442,6 +1491,7 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool await rebuildReplaySessionAtRevision(session, params.targetRevisionRef) session.activeDocId = params.targetDocId session.activeRevisionRef = params.targetRevisionRef + syncReplaySessionStats(session) return { previousRevisionRef, currentRevisionRef: params.targetRevisionRef diff --git a/tests/ai/tool/docx/docx-replay-stats-track-active-revision.test.ts b/tests/ai/tool/docx/docx-replay-stats-track-active-revision.test.ts new file mode 100644 index 00000000..61ec0676 --- /dev/null +++ b/tests/ai/tool/docx/docx-replay-stats-track-active-revision.test.ts @@ -0,0 +1,94 @@ +import { describe, it } from 'vitest' +import { + createDocxToolHarness, + writeTestDocxFile +} from '../../../harness/backend/aiToolHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' +import type { DocxMutationResult, DocxOutlineResult } from './testUtils' +import { findParagraphBlockIdByText } from './testUtils' + +describe('ai tool docx backend', () => { + it('docx-replay-stats-track-active-revision', async () => { + await runBackendCase({ + caseId: 'docx-replay-stats-track-active-revision', + harnessMode: 'tool-real', + domain: 'ai', + suite: 'tool', + groups: ['docx'], + description: '验证 replay session 的 stats 会随 active revision 前进而同步刷新。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + await writeTestDocxFile(runtime.workspaceRoot, 'replay-stats-active-revision.docx', [ + { type: 'paragraph', text: '开头段落' }, + { type: 'paragraph', text: '锚点段落' } + ]) + + const harness = createDocxToolHarness(runtime) + try { + const initialOutline = await harness.invoke('docx_outline', { + sourcePath: 'replay-stats-active-revision.docx', + ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 20 }] + }) + const anchorBlockId = findParagraphBlockIdByText(initialOutline.blocks, '锚点段落') + + const insertResult = await harness.invoke('docx_paragraph_edit', { + sourcePath: 'replay-stats-active-revision.docx', + anchorBlockId, + action: 'insert_after', + paragraphs: ['新增段落'] + }) + const statsAfterInsert = + harness.state.replaySessionBySourcePath.get(insertResult.sourcePath)?.stats + + const rewriteResult = await harness.invoke('docx_text_edit', { + sourcePath: 'replay-stats-active-revision.docx', + query: '新增段落', + replace: '新增段落二稿' + }) + const statsAfterRewrite = + harness.state.replaySessionBySourcePath.get(rewriteResult.sourcePath)?.stats + + const finalOutline = await harness.invoke('docx_outline', { + sourcePath: 'replay-stats-active-revision.docx', + ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 20 }] + }) + + return { + insertResult, + rewriteResult, + statsAfterInsert, + statsAfterRewrite, + finalOutline + } + } finally { + await harness.release() + } + }, + assert: async (result, runtime) => { + runtime.assertThat( + result.insertResult.currentRevisionRef === 'v2' && + result.rewriteResult.currentRevisionRef === 'v3' && + result.statsAfterInsert?.blockCount === 3 && + result.statsAfterRewrite?.blockCount === 3 && + result.finalOutline.blocks.length === 3 && + result.finalOutline.blocks.some( + (block) => block.type === 'paragraph' && block.text === '新增段落二稿' + ), + { + name: 'replay-stats-track-active-revision', + message: + '每次 registerRevision 后,replay stats 都应与当前 active snapshot 对齐,而不是残留旧 revision 的 blockCount。', + data: { + insertResult: result.insertResult, + rewriteResult: result.rewriteResult, + statsAfterInsert: result.statsAfterInsert, + statsAfterRewrite: result.statsAfterRewrite, + finalOutline: result.finalOutline.blocks + } + } + ) + } + }) + }) +}) diff --git a/tests/ai/tool/docx/docx-write-then-outline-percent-sees-new-block.test.ts b/tests/ai/tool/docx/docx-write-then-outline-percent-sees-new-block.test.ts new file mode 100644 index 00000000..23a761c8 --- /dev/null +++ b/tests/ai/tool/docx/docx-write-then-outline-percent-sees-new-block.test.ts @@ -0,0 +1,84 @@ +import { describe, it } from 'vitest' +import { + createDocxToolHarness, + writeTestDocxFile +} from '../../../harness/backend/aiToolHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' +import type { DocxMutationResult, DocxOutlineResult } from './testUtils' +import { findParagraphBlockIdByText } from './testUtils' + +describe('ai tool docx backend', () => { + it('docx-write-then-outline-percent-sees-new-block', async () => { + await runBackendCase({ + caseId: 'docx-write-then-outline-percent-sees-new-block', + harnessMode: 'tool-real', + domain: 'ai', + suite: 'tool', + groups: ['docx'], + description: '验证 docx 写后立刻用 percent 读取 outline 时,能看到当前 revision 新增块。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + await writeTestDocxFile(runtime.workspaceRoot, 'outline-percent-after-write.docx', [ + { type: 'heading', text: '复习总结', level: 1 }, + { type: 'paragraph', text: '导语段落。' } + ]) + + const harness = createDocxToolHarness(runtime) + try { + const beforeOutline = await harness.invoke('docx_outline', { + sourcePath: 'outline-percent-after-write.docx', + ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }] + }) + const anchorBlockId = findParagraphBlockIdByText(beforeOutline.blocks, '导语段落') + + const writeResult = await harness.invoke('docx_heading_edit', { + sourcePath: 'outline-percent-after-write.docx', + anchorBlockId, + action: 'insert_after', + headings: [{ level: 2, text: '第一部分:深度学习' }] + }) + + const afterOutline = await harness.invoke('docx_outline', { + sourcePath: 'outline-percent-after-write.docx', + ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }] + }) + + return { + beforeOutline, + writeResult, + afterOutline, + replayStats: + harness.state.replaySessionBySourcePath.get(writeResult.sourcePath)?.stats + } + } finally { + await harness.release() + } + }, + assert: async (result, runtime) => { + runtime.assertThat( + result.beforeOutline.blocks.length === 2 && + result.writeResult.createdBlocks?.some((block) => block.blockId === 'b:2') && + result.afterOutline.blocks.some( + (block) => + block.type === 'paragraph' && + block.blockId === 'b:2' && + block.text === '第一部分:深度学习' + ) && + result.replayStats?.blockCount === 3, + { + name: 'outline-percent-sees-new-block-after-write', + message: + '写后同 revision 的 percent outline 应立刻包含新增块,且 replay stats.blockCount 必须同步到当前真实块数。', + data: { + beforeOutline: result.beforeOutline.blocks, + writeResult: result.writeResult, + afterOutline: result.afterOutline.blocks, + replayStats: result.replayStats + } + } + ) + } + }) + }) +}) diff --git a/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts b/tests/ai/tool/hand/hand-docx-multi-file-single-write-allowed.test.ts similarity index 54% rename from tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts rename to tests/ai/tool/hand/hand-docx-multi-file-single-write-allowed.test.ts index 30ebab64..4c8fc294 100644 --- a/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts +++ b/tests/ai/tool/hand/hand-docx-multi-file-single-write-allowed.test.ts @@ -1,56 +1,42 @@ import { describe, it } from 'vitest' import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' -import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' +import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor' import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema' +import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' import { writeTestDocxFile } from '../../../harness/backend/aiToolHarness' import { runBackendCase } from '../../../harness/backend/run-backend-case' -import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor' import type { DocxOutlineResult } from '../docx/testUtils' describe('ai tool hand backend', () => { - it('hand-docx-serial-snapshot-bound-edit-plan-invalid-target', async () => { + it('hand-docx-multi-file-single-write-allowed', async () => { await runBackendCase({ - caseId: 'hand-docx-serial-snapshot-bound-edit-plan-invalid-target', + caseId: 'hand-docx-multi-file-single-write-allowed', harnessMode: 'tool-real', domain: 'ai', suite: 'tool', groups: ['hand'], - description: '验证事务段中若后续目标已被前序操作删除,应明确失败并中止,不允许 silent fallback。', + description: '验证一轮 hand 内对不同 sourcePath 各写一次可以通过。', fixtures: [], runMode: 'single', execute: async (runtime) => { - await writeTestDocxFile(runtime.workspaceRoot, 'invalid-target.docx', [ - { type: 'paragraph', text: 'Alpha' }, + await writeTestDocxFile(runtime.workspaceRoot, 'multi-file-a.docx', [ + { type: 'paragraph', text: 'Alpha' } + ]) + await writeTestDocxFile(runtime.workspaceRoot, 'multi-file-b.docx', [ { type: 'paragraph', text: 'Beta' } ]) const ctx = runtime.createAiRunContext() const cache = createHumanRuntimeCache() - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' }) ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' }) - const initialOutline = await executeToolCall({ - ctx, - cache, - taskId: runtime.taskId, - learnedSkillIds: ['docx-read', 'docx-write'], - toolName: 'docx_outline', - args: { - sourcePath: 'invalid-target.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }] - } - }) - const outlineData = initialOutline.data as DocxOutlineResult - const alphaBlockId = - outlineData.blocks.find((block) => block.type === 'paragraph' && block.text.includes('Alpha'))?.blockId || '' - const handNode = createHandNode({ ctx, cache }) const state = await handNode({ ...createInitialHumanState({ taskId: runtime.taskId, - userInput: '请连续删除再修改同一段' + userInput: '分别更新两个文档' }), skills: { learnedSkillIds: ['docx-read', 'docx-write'] @@ -58,11 +44,11 @@ describe('ai tool hand backend', () => { execution: { ...createInitialHumanState({ taskId: runtime.taskId, - userInput: '请连续删除再修改同一段' + userInput: '分别更新两个文档' }).execution, currentThought: { thought: { - next: '先删再改同一目标' + next: '同轮对两个 docx 各写一次' }, nextAction: { action: 'hand', @@ -70,19 +56,19 @@ describe('ai tool hand backend', () => { mode: 'serial', calls: [ { - toolName: 'docx_delete', + toolName: 'docx_text_edit', args: { - sourcePath: 'invalid-target.docx', - blockIds: [alphaBlockId] + sourcePath: 'multi-file-a.docx', + query: 'Alpha', + replace: 'Alpha updated' } }, { toolName: 'docx_text_edit', args: { - sourcePath: 'invalid-target.docx', - query: 'Alpha', - replace: 'Should fail', - blockIds: [alphaBlockId] + sourcePath: 'multi-file-b.docx', + query: 'Beta', + replace: 'Beta updated' } } ] @@ -92,31 +78,52 @@ describe('ai tool hand backend', () => { } }) + const outlineA = await executeToolCall({ + ctx, + cache, + taskId: runtime.taskId, + learnedSkillIds: ['docx-read', 'docx-write'], + toolName: 'docx_outline', + args: { + sourcePath: 'multi-file-a.docx', + ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 10 }] + } + }) + const outlineB = await executeToolCall({ + ctx, + cache, + taskId: runtime.taskId, + learnedSkillIds: ['docx-read', 'docx-write'], + toolName: 'docx_outline', + args: { + sourcePath: 'multi-file-b.docx', + ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 10 }] + } + }) + return { - state + state, + outlineA, + outlineB } }, assert: async (result, runtime) => { - const results = result.state.execution.lastToolResults || [] - const haltedHint = - typeof results[1]?.data === 'object' && - results[1]?.data && - !Array.isArray(results[1]?.data) && - 'decisionHint' in results[1].data - ? (results[1].data as { decisionHint?: { code?: string } }).decisionHint - : undefined + const outlineA = result.outlineA.data as DocxOutlineResult + const outlineB = result.outlineB.data as DocxOutlineResult runtime.assertThat( - results.length === 2 && - results[0]?.code === 'ok' && - results[1]?.code === 'invalid_input' && - haltedHint?.code === 'hand_docx_batch_write_halted' && - result.state.execution.pendingDecisionHint?.code === 'hand_docx_batch_write_halted', + result.state.execution.pendingDecisionHint === undefined && + result.state.execution.lastToolResults?.length === 2 && + result.state.execution.lastToolResults.every((item) => item.code === 'ok') && + outlineA.blocks.some((block) => block.type === 'paragraph' && block.text === 'Alpha updated') && + outlineB.blocks.some((block) => block.type === 'paragraph' && block.text === 'Beta updated'), { - name: 'invalid-target-halts-transaction-with-decision-hint', - message: '事务段内后续目标若已无法安全恢复,应停止后续写入并通过 halted decision hint 把现状交还 brain。', + name: 'multi-file-single-write-plan-is-allowed', + message: + '不同 sourcePath 各出现一次 docx 写应允许通过,且各自推进独立 revision。', data: { - lastToolResults: results, - decisionHint: result.state.execution.pendingDecisionHint + lastToolResults: result.state.execution.lastToolResults, + outlineA: outlineA.blocks, + outlineB: outlineB.blocks } } ) diff --git a/tests/ai/tool/hand/hand-docx-same-file-read-write-read-allowed.test.ts b/tests/ai/tool/hand/hand-docx-same-file-read-write-read-allowed.test.ts new file mode 100644 index 00000000..876e786f --- /dev/null +++ b/tests/ai/tool/hand/hand-docx-same-file-read-write-read-allowed.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from 'vitest' +import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' +import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' +import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' +import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema' +import { writeTestDocxFile } from '../../../harness/backend/aiToolHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' + +describe('ai tool hand backend', () => { + it('hand-docx-same-file-read-write-read-allowed', async () => { + await runBackendCase({ + caseId: 'hand-docx-same-file-read-write-read-allowed', + harnessMode: 'tool-real', + domain: 'ai', + suite: 'tool', + groups: ['hand'], + description: '验证同一文件同轮 read -> write -> read 允许通过,限制只作用于多次写。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + await writeTestDocxFile(runtime.workspaceRoot, 'same-file-read-write-read.docx', [ + { type: 'paragraph', text: 'Alpha' } + ]) + + const ctx = runtime.createAiRunContext() + const cache = createHumanRuntimeCache() + ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' }) + ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' }) + + const handNode = createHandNode({ ctx, cache }) + const state = await handNode({ + ...createInitialHumanState({ + taskId: runtime.taskId, + userInput: '读取后修改再读取' + }), + skills: { + learnedSkillIds: ['docx-read', 'docx-write'] + }, + execution: { + ...createInitialHumanState({ + taskId: runtime.taskId, + userInput: '读取后修改再读取' + }).execution, + currentThought: { + thought: { + next: '先读,再写,再读同一 docx' + }, + nextAction: { + action: 'hand', + toolUsePlan: { + mode: 'serial', + calls: [ + { + toolName: 'docx_outline', + args: { + sourcePath: 'same-file-read-write-read.docx', + ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 10 }] + } + }, + { + toolName: 'docx_text_edit', + args: { + sourcePath: 'same-file-read-write-read.docx', + query: 'Alpha', + replace: 'Alpha updated' + } + }, + { + toolName: 'docx_read_blocks', + args: { + sourcePath: 'same-file-read-write-read.docx', + range: { type: 'block_ids', blockIds: ['b:0'] } + } + } + ] + } + } + } + } + }) + + return { + state + } + }, + assert: async (result, runtime) => { + const results = result.state.execution.lastToolResults || [] + runtime.assertThat( + result.state.execution.pendingDecisionHint === undefined && + results.length === 3 && + results.map((item) => item.toolName).join(',') === + 'docx_outline,docx_text_edit,docx_read_blocks' && + results.every((item) => item.code === 'ok') && + typeof results[2]?.data === 'object' && + results[2]?.data !== null && + JSON.stringify(results[2].data).includes('Alpha updated'), + { + name: 'same-file-read-write-read-plan-is-allowed', + message: '同一文件的多次读加一次写应允许通过,限制只针对同一文件多次写。', + data: { + lastToolResults: results + } + } + ) + } + }) + }) +}) diff --git a/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan.test.ts b/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan.test.ts deleted file mode 100644 index ca80aad5..00000000 --- a/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it } from 'vitest' -import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' -import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' -import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' -import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema' -import { createDocxToolHarness, writeTestDocxFile } from '../../../harness/backend/aiToolHarness' -import { runBackendCase } from '../../../harness/backend/run-backend-case' -import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor' -import type { DocxOutlineResult } from '../docx/testUtils' - -describe('ai tool hand backend', () => { - it('hand-docx-serial-snapshot-bound-edit-plan', async () => { - await runBackendCase({ - caseId: 'hand-docx-serial-snapshot-bound-edit-plan', - harnessMode: 'tool-real', - domain: 'ai', - suite: 'tool', - groups: ['hand'], - description: '验证同文件 docx 串行结构写会绑定段起点快照,而不是让旧 blockId 随结构漂移误命中。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - await writeTestDocxFile(runtime.workspaceRoot, 'snapshot-bound.docx', [ - { type: 'paragraph', text: 'Alpha' }, - { type: 'paragraph', text: 'Beta' }, - { type: 'paragraph', text: 'Gamma' } - ]) - - const harness = createDocxToolHarness(runtime) - const outline = await harness.invoke('docx_outline', { - sourcePath: 'snapshot-bound.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }] - }) - await harness.release() - - const alphaBlockId = outline.blocks.find((block) => block.type === 'paragraph' && block.text.includes('Alpha'))?.blockId || '' - const betaBlockId = outline.blocks.find((block) => block.type === 'paragraph' && block.text.includes('Beta'))?.blockId || '' - - const ctx = runtime.createAiRunContext() - const cache = createHumanRuntimeCache() - - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' }) - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' }) - - const handNode = createHandNode({ ctx, cache }) - const state = await handNode({ - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请按计划连续修改同一 docx' - }), - skills: { - learnedSkillIds: ['docx-read', 'docx-write'] - }, - execution: { - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请按计划连续修改同一 docx' - }).execution, - currentThought: { - thought: { - next: '执行同文件结构写事务段' - }, - nextAction: { - action: 'hand', - toolUsePlan: { - mode: 'serial', - calls: [ - { - toolName: 'docx_paragraph_edit', - args: { - sourcePath: 'snapshot-bound.docx', - action: 'insert_before', - anchorBlockId: alphaBlockId, - paragraphs: ['Inserted before Alpha'] - } - }, - { - toolName: 'docx_text_edit', - args: { - sourcePath: 'snapshot-bound.docx', - query: 'Beta', - replace: 'Beta rewritten', - blockIds: [betaBlockId] - } - } - ] - } - } - } - } - }) - - const updatedOutline = await executeToolCall({ - ctx, - cache, - taskId: runtime.taskId, - learnedSkillIds: ['docx-read', 'docx-write'], - toolName: 'docx_outline', - args: { - sourcePath: 'snapshot-bound.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }] - } - }) - - return { - state, - updatedOutline - } - }, - assert: async (result, runtime) => { - const outlineData = result.updatedOutline.data as DocxOutlineResult - const paragraphTexts = outlineData.blocks - .filter((block) => block.type === 'paragraph') - .map((block) => block.text) - - runtime.assertThat( - paragraphTexts[0] === 'Inserted before Alpha' && - paragraphTexts[1] === 'Alpha' && - paragraphTexts[2] === 'Beta rewritten' && - paragraphTexts[3] === 'Gamma', - { - name: 'serial-docx-writes-stay-bound-to-base-snapshot', - message: '后续旧 blockId 应继续命中段开始时的原始节点,而不是在前序插入后漂移误改。', - data: { - paragraphTexts, - updatedOutline: result.updatedOutline, - lastToolResults: result.state.execution.lastToolResults - } - } - ) - } - }) - }) -}) diff --git a/tests/ai/tool/hand/hand-docx-serial-successor-chain.test.ts b/tests/ai/tool/hand/hand-docx-serial-successor-chain.test.ts deleted file mode 100644 index 4dd9bf6e..00000000 --- a/tests/ai/tool/hand/hand-docx-serial-successor-chain.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it } from 'vitest' -import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' -import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' -import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' -import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema' -import { createDocxToolHarness, writeTestDocxFile } from '../../../harness/backend/aiToolHarness' -import { runBackendCase } from '../../../harness/backend/run-backend-case' -import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor' -import type { DocxOutlineResult } from '../docx/testUtils' - -describe('ai tool hand backend', () => { - it('hand-docx-serial-successor-chain', async () => { - await runBackendCase({ - caseId: 'hand-docx-serial-successor-chain', - harnessMode: 'tool-real', - domain: 'ai', - suite: 'tool', - groups: ['hand'], - description: '验证同一批 docx 串行写中,replace 后继续 insert_after 同锚点时能通过 live resolve 与 continuation 继续成功。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - await writeTestDocxFile(runtime.workspaceRoot, 'successor-chain.docx', [ - { type: 'paragraph', text: '项目对外同步周报' }, - { type: 'paragraph', text: '二、主要风险与当前卡点' }, - { type: 'paragraph', text: '主要风险:RISK_TODO' } - ]) - - const harness = createDocxToolHarness(runtime) - const outline = await harness.invoke('docx_outline', { - sourcePath: 'successor-chain.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }] - }) - await harness.release() - - const riskBlockId = - outline.blocks.find( - (block) => block.type === 'paragraph' && block.text.includes('主要风险:RISK_TODO') - )?.blockId || '' - - const ctx = runtime.createAiRunContext() - const cache = createHumanRuntimeCache() - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' }) - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' }) - - const handNode = createHandNode({ ctx, cache }) - const state = await handNode({ - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请完成风险周报剩余章节' - }), - skills: { - learnedSkillIds: ['docx-read', 'docx-write'] - }, - execution: { - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请完成风险周报剩余章节' - }).execution, - currentThought: { - thought: { - next: '串行补全风险周报章节' - }, - nextAction: { - action: 'hand', - toolUsePlan: { - mode: 'serial', - calls: [ - { - toolName: 'docx_text_edit', - args: { - sourcePath: 'successor-chain.docx', - query: '主要风险:RISK_TODO', - replace: '主要风险:最后一份合规附件仍在内部确认流程中。', - blockIds: [riskBlockId] - } - }, - { - toolName: 'docx_paragraph_edit', - args: { - sourcePath: 'successor-chain.docx', - action: 'insert_after', - anchorBlockId: riskBlockId, - paragraphs: ['三、负责人及协同分工'] - } - }, - { - toolName: 'docx_paragraph_edit', - args: { - sourcePath: 'successor-chain.docx', - action: 'insert_after', - anchorBlockId: riskBlockId, - paragraphs: ['四、下周动作'] - } - } - ] - } - } - } - } - }) - - const updatedOutline = await executeToolCall({ - ctx, - cache, - taskId: runtime.taskId, - learnedSkillIds: ['docx-read', 'docx-write'], - toolName: 'docx_outline', - args: { - sourcePath: 'successor-chain.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }] - } - }) - - return { - state, - updatedOutline - } - }, - assert: async (result, runtime) => { - const outline = result.updatedOutline.data as DocxOutlineResult - const previews = outline.blocks - .filter((block) => block.type === 'paragraph') - .map((block) => block.text) - runtime.assertThat( - result.state.execution.lastToolResults?.length === 3 && - result.state.execution.lastToolResults.every((item) => item.code === 'ok') && - previews.includes('主要风险:最后一份合规附件仍在内部确认流程中。') && - previews.includes('三、负责人及协同分工') && - previews.includes('四、下周动作'), - { - name: 'serial-successor-chain-completes-with-live-resolve', - message: '同批 replace 后继续 insert_after 同锚点时,应通过 successor/live resolve 与 continuation 继续成功,而不是因旧 blockId 失效中断。', - data: { - lastToolResults: result.state.execution.lastToolResults, - decisionHint: result.state.execution.pendingDecisionHint, - outline: outline.blocks - } - } - ) - } - }) - }) -}) diff --git a/tests/ai/tool/hand/hand-docx-batch-write-limit.test.ts b/tests/ai/tool/hand/hand-docx-single-file-multi-write-invalid.test.ts similarity index 54% rename from tests/ai/tool/hand/hand-docx-batch-write-limit.test.ts rename to tests/ai/tool/hand/hand-docx-single-file-multi-write-invalid.test.ts index 3d937639..621f24b7 100644 --- a/tests/ai/tool/hand/hand-docx-batch-write-limit.test.ts +++ b/tests/ai/tool/hand/hand-docx-single-file-multi-write-invalid.test.ts @@ -1,5 +1,4 @@ import { describe, it } from 'vitest' -import { HUMAN_DOCX_BATCH_WRITE_LIMIT } from '../../../../src/main/services/ai/human/constants' import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' @@ -7,14 +6,14 @@ import { createInitialHumanState } from '../../../../src/main/services/ai/human/ import { runBackendCase } from '../../../harness/backend/run-backend-case' describe('ai tool hand backend', () => { - it('hand-docx-batch-write-limit', async () => { + it('hand-docx-single-file-multi-write-invalid', async () => { await runBackendCase({ - caseId: 'hand-docx-batch-write-limit', + caseId: 'hand-docx-single-file-multi-write-invalid', harnessMode: 'tool-real', domain: 'ai', suite: 'tool', groups: ['hand'], - description: '验证 docx 串行批量写超过单轮上限时会在执行前被阻止。', + description: '验证同一文件一轮 hand 内出现两次 docx 写时,会在执行前被阻止。', fixtures: [], runMode: 'single', execute: async (runtime) => { @@ -27,7 +26,7 @@ describe('ai tool hand backend', () => { const state = await handNode({ ...createInitialHumanState({ taskId: runtime.taskId, - userInput: '请批量写入 docx' + userInput: '请在同一文档里连续写两次' }), skills: { learnedSkillIds: ['docx-write'] @@ -35,25 +34,36 @@ describe('ai tool hand backend', () => { execution: { ...createInitialHumanState({ taskId: runtime.taskId, - userInput: '请批量写入 docx' + userInput: '请在同一文档里连续写两次' }).execution, currentThought: { thought: { - next: '尝试提交超限 docx 批量写' + next: '对同一 docx 连续执行两次写' }, nextAction: { action: 'hand', toolUsePlan: { mode: 'serial', - calls: Array.from({ length: HUMAN_DOCX_BATCH_WRITE_LIMIT + 1 }, (_, index) => ({ - toolName: 'docx_text_edit', - args: { - sourcePath: 'batch-limit.docx', - query: `Paragraph ${String(index)}`, - replace: `Paragraph ${String(index)} updated`, - blockIds: [`b:${String(index)}`] + calls: [ + { + toolName: 'docx_heading_edit', + args: { + sourcePath: 'same-file-invalid.docx', + anchorBlockId: 'b:1', + action: 'insert_after', + headings: [{ level: 2, text: '第一部分' }] + } + }, + { + toolName: 'docx_paragraph_edit', + args: { + sourcePath: 'same-file-invalid.docx', + anchorBlockId: 'b:2', + action: 'insert_after', + paragraphs: ['补充说明'] + } } - })) + ] } } } @@ -67,11 +77,19 @@ describe('ai tool hand backend', () => { assert: async (result, runtime) => { runtime.assertThat( result.state.execution.pendingDecisionHint?.code === 'hand_plan_invalid' && - result.state.execution.pendingDecisionHint.detail.reason === 'docx_batch_write_limit_exceeded' && - result.state.execution.pendingDecisionHint.detail.limit === HUMAN_DOCX_BATCH_WRITE_LIMIT, + result.state.execution.pendingDecisionHint.detail.reason === + 'docx_single_file_write_limit_exceeded' && + result.state.execution.pendingDecisionHint.detail.limit === 1 && + result.state.execution.pendingDecisionHint.detail.sourcePaths?.includes( + 'same-file-invalid.docx' + ) && + result.state.execution.pendingDecisionHint.detail.offendingToolNames.join(',') === + 'docx_heading_edit,docx_paragraph_edit' && + (result.state.execution.lastToolResults || []).length === 0, { - name: 'docx-batch-write-limit-blocks-oversized-plan', - message: `纯 docx 段落批量写超过 ${String(HUMAN_DOCX_BATCH_WRITE_LIMIT)} 个时,应在执行前直接阻止。`, + name: 'same-file-multi-write-plan-is-rejected', + message: + '同一文件在一轮 hand 中出现第 2 次 docx 写时,应直接返回 hand_plan_invalid,并带稳定 decision hint。', data: { decisionHint: result.state.execution.pendingDecisionHint, lastToolResults: result.state.execution.lastToolResults diff --git a/tests/ai/tool/hand/hand-docx-table-cell-stable-target.test.ts b/tests/ai/tool/hand/hand-docx-table-cell-stable-target.test.ts deleted file mode 100644 index 0a4c1b82..00000000 --- a/tests/ai/tool/hand/hand-docx-table-cell-stable-target.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it } from 'vitest' -import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' -import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' -import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor' -import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema' -import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' -import { createDocxToolHarness, writeTestDocxFile } from '../../../harness/backend/aiToolHarness' -import { runBackendCase } from '../../../harness/backend/run-backend-case' -import type { DocxOutlineResult, DocxSearchResult } from '../docx/testUtils' -import { findParagraphBlockIdByText, findTableCellBlockIdByText } from '../docx/testUtils' - -describe('ai tool hand backend', () => { - it('hand-docx-table-cell-stable-target', async () => { - await runBackendCase({ - caseId: 'hand-docx-table-cell-stable-target', - harnessMode: 'tool-real', - domain: 'ai', - suite: 'tool', - groups: ['hand'], - description: '验证同批 docx 串行写中,前序结构插入后仍可基于 stable locator 恢复原表格 cell 并继续写入。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - await writeTestDocxFile(runtime.workspaceRoot, 'table-cell-stable-target.docx', [ - { type: 'paragraph', text: '开头段落' }, - { type: 'paragraph', text: '插入锚点段落' }, - { - type: 'table', - rows: [ - ['Topic', 'Owner'], - ['Risk', 'Alice'] - ] - } - ]) - - const harness = createDocxToolHarness(runtime) - const outline = await harness.invoke('docx_outline', { - sourcePath: 'table-cell-stable-target.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 49 }] - }) - await harness.release() - - const anchorBlockId = findParagraphBlockIdByText(outline.blocks, '插入锚点段落') - const ownerCellBlockId = findTableCellBlockIdByText(outline.blocks, 'Alice') - - const ctx = runtime.createAiRunContext() - const cache = createHumanRuntimeCache() - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' }) - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' }) - - const handNode = createHandNode({ ctx, cache }) - const state = await handNode({ - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请插入说明并更新负责人' - }), - skills: { - learnedSkillIds: ['docx-read', 'docx-write'] - }, - execution: { - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请插入说明并更新负责人' - }).execution, - currentThought: { - thought: { - next: '先插段落再更新表格单元格' - }, - nextAction: { - action: 'hand', - toolUsePlan: { - mode: 'serial', - calls: [ - { - toolName: 'docx_paragraph_edit', - args: { - sourcePath: 'table-cell-stable-target.docx', - action: 'insert_before', - anchorBlockId, - paragraphs: ['新增说明段落'] - } - }, - { - toolName: 'docx_table_edit', - args: { - sourcePath: 'table-cell-stable-target.docx', - action: 'update_cell', - cellBlockId: ownerCellBlockId, - text: 'Bob' - } - } - ] - } - } - } - } - }) - - const updatedOwnerSearch = await executeToolCall({ - ctx, - cache, - taskId: runtime.taskId, - learnedSkillIds: ['docx-read', 'docx-write'], - toolName: 'docx_search', - args: { - sourcePath: 'table-cell-stable-target.docx', - query: 'Bob' - } - }) - - return { - state, - updatedOwnerSearch - } - }, - assert: async (result, runtime) => { - const search = result.updatedOwnerSearch.data as DocxSearchResult - runtime.assertThat( - result.state.execution.lastToolResults?.length === 2 && - result.state.execution.lastToolResults.every((item) => item.code === 'ok') && - result.state.execution.pendingDecisionHint === undefined && - typeof result.state.execution.lastToolResults?.[0]?.data === 'object' && - result.state.execution.lastToolResults?.[0]?.data !== null && - search.matches.some((match) => match.type === 'table_cell' && match.text === 'Bob'), - { - name: 'table-cell-stable-target-resolves-after-structure-shift', - message: '前序插入导致 blockIndex 漂移后,旧 cellBlockId 仍应通过 stable locator 恢复到新表格 cell,而不是误落到 table block 或直接跳过。', - data: { - lastToolResults: result.state.execution.lastToolResults, - search - } - } - ) - } - }) - }) -}) diff --git a/tests/ai/tool/hand/hand-docx-table-mixed-chain.test.ts b/tests/ai/tool/hand/hand-docx-table-mixed-chain.test.ts deleted file mode 100644 index ab4a139e..00000000 --- a/tests/ai/tool/hand/hand-docx-table-mixed-chain.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it } from 'vitest' -import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode' -import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader' -import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor' -import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema' -import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache' -import { createDocxToolHarness, writeTestDocxFile } from '../../../harness/backend/aiToolHarness' -import { runBackendCase } from '../../../harness/backend/run-backend-case' -import type { DocxOutlineResult } from '../docx/testUtils' -import { findParagraphBlockIdByText, findTableCellBlockIdByText } from '../docx/testUtils' - -describe('ai tool hand backend', () => { - it('hand-docx-table-mixed-chain', async () => { - await runBackendCase({ - caseId: 'hand-docx-table-mixed-chain', - harnessMode: 'tool-real', - domain: 'ai', - suite: 'tool', - groups: ['hand'], - description: - '验证同批 docx 串行写在混合段落与表格操作时,既能恢复旧 table cell,也不会把 insert_before 的 continuation 错续到 insert_after。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - await writeTestDocxFile(runtime.workspaceRoot, 'table-mixed-chain.docx', [ - { type: 'paragraph', text: '一、风险综述' }, - { type: 'paragraph', text: '二、风险跟进说明' }, - { - type: 'table', - rows: [ - ['事项', '负责人'], - ['供应商补件', 'Alice'] - ] - }, - { type: 'paragraph', text: '三、结论占位' } - ]) - - const harness = createDocxToolHarness(runtime) - const outline = await harness.invoke('docx_outline', { - sourcePath: 'table-mixed-chain.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }], - - }) - await harness.release() - - const anchorBlockId = findParagraphBlockIdByText(outline.blocks, '二、风险跟进说明') - const ownerCellBlockId = findTableCellBlockIdByText(outline.blocks, 'Alice') - - const ctx = runtime.createAiRunContext() - const cache = createHumanRuntimeCache() - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' }) - ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' }) - - const handNode = createHandNode({ ctx, cache }) - const state = await handNode({ - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请补全这份风险说明' - }), - skills: { - learnedSkillIds: ['docx-read', 'docx-write'] - }, - execution: { - ...createInitialHumanState({ - taskId: runtime.taskId, - userInput: '请补全这份风险说明' - }).execution, - currentThought: { - thought: { - next: '混合写入段落与表格' - }, - nextAction: { - action: 'hand', - toolUsePlan: { - mode: 'serial', - calls: [ - { - toolName: 'docx_paragraph_edit', - args: { - sourcePath: 'table-mixed-chain.docx', - action: 'insert_before', - anchorBlockId, - paragraphs: ['补充说明:供应商附件仍待法务确认。'] - } - }, - { - toolName: 'docx_table_edit', - args: { - sourcePath: 'table-mixed-chain.docx', - action: 'update_cell', - cellBlockId: ownerCellBlockId, - text: 'Bob' - } - }, - { - toolName: 'docx_paragraph_edit', - args: { - sourcePath: 'table-mixed-chain.docx', - action: 'insert_after', - anchorBlockId, - paragraphs: ['结论:本周先完成补件闭环,再推进外发。'] - } - } - ] - } - } - } - } - }) - - const updatedOutline = await executeToolCall({ - ctx, - cache, - taskId: runtime.taskId, - learnedSkillIds: ['docx-read', 'docx-write'], - toolName: 'docx_outline', - args: { - sourcePath: 'table-mixed-chain.docx', - ranges: [{ type: 'block_index', fromBlockIndex: 0, toBlockIndex: 19 }] - } - }) - - return { - state, - updatedOutline - } - }, - assert: async (result, runtime) => { - const outline = result.updatedOutline.data as DocxOutlineResult - const paragraphPreviews = outline.blocks - .filter((block) => block.type === 'paragraph') - .map((block) => block.text) - const supplementIndex = paragraphPreviews.indexOf('补充说明:供应商附件仍待法务确认。') - const anchorIndex = paragraphPreviews.indexOf('二、风险跟进说明') - const conclusionIndex = paragraphPreviews.indexOf('结论:本周先完成补件闭环,再推进外发。') - - runtime.assertThat( - result.state.execution.lastToolResults?.length === 3 && - result.state.execution.lastToolResults.every((item) => item.code === 'ok') && - result.state.execution.pendingDecisionHint === undefined && - typeof result.state.execution.lastToolResults?.[0]?.data === 'object' && - result.state.execution.lastToolResults?.[0]?.data !== null && - supplementIndex !== -1 && - anchorIndex !== -1 && - conclusionIndex !== -1 && - supplementIndex < anchorIndex && - conclusionIndex > anchorIndex && - outline.blocks.some((block) => block.type === 'table_cell' && block.text === 'Bob'), - { - name: 'table-mixed-chain-preserves-direction-and-table-target', - message: - '混合链式写中,insert_before 与 insert_after 不能共用错误的 continuation;同时旧 table cell 仍应稳定恢复并命中更新后的 Bob。', - data: { - lastToolResults: result.state.execution.lastToolResults, - outline: outline.blocks - } - } - ) - } - }) - }) -})