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.
This commit is contained in:
hy committed 2026-05-22 11:36:11 +08:00
1 parent a3c3e81a6e
commit f472f61686
20 files changed
+535 -1000

No files matched your search

+1
View File
@@ -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、日志或正式测试流程时阅读。
+1 -1
View File
@@ -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[]
// 若是数量超限,则记录本次调用数。
+1 -1
View File
@@ -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`
+30 -30
View File
@@ -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`
+1 -2
View File
@@ -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
+48 -308
View File
@@ -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<HumanDecisionHint, { code: 'hand_plan_invalid' }>['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<string, ToolUsePlan['calls']>()
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<ToolCallResult[]> => {
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<string, DocxAnchorContinuation>()
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' &&
@@ -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 中列出这些资料,并说明来源定位与它支撑了最终交付的哪些地方。
@@ -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')
+1
View File
@@ -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()
})
+2 -2
View File
@@ -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<RuntimeSkillId, SkillDefinition> = {
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',
@@ -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<RequireReadSessionResult, { failure: DocxToolFailure }> => ({
sourcePath,
docId: session.activeDocId,
currentRevisionRef: session.activeRevisionRef,
meta: session.meta,
stats: session.stats,
replaySession: session
})
): Exclude<RequireReadSessionResult, { failure: DocxToolFailure }> => {
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
@@ -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<DocxOutlineResult>('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<DocxMutationResult>('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<DocxMutationResult>('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<DocxOutlineResult>('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
}
}
)
}
})
})
})
@@ -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<DocxOutlineResult>('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<DocxMutationResult>('docx_heading_edit', {
sourcePath: 'outline-percent-after-write.docx',
anchorBlockId,
action: 'insert_after',
headings: [{ level: 2, text: '第一部分:深度学习' }]
})
const afterOutline = await harness.invoke<DocxOutlineResult>('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
}
}
)
}
})
})
})
@@ -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
}
}
)
@@ -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
}
}
)
}
})
})
})
@@ -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<DocxOutlineResult>('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
}
}
)
}
})
})
})
@@ -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<DocxOutlineResult>('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
}
}
)
}
})
})
})
@@ -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
@@ -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<DocxOutlineResult>('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
}
}
)
}
})
})
})
@@ -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<DocxOutlineResult>('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
}
}
)
}
})
})
})