onPreviewReadyForNextTask 在 task1 申请核对表刚生成、用户还没操作时就 提前拉起 task2,与用户后续在 task1 上的保存草稿/提交操作互相打架,导致 task2 完全无反应。移除该提前推进回调,统一由 onApplicationActionCompleted 在 task1 真正完成后再推进 task2。 - useWorkbenchAiApplicationPreviewFlow: 删除预览生成时的提前推进分支 - usePersonalWorkbenchAiMode: startModelPlannedApplicationPreview 不再传 onPreviewReadyForNextTask - useWorkbenchAiActionRouter: 低置信确认按钮分支同步删除该回调 - 新增时序回归测试:预览生成不提前推进、保存草稿后才推进 - 更新两处源码正则断言为 doesNotMatch
519 lines
18 KiB
JavaScript
519 lines
18 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import test from 'node:test'
|
||
|
||
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
|
||
import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
|
||
import {
|
||
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
|
||
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
|
||
|
||
test('workbench steward application confirmation opens inline application preview directly', () => {
|
||
const [action] = buildStewardSuggestedActions({
|
||
plan_id: 'steward-plan-ready-application',
|
||
plan_status: 'ready',
|
||
tasks: [
|
||
{
|
||
task_id: 'task-application-beijing',
|
||
task_type: 'expense_application',
|
||
title: '费用申请 2026-06-23 北京',
|
||
summary: '明天前往北京出差3天,支撑客户现场实施。',
|
||
assigned_agent: 'application_assistant',
|
||
ontology_fields: {
|
||
expense_type: 'travel',
|
||
time_range: '2026-06-23 至 2026-06-25',
|
||
location: '北京',
|
||
days: '3天',
|
||
reason: '支撑客户现场实施'
|
||
},
|
||
missing_fields: ['transport_mode']
|
||
}
|
||
],
|
||
confirmation_groups: [
|
||
{
|
||
confirmation_id: 'confirm-application-beijing',
|
||
action_type: 'confirm_create_application',
|
||
target_task_id: 'task-application-beijing'
|
||
}
|
||
]
|
||
})
|
||
|
||
let previewPayload = null
|
||
let fallbackConversationStarted = false
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||
startAiApplicationPreviewFromAction: (payload) => {
|
||
previewPayload = payload
|
||
},
|
||
startAiExpenseDraft: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
resolveLatestInlineUserPrompt: () => '',
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {
|
||
fallbackConversationStarted = true
|
||
},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
|
||
router.handleInlineSuggestedAction(action)
|
||
|
||
assert.equal(fallbackConversationStarted, false)
|
||
assert.equal(previewPayload?.flow_id, 'travel_application')
|
||
assert.equal(previewPayload?.expense_type, 'travel')
|
||
assert.equal(previewPayload?.expense_type_label, '差旅费')
|
||
assert.match(previewPayload?.carry_text || '', /支撑客户现场实施/)
|
||
|
||
const preview = buildInlineApplicationPreview(previewPayload.expense_type_label, previewPayload.carry_text, {
|
||
name: '测试用户',
|
||
departmentName: '交付部',
|
||
position: '实施顾问',
|
||
managerName: '张经理',
|
||
grade: 'P5'
|
||
})
|
||
assert.equal(preview.fields.time, '2026-06-23 至 2026-06-25')
|
||
assert.equal(preview.fields.location, '北京')
|
||
assert.equal(preview.fields.reason, '支撑客户现场实施')
|
||
assert.equal(preview.fields.days, '3天')
|
||
assert.equal(preview.fields.transportMode, '')
|
||
})
|
||
|
||
test('workbench low-confidence application confirmation forwards remaining tasks', () => {
|
||
let previewCall = null
|
||
const remainingTasks = [{
|
||
task_id: 'task-reimbursement-2',
|
||
task_type: 'reimbursement',
|
||
assigned_agent: 'reimbursement_assistant',
|
||
ontology_fields: {
|
||
expense_type: 'entertainment',
|
||
expense_type_label: '业务招待费',
|
||
amount: '2000元',
|
||
time_range: '2026-06-25',
|
||
reason: '业务招待'
|
||
}
|
||
}]
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {},
|
||
startAiApplicationPreview: (...args) => {
|
||
previewCall = args
|
||
}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||
startAiApplicationPreviewFromAction: () => {},
|
||
startAiExpenseDraft: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
resolveLatestInlineUserPrompt: () => '',
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
|
||
router.handleInlineSuggestedAction({
|
||
label: '确认发起出差申请',
|
||
action_type: 'ai_application_confirm_intent',
|
||
payload: {
|
||
sourceText: '2月20-23日去上海出差3天,服务国网服务器部署,并且报销昨天的业务招待费2000元',
|
||
ontologyFields: { location: '上海', reason: '服务国网服务器部署' },
|
||
stewardRemainingTasks: remainingTasks
|
||
}
|
||
})
|
||
|
||
assert.ok(previewCall, 'startAiApplicationPreview 应被调用')
|
||
assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks)
|
||
// 低置信确认按钮只在 task1 完成后推进 task2,不再在预览生成时提前推进。
|
||
assert.equal(previewCall[3].onPreviewReadyForNextTask, undefined)
|
||
assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function')
|
||
})
|
||
|
||
test('workbench reimbursement skip link action opens new reimbursement flow', () => {
|
||
let sceneSelectionPayload = null
|
||
let fallbackConversationStarted = false
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
pushInlineExpenseSceneSelectionPrompt: (sourceText, label) => {
|
||
sceneSelectionPayload = { sourceText, label }
|
||
},
|
||
startAiApplicationPreviewFromAction: () => {},
|
||
startAiExpenseDraft: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
resolveLatestInlineUserPrompt: () => '',
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {
|
||
fallbackConversationStarted = true
|
||
},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
|
||
router.handleInlineSuggestedAction({
|
||
label: '不关联,单独新建报销单',
|
||
action_type: 'skip_required_application_link',
|
||
payload: {
|
||
original_message: '我要报销'
|
||
}
|
||
})
|
||
|
||
assert.equal(fallbackConversationStarted, false)
|
||
assert.deepEqual(sceneSelectionPayload, {
|
||
sourceText: '我要报销',
|
||
label: '不关联,单独新建报销单'
|
||
})
|
||
})
|
||
|
||
test('workbench draft continuation action asks for attachments or description', () => {
|
||
let continuationPayload = null
|
||
let fallbackConversationStarted = false
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
promptAiReimbursementDraftContinuation: (payload) => {
|
||
continuationPayload = payload
|
||
},
|
||
promptStandaloneReimbursementDraftCreation: () => {},
|
||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||
startAiApplicationPreviewFromAction: () => {},
|
||
startAiExpenseDraft: () => {},
|
||
startAiReimbursementAssociationGate: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
resolveLatestInlineUserPrompt: () => '',
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {
|
||
fallbackConversationStarted = true
|
||
},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
|
||
router.handleInlineSuggestedAction({
|
||
label: '继续关联草稿 RE-202606-010',
|
||
action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||
payload: {
|
||
claim_id: 'draft-travel-1',
|
||
claim_no: 'RE-202606-010',
|
||
original_message: '我要报销'
|
||
}
|
||
})
|
||
|
||
assert.equal(fallbackConversationStarted, false)
|
||
assert.deepEqual(continuationPayload, {
|
||
claim_id: 'draft-travel-1',
|
||
claim_no: 'RE-202606-010',
|
||
original_message: '我要报销'
|
||
})
|
||
})
|
||
|
||
test('workbench standalone draft action asks before creating a new reimbursement draft', () => {
|
||
let standalonePrompt = null
|
||
let fallbackConversationStarted = false
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
promptAiReimbursementDraftContinuation: () => {},
|
||
promptStandaloneReimbursementDraftCreation: (sourceText, label) => {
|
||
standalonePrompt = { sourceText, label }
|
||
},
|
||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||
startAiApplicationPreviewFromAction: () => {},
|
||
startAiExpenseDraft: () => {},
|
||
startAiReimbursementAssociationGate: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
resolveLatestInlineUserPrompt: () => '',
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {
|
||
fallbackConversationStarted = true
|
||
},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
|
||
router.handleInlineSuggestedAction({
|
||
label: '独立新建报销单',
|
||
action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
|
||
payload: {
|
||
original_message: '我要报销'
|
||
}
|
||
})
|
||
|
||
assert.equal(fallbackConversationStarted, false)
|
||
assert.deepEqual(standalonePrompt, {
|
||
sourceText: '我要报销',
|
||
label: '独立新建报销单'
|
||
})
|
||
})
|
||
|
||
test('workbench steward executable submit action runs precheck before submit and writes result message', async () => {
|
||
const requests = []
|
||
const originalFetch = globalThis.fetch
|
||
globalThis.fetch = async (_url, options = {}) => {
|
||
const body = JSON.parse(String(options.body || '{}'))
|
||
requests.push(body)
|
||
if (body.action_type === 'run_duplicate_precheck') {
|
||
return {
|
||
ok: true,
|
||
async json() {
|
||
return {
|
||
action_type: 'run_duplicate_precheck',
|
||
status: 'succeeded',
|
||
message: '未发现重复或冲突申请,可以继续提交。',
|
||
result_payload: {
|
||
status: 'ok',
|
||
blocking: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
async json() {
|
||
return {
|
||
action_type: 'submit_application',
|
||
status: 'succeeded',
|
||
message: '申请已提交审批。',
|
||
result_payload: {
|
||
draft_payload: {
|
||
claim_id: 'claim-app-1',
|
||
claim_no: 'A1BCDEF2'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
const messages = []
|
||
let messageSeq = 0
|
||
const createInlineMessage = (role, content, options = {}) => ({
|
||
id: options.id || `msg-${++messageSeq}`,
|
||
role,
|
||
content,
|
||
pending: Boolean(options.pending),
|
||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
||
})
|
||
const replaceInlineMessage = (id, nextMessage) => {
|
||
const index = messages.findIndex((item) => item.id === id)
|
||
if (index >= 0) {
|
||
messages.splice(index, 1, nextMessage)
|
||
}
|
||
}
|
||
let persisted = false
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
conversationMessages: { value: messages },
|
||
createInlineMessage,
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||
startAiApplicationPreviewFromAction: () => {},
|
||
startAiExpenseDraft: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
persistCurrentConversation: () => {
|
||
persisted = true
|
||
},
|
||
replaceInlineMessage,
|
||
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交',
|
||
scrollInlineConversationToBottom: () => {},
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
const sourceMessage = {
|
||
suggestedActionsLocked: false
|
||
}
|
||
|
||
await router.handleInlineSuggestedAction({
|
||
label: '确认提交申请',
|
||
action_type: 'switch_session',
|
||
payload: {
|
||
steward_execute_action: true,
|
||
steward_plan_id: 'plan-submit-1',
|
||
steward_action_type: 'submit_application',
|
||
steward_action_requires_confirmation: true,
|
||
steward_action_step: {
|
||
step_id: 'task-app-1:05',
|
||
action_type: 'submit_application',
|
||
requires_confirmation: true
|
||
},
|
||
steward_current_task: {
|
||
task_id: 'task-app-1',
|
||
task_type: 'expense_application',
|
||
assigned_agent: 'application_assistant',
|
||
title: '上海出差申请',
|
||
summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。',
|
||
requested_action: 'submit',
|
||
ontology_fields: {
|
||
expense_type: 'travel',
|
||
time_range: '2026-02-20 至 2026-02-23',
|
||
location: '上海',
|
||
reason: '辅助国网仿生产服务器部署',
|
||
transport_mode: 'train'
|
||
},
|
||
missing_fields: [],
|
||
action_steps: [
|
||
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' },
|
||
{ step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true }
|
||
]
|
||
},
|
||
carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交'
|
||
}
|
||
}, sourceMessage)
|
||
|
||
assert.equal(requests.length, 2)
|
||
assert.equal(requests[0].action_type, 'run_duplicate_precheck')
|
||
assert.equal(requests[1].action_type, 'submit_application')
|
||
assert.equal(requests[1].confirmed, true)
|
||
assert.equal(requests[1].context_json.precheck_result.status, 'ok')
|
||
assert.equal(sourceMessage.suggestedActionsLocked, true)
|
||
assert.equal(persisted, true)
|
||
assert.match(messages.at(-1).content, /申请已提交审批/)
|
||
assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail')
|
||
} finally {
|
||
globalThis.fetch = originalFetch
|
||
}
|
||
})
|
||
|
||
test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {
|
||
let expenseDraftCall = null
|
||
const router = useWorkbenchAiActionRouter({
|
||
aiExpenseDraft: { value: null },
|
||
applicationFlow: {
|
||
isInlineSuggestedActionDisabled: () => false,
|
||
executeInlineApplicationPreviewAction: () => {}
|
||
},
|
||
assistantDraft: { value: '' },
|
||
attachmentFlow: {
|
||
confirmAiAttachmentAssociation: () => {}
|
||
},
|
||
emit: () => {},
|
||
expenseFlow: {
|
||
linkAiExpenseApplication: () => {},
|
||
promptAiReimbursementDraftContinuation: () => {},
|
||
promptStandaloneReimbursementDraftCreation: () => {},
|
||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||
startAiApplicationPreviewFromAction: () => {},
|
||
startAiExpenseDraft: (expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options) => {
|
||
expenseDraftCall = { expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options }
|
||
},
|
||
startAiReimbursementAssociationGate: () => {}
|
||
},
|
||
focusAiModeInput: () => {},
|
||
hasInlineAttachmentOcrDetails: () => false,
|
||
resolveLatestInlineUserPrompt: () => '',
|
||
selectedFiles: { value: [] },
|
||
startInlineConversation: () => {},
|
||
toast: () => {},
|
||
toggleInlineAttachmentOcrDetails: () => {}
|
||
})
|
||
|
||
router.handleInlineSuggestedAction({
|
||
label: '继续处理费用报销',
|
||
action_type: 'steward_continue_next_task',
|
||
payload: {
|
||
steward_confirm_flow: true,
|
||
flow_id: 'travel_reimbursement',
|
||
steward_current_task: {
|
||
task_id: 'task-meal-1',
|
||
task_type: 'reimbursement',
|
||
title: '业务招待费报销',
|
||
summary: '报销昨天业务招待费2000元',
|
||
ontology_fields: {
|
||
expense_type: 'meal',
|
||
expense_type_label: '业务招待费',
|
||
amount: '2000元',
|
||
time_range: '昨天',
|
||
reason: '客户招待'
|
||
}
|
||
},
|
||
steward_remaining_tasks: []
|
||
}
|
||
})
|
||
|
||
// task2(招待费报销)启动时:费用类型正确、语义预填到草稿、remaining tasks 透传
|
||
assert.ok(expenseDraftCall, 'startAiExpenseDraft 应被调用')
|
||
assert.equal(expenseDraftCall.expenseType, 'meal')
|
||
assert.equal(expenseDraftCall.expenseTypeLabel, '业务招待费')
|
||
assert.equal(expenseDraftCall.requiresApplicationBeforeReimbursement, true)
|
||
assert.equal(expenseDraftCall.options.prefillValues.amount, '2000元')
|
||
assert.equal(expenseDraftCall.options.prefillValues.reason, '客户招待')
|
||
assert.equal(expenseDraftCall.options.prefillValues.time_range, '昨天')
|
||
assert.deepEqual(expenseDraftCall.options.stewardRemainingTasks, [])
|
||
})
|