diff --git a/src/main/services/ai/human/node/utils/stateTransitionState.ts b/src/main/services/ai/human/node/utils/stateTransitionState.ts index 0e7e143f..68f14fa9 100644 --- a/src/main/services/ai/human/node/utils/stateTransitionState.ts +++ b/src/main/services/ai/human/node/utils/stateTransitionState.ts @@ -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 @@ -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 ( diff --git a/tests/ai/human/live/human-risk-report-draft-live.test.ts b/tests/ai/human/live/human-risk-report-draft-live.test.ts index fb79c965..014b5e68 100644 --- a/tests/ai/human/live/human-risk-report-draft-live.test.ts +++ b/tests/ai/human/live/human-risk-report-draft-live.test.ts @@ -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) diff --git a/tests/ai/tool/docx/docx-create-target-review-shows-net-additions-only.test.ts b/tests/ai/tool/docx/docx-create-target-review-shows-net-additions-only.test.ts new file mode 100644 index 00000000..0d79a8f7 --- /dev/null +++ b/tests/ai/tool/docx/docx-create-target-review-shows-net-additions-only.test.ts @@ -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('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('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('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('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) + } + } + ) + } + }) + }) +}) diff --git a/tests/ai/tool/hand/brain-docx-readback-gate.test.ts b/tests/ai/tool/hand/brain-docx-readback-gate.test.ts new file mode 100644 index 00000000..ba69521b --- /dev/null +++ b/tests/ai/tool/hand/brain-docx-readback-gate.test.ts @@ -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() + }) +}) diff --git a/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts b/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts new file mode 100644 index 00000000..9b7be314 --- /dev/null +++ b/tests/ai/tool/hand/hand-docx-serial-snapshot-bound-edit-plan-invalid-target.test.ts @@ -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 + } + } + ) + } + }) + }) +})