Files
X-Financial/web/tests/workbench-ai-application-context-submit.test.mjs
caoxiaozhu 43779f8f2c fix(web): 多 task 串行推进 task2 不再被预览生成时提前触发
onPreviewReadyForNextTask 在 task1 申请核对表刚生成、用户还没操作时就
提前拉起 task2,与用户后续在 task1 上的保存草稿/提交操作互相打架,导致
task2 完全无反应。移除该提前推进回调,统一由 onApplicationActionCompleted
在 task1 真正完成后再推进 task2。

- useWorkbenchAiApplicationPreviewFlow: 删除预览生成时的提前推进分支
- usePersonalWorkbenchAiMode: startModelPlannedApplicationPreview 不再传 onPreviewReadyForNextTask
- useWorkbenchAiActionRouter: 低置信确认按钮分支同步删除该回调
- 新增时序回归测试:预览生成不提前推进、保存草稿后才推进
- 更新两处源码正则断言为 doesNotMatch
2026-06-30 11:40:31 +08:00

291 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import test from 'node:test'
import { useWorkbenchAiApplicationPreviewFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js'
function createRef(value) {
return { value }
}
function createInlineMessage(role, content, options = {}) {
return {
id: options.id || `msg-${Math.random().toString(16).slice(2)}`,
role,
content,
text: content,
paragraphs: String(content || '').split(/\n+/).filter(Boolean),
pending: Boolean(options.pending),
...options
}
}
function buildApplicationPreviewFlowHarness(messages, options = {}) {
const conversationMessages = createRef(messages)
const applicationSubmitConfirmOpen = createRef(false)
const applicationSubmitConfirmContext = createRef(null)
const persisted = createRef(0)
const flow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation: () => {},
applicationPreviewEditor: createRef({}),
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft: createRef(''),
cancelApplicationPreviewEditor: () => {},
clearAiModeFiles: () => {},
closeWorkbenchDatePicker: () => {},
commitApplicationPreviewEditor: async () => true,
conversationId: createRef('conversation-context-submit'),
conversationMessages,
conversationStarted: createRef(true),
createInlineMessage,
currentUser: createRef({ username: 'zhangsan@example.com', name: '张三' }),
handleApplicationPreviewEditorKeydown: () => {},
inlineConversationAutoScrollPinned: createRef(true),
isApplicationPreviewEditing: createRef(false),
openApplicationPreviewEditor: () => {},
persistCurrentConversation: () => { persisted.value += 1 },
pushInlineApplicationActionUserMessage: (text) => {
conversationMessages.value.push(createInlineMessage('user', text))
},
pushInlineUserMessage: (text) => {
conversationMessages.value.push(createInlineMessage('user', text))
},
refreshApplicationPreviewEstimate: async (preview) => preview,
removeWorkbenchDateTag: () => {},
replaceInlineMessage: (id, nextMessage) => {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index >= 0) {
conversationMessages.value.splice(index, 1, nextMessage)
} else {
conversationMessages.value.push(nextMessage)
}
},
resolveApplicationPreviewEditorDateMax: () => '',
resolveApplicationPreviewEditorDateMin: () => '',
resolveApplicationPreviewEditorControl: () => null,
resolveApplicationPreviewEditorOptions: () => [],
resolveInlineThinkingEvents: (message) => message?.stewardPlan?.thinkingEvents || [],
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿',
scrollInlineConversationToBottom: () => {},
sending: createRef(false),
toast: () => {},
onApplicationActionCompleted: options.onApplicationActionCompleted
})
return {
applicationSubmitConfirmOpen,
conversationMessages,
flow,
persisted
}
}
test('workbench auto-saved application draft continues remaining steward task', async () => {
const originalFetch = globalThis.fetch
const requests = []
globalThis.fetch = async (url, options = {}) => {
const normalizedUrl = String(url)
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
const body = JSON.parse(String(options.body || '{}'))
requests.push({ url: normalizedUrl, body })
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-auto-saved-draft',
claim_no: 'AEW2DDAFL',
status: 'draft'
}
}
}
}
}
}
throw new Error(`unexpected request: ${normalizedUrl}`)
}
try {
const continuedTasks = []
const remainingTasks = [{
task_id: 'task-reimbursement-2',
task_type: 'reimbursement',
assigned_agent: 'reimbursement_assistant',
summary: '报销昨天的业务招待费 2000 元',
ontology_fields: {
expense_type: 'entertainment',
amount: '2000元',
time_range: '2026-06-25',
reason: '业务招待费报销'
}
}]
const harness = buildApplicationPreviewFlowHarness([], {
onApplicationActionCompleted: (tasks, sourceMessage) => {
continuedTasks.push({ tasks, sourceMessage })
}
})
await harness.flow.startAiApplicationPreview('travel', '差旅费', '2月20-23日去上海出差3天服务国网服务器部署并且报销昨天的业务招待费2000元', {
autoSaveDraft: true,
stewardRemainingTasks: remainingTasks,
onApplicationActionCompleted: (tasks, sourceMessage) => {
continuedTasks.push({ tasks, sourceMessage, fromOptions: true })
}
})
assert.equal(requests.length, 1)
assert.equal(continuedTasks.length, 1)
assert.deepEqual(continuedTasks[0].tasks, remainingTasks)
assert.equal(continuedTasks[0].sourceMessage.stewardRemainingTasks, remainingTasks)
assert.equal(continuedTasks[0].fromOptions, true)
assert.doesNotMatch(harness.conversationMessages.value.at(-1).content, /继续处理费用报销/)
} finally {
globalThis.fetch = originalFetch
}
})
test('workbench application preview does not continue next task until draft is saved or submitted', async () => {
// 时序回归:task1 申请核对表刚生成、用户还没点保存草稿/提交时,
// 不能提前拉起 task2(会导致两条流程消息和状态互相打架,最终 task2 无反应)。
// task2 的推进必须等 task1 真正完成(onApplicationActionCompleted)后再触发。
const originalFetch = globalThis.fetch
globalThis.fetch = async (url) => {
if (String(url).includes('/reimbursements/application-preview-action')) {
return {
ok: true,
async json() {
return { status: 'succeeded', result: { draft_payload: { claim_id: 'c1', claim_no: 'AEW2', status: 'draft' } } }
}
}
}
throw new Error(`unexpected request: ${url}`)
}
try {
const continuedTasks = []
const remainingTasks = [{
task_id: 'task-reimbursement-2',
task_type: 'reimbursement',
assigned_agent: 'reimbursement_assistant',
summary: '报销昨天的业务招待费 2000 元',
ontology_fields: { expense_type: 'entertainment', amount: '2000元', time_range: '2026-06-29', reason: '业务招待费报销' }
}]
const harness = buildApplicationPreviewFlowHarness([], {
onApplicationActionCompleted: (tasks) => { continuedTasks.push({ tasks, phase: 'module' }) }
})
// 第一步:生成申请核对表(不传 autoSaveDraft,模拟用户需要手动操作 task1)
await harness.flow.startAiApplicationPreview('travel', '差旅费', '2月20-23去上海出差并且报销昨天招待费2000元', {
stewardRemainingTasks: remainingTasks,
onApplicationActionCompleted: (tasks) => { continuedTasks.push({ tasks, phase: 'options' }) }
})
// 预览生成后,task2 不应被提前拉起
assert.equal(continuedTasks.length, 0, '预览生成时不应触发 task2 推进回调')
const previewMessage = harness.conversationMessages.value.find((m) => m.applicationPreview)
assert.equal(previewMessage?.stewardRemainingTasks?.length, 1, 'task2 应仍挂在核对表消息上等待用户完成 task1')
// 第二步:用户手动点击"保存草稿"(走 actionRouter,不传 options.onApplicationActionCompleted),
// 此时回落到模块级 onApplicationActionCompleted 触发 task2,这正是真实运行时的续跑路径。
await harness.flow.executeInlineApplicationPreviewAction('save_draft', previewMessage, {
userText: '保存草稿',
draftPayload: null
})
assert.equal(continuedTasks.length, 1, '保存草稿完成后应推进 task2')
assert.deepEqual(continuedTasks[0].tasks, remainingTasks)
assert.equal(continuedTasks[0].phase, 'module', '手动保存草稿走模块级续跑回调')
assert.doesNotMatch(harness.conversationMessages.value.at(-1).content, /继续处理费用报销/, '自动续跑时不展示重复的继续处理按钮')
} finally {
globalThis.fetch = originalFetch
}
})
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
const originalFetch = globalThis.fetch
const requests = []
globalThis.fetch = async (url, options = {}) => {
const normalizedUrl = String(url)
if (normalizedUrl.includes('/reimbursements/claims')) {
return {
ok: true,
async json() {
return { items: [] }
}
}
}
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
const body = JSON.parse(String(options.body || '{}'))
requests.push({ url: normalizedUrl, body })
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-saved-draft',
claim_no: 'A20260220',
status: 'submitted',
approval_stage: '直属领导审批'
}
}
}
}
}
}
throw new Error(`unexpected request: ${normalizedUrl}`)
}
try {
const previewMessage = createInlineMessage('assistant', '申请核对表', {
id: 'application-preview-1',
applicationPreview: {
readyToSubmit: true,
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '1200元'
},
missingFields: [],
validationIssues: []
},
draftPayload: {
claim_id: 'claim-saved-draft',
claim_no: 'A20260220',
status: 'draft'
}
})
const harness = buildApplicationPreviewFlowHarness([
createInlineMessage('user', '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿'),
previewMessage,
createInlineMessage('assistant', '### 申请草稿已保存', {
draftPayload: previewMessage.draftPayload
})
])
const handled = harness.flow.handleInlineApplicationPreviewTextAction(
'提交这个单据',
createRef(false)
)
assert.equal(handled, true)
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(harness.applicationSubmitConfirmOpen.value, false)
assert.equal(requests.length, 1)
assert.equal(requests[0].body.context_json.application_edit_claim_id, 'claim-saved-draft')
assert.equal(requests[0].body.context_json.application_edit_mode, true)
assert.match(harness.conversationMessages.value.at(-1).content, /申请单据已生成/)
assert.ok(harness.persisted.value > 0)
} finally {
globalThis.fetch = originalFetch
}
})