feat(docx): enhance readback validation and add new tests for docx handling

This commit is contained in:
hy committed 2026-04-30 18:51:19 +08:00
1 parent b050ffb188
commit dbfb783320
5 files changed
+399 -1

No files matched your search

@@ -15,6 +15,8 @@ import type {
HumanWatchHint
} from '../../types'
const DOCX_READ_TOOL_NAMES = new Set(['docx_outline', 'docx_read_blocks'])
const docxReadSatisfiesWatchHint = (input: {
decision: HumanDecision
watchHint: Extract<HumanWatchHint, { code: 'docx_write_requires_readback' }>
@@ -25,7 +27,7 @@ const docxReadSatisfiesWatchHint = (input: {
const spec = getToolSpec(call.toolName)
if (!spec) return false
const meta = spec.resolveExecutionMeta(call.args)
if (meta.mutability !== 'read_only' || meta.resourceKey !== 'docx') return false
if (meta.mutability !== 'read_only' || !DOCX_READ_TOOL_NAMES.has(call.toolName)) return false
const requestedPath =
typeof call.args.sourcePath === 'string' ? call.args.sourcePath : undefined
if (
@@ -1,6 +1,8 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, it } from 'vitest'
import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/blockIndex'
import { XMLSerializer } from '@xmldom/xmldom'
import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../harness/backend/llmConfig'
import { createHumanTestRunner } from '../../../harness/backend/humanHarness'
import { runBackendCase } from '../../../harness/backend/run-backend-case'
@@ -153,6 +155,34 @@ describe('ai human live backend', () => {
const referencesTxtEvidence = result.workspaceSeed.relevantTxtRelativePaths.some((relativePath) =>
deliveryText.includes(path.basename(relativePath))
)
const watchHints = filterAiRunEventsByType(events, 'watch_hint')
const writeStartedEvents = filterAiRunEventsByType(events, 'tool_call_started').filter((event) =>
[
'docx_paragraph_edit',
'docx_heading_edit',
'docx_table_edit',
'docx_text_edit',
'docx_text_style_edit'
].includes(event.toolName)
)
const readStartedEvents = filterAiRunEventsByType(events, 'tool_call_started').filter((event) =>
['docx_outline', 'docx_read_blocks'].includes(event.toolName)
)
const hasReadbackAfterWrite =
writeStartedEvents.length === 0 ||
writeStartedEvents.some((writeEvent) =>
readStartedEvents.some(
(readEvent) =>
readEvent.createdAt >= writeEvent.createdAt &&
readEvent.input.sourcePath === writeEvent.input.sourcePath
)
)
const reviewArtifact = result.output?.artifacts?.find((artifact) => artifact.kind === 'draft_review')
let reviewXml = ''
if (reviewArtifact?.review.draftArtifactPath) {
const reviewBundle = await loadDocxBundle(await fs.readFile(reviewArtifact.review.draftArtifactPath))
reviewXml = new XMLSerializer().serializeToString(reviewBundle.documentDom)
}
runtime.assertThat(
result.output?.status === 'completed' &&
@@ -243,6 +273,31 @@ describe('ai human live backend', () => {
}
}
)
runtime.assertThat(
watchHints.some((event) => event.code === 'docx_write_requires_readback') &&
hasReadbackAfterWrite,
{
name: 'human-live-risk-report-writes-then-reads-back-latest-docx',
message: '同文件 docx 写后,live case 事件流里应出现 docx_write_requires_readback,并在之后出现对应 readback。',
data: {
watchHints,
writeStartedEvents,
readStartedEvents
}
}
)
runtime.assertThat(
!reviewXml.includes('review_artifact_invalid_generated'),
{
name: 'human-live-risk-report-review-hides-generated-discarded-draft',
message: '最终 review 不应包含 generated 中间废稿的删除项痕迹。',
data: {
reviewXmlSnippet: reviewXml.slice(0, 2000)
}
}
)
}
})
}, 900_000)
@@ -0,0 +1,87 @@
import fs from 'node:fs/promises'
import { describe, it } from 'vitest'
import { XMLSerializer } from '@xmldom/xmldom'
import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/blockIndex'
import {
createDocxToolHarness
} from '../../../harness/backend/aiToolHarness'
import { runBackendCase } from '../../../harness/backend/run-backend-case'
import type { DocxCreateResult, DocxMutationResult, DocxOutlineResult } from './testUtils'
describe('ai tool docx backend', () => {
it('docx-create-target-review-shows-net-additions-only', async () => {
await runBackendCase({
caseId: 'docx-create-target-review-shows-net-additions-only',
harnessMode: 'tool-real',
domain: 'ai',
suite: 'tool',
groups: ['docx'],
description: '验证 create-target 场景多轮草稿往返后,review 只展示最终存活新增内容。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
const harness = createDocxToolHarness(runtime)
try {
harness.ctx.beginDocxReviewRun()
await harness.invoke<DocxCreateResult>('docx_create', {
sourcePath: 'net-additions-only.docx',
blocks: [
{ type: 'heading', level: 1, text: 'Final Report' },
{ type: 'paragraph', text: 'Keep this final paragraph.' }
]
})
const outline = await harness.invoke<DocxOutlineResult>('docx_outline', {
sourcePath: 'net-additions-only.docx'
})
const finalParagraphBlockId =
outline.blocks.find(
(block) => block.type === 'paragraph' && block.preview.includes('Keep this final paragraph.')
)?.blockId || ''
const inserted = await harness.invoke<DocxMutationResult>('docx_paragraph_edit', {
sourcePath: 'net-additions-only.docx',
action: 'insert_after',
anchorBlockId: finalParagraphBlockId,
text: 'Temporary draft that should disappear from review.'
})
const temporaryBlockId = inserted.createdBlocks?.[0]?.blockId || ''
await harness.invoke<DocxMutationResult>('docx_paragraph_edit', {
sourcePath: 'net-additions-only.docx',
action: 'delete',
blockId: temporaryBlockId
})
const details = await harness.ctx.finalizeDocxReviewRun()
const reviewPath = details[0]?.review.draft_artifact_path
if (!reviewPath) {
throw new Error('expected review artifact path')
}
const reviewBundle = await loadDocxBundle(await fs.readFile(reviewPath))
const reviewXml = new XMLSerializer().serializeToString(reviewBundle.documentDom)
return {
reviewXml
}
} finally {
await harness.release()
}
},
assert: async (result, runtime) => {
runtime.assertThat(
result.reviewXml.includes('Final Report') &&
result.reviewXml.includes('Keep this final paragraph.') &&
!result.reviewXml.includes('Temporary draft that should disappear from review.'),
{
name: 'create-target-review-only-shows-final-net-additions',
message: 'create-target review 应只保留最终存活新增内容,不展示 AI 中途删掉的废稿。',
data: {
reviewXmlSnippet: result.reviewXml.slice(0, 1800)
}
}
)
}
})
})
})
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache'
import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema'
import { applyBrainDecisionTransition } from '../../../../src/main/services/ai/human/node/utils/stateTransitions'
import type { AiRunContext } from '../../../../src/main/services/ai/types'
import type { HumanDecision } from '../../../../src/main/services/ai/human/types'
const createStubAiRunContext = (): AiRunContext => ({
runId: 'run-brain-docx-readback-gate',
referenceScope: {
workspace: {
workspaceId: 'workspace-1',
workspaceName: 'workspace',
rootPath: '/tmp/workspace-root'
},
selectedReferences: [],
hasExplicitSelectedReferences: false,
hasDirectDocumentNarrowing: false,
defaultKnowledgeScope: 'current_workspace'
},
workspace: {
workspaceId: 'workspace-1',
workspaceName: 'workspace',
rootPath: '/tmp/workspace-root'
},
checkCancelled: () => {},
chatStructured: async () => {
throw new Error('chatStructured should not be called in this unit test')
},
chatText: async () => {
throw new Error('chatText should not be called in this unit test')
},
sendEvent: () => {},
requestApproval: async () => ({ approved: false }),
loadHistory: async () => ({
recentTurnsText: '',
sessionSummaryText: ''
}),
trackDocxOpen: () => {},
trackDocxChanges: () => {},
resolveDocxOpenTarget: async () => {
throw new Error('resolveDocxOpenTarget should not be called in this unit test')
},
beginDocxReviewRun: () => {},
finalizeDocxReviewRun: async () => [],
persistDocxReviewRun: async () => {},
discardDocxReviewRun: () => {},
documentsStore: undefined as never,
embeddingService: undefined as never
})
const buildStateWithReadbackHint = () => ({
...createInitialHumanState({
taskId: 'task-brain-docx-readback-gate',
userInput: 'test'
}),
execution: {
...createInitialHumanState({
taskId: 'task-brain-docx-readback-gate',
userInput: 'test'
}).execution,
watchHint: {
code: 'docx_write_requires_readback' as const,
detail: {
sourcePaths: ['/tmp/workspace-root/report.docx'],
latestRevisionRefs: ['v3'],
suggestedToolNames: ['docx_outline', 'docx_read_blocks']
}
}
}
})
describe('ai tool hand backend', () => {
it('brain-docx-readback-gate', () => {
const ctx = createStubAiRunContext()
const cache = createHumanRuntimeCache()
const baseState = buildStateWithReadbackHint()
const mouthDecision: HumanDecision = {
decision: {
summary: '直接结束',
reason: '故意测试 gate',
expectedOutcome: '应被阻断'
},
nextAction: {
action: 'mouth'
}
}
const blockedMouth = applyBrainDecisionTransition({
ctx,
cache,
state: baseState,
decision: mouthDecision,
stableMemory: baseState.memory.stable
})
expect(blockedMouth.execution.stage).toBe('brain')
expect(blockedMouth.execution.watchHint?.code).toBe('docx_write_requires_readback')
const readDecision: HumanDecision = {
decision: {
summary: '先 readback',
reason: '满足 gate',
expectedOutcome: '允许进入 hand'
},
nextAction: {
action: 'hand',
toolUsePlan: {
mode: 'serial',
calls: [
{
toolName: 'docx_outline',
args: {
sourcePath: 'report.docx'
}
}
]
}
}
}
const allowedRead = applyBrainDecisionTransition({
ctx,
cache,
state: baseState,
decision: readDecision,
stableMemory: baseState.memory.stable
})
expect(allowedRead.execution.stage).toBe('hand')
expect(allowedRead.execution.watchHint).toBeUndefined()
})
})
@@ -0,0 +1,120 @@
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'
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 () => {
await runBackendCase({
caseId: 'hand-docx-serial-snapshot-bound-edit-plan-invalid-target',
harnessMode: 'tool-real',
domain: 'ai',
suite: 'tool',
groups: ['hand'],
description: '验证事务段中若后续目标已被前序操作删除,应明确失败并中止,不允许 silent fallback。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
await writeTestDocxFile(runtime.workspaceRoot, 'invalid-target.docx', [
{ type: 'paragraph', text: 'Alpha' },
{ 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'
}
})
const outlineData = initialOutline.data as DocxOutlineResult
const alphaBlockId =
outlineData.blocks.find((block) => block.type === 'paragraph' && block.preview.includes('Alpha'))?.blockId || ''
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,
currentDecision: {
decision: {
summary: '先删再改同一目标',
reason: '验证目标失效时会中止事务段。',
expectedOutcome: '第二步明确失败。'
},
nextAction: {
action: 'hand',
toolUsePlan: {
mode: 'serial',
calls: [
{
toolName: 'docx_paragraph_edit',
args: {
sourcePath: 'invalid-target.docx',
action: 'delete',
blockId: alphaBlockId
}
},
{
toolName: 'docx_paragraph_edit',
args: {
sourcePath: 'invalid-target.docx',
action: 'replace_text',
blockId: alphaBlockId,
text: 'Should fail'
}
}
]
}
}
}
}
})
return {
state
}
},
assert: async (result, runtime) => {
const results = result.state.execution.lastToolResults || []
runtime.assertThat(
results.length === 2 &&
results[0]?.code === 'ok' &&
results[1]?.code === 'invalid_input' &&
results[1]?.diagnostic?.message.includes('already been removed'),
{
name: 'invalid-target-aborts-transaction-with-explicit-error',
message: '事务段内后续目标若已被前序操作删除,应明确失败并中止,而不是 fallback 到别的块。',
data: {
lastToolResults: results,
watchHint: result.state.execution.watchHint
}
}
)
}
})
})
})