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:
1 parent
a3c3e81a6e
commit
f472f61686
20 files changed
+535
-1000
No files matched your search
@@ -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、日志或正式测试流程时阅读。
|
||||
|
||||
|
||||
@@ -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[]
|
||||
// 若是数量超限,则记录本次调用数。
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
+58
-51
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
+37
-19
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user