Compare commits
3 Commits
08f023243e
...
7ced9d93bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ced9d93bc | ||
|
|
876cf342ac | ||
|
|
43779f8f2c |
@@ -0,0 +1,16 @@
|
|||||||
|
# 多 task 串行推进时 task2 无法启动(onPreviewReadyForNextTask 时序缺陷)
|
||||||
|
|
||||||
|
日期:2026-06-30
|
||||||
|
文档路径:document/development/2026-06-30/dev-logs/bugs/multi-task-next-task-blocked-by-preview-ready-timing.md
|
||||||
|
|
||||||
|
## 修复记录
|
||||||
|
- 11:18:记录 bug 修复:用户在 AI 工作台输入"出差申请 + 招待费报销"等多 task 时,task1(出差申请)保存草稿/提交成功后,task2(招待费报销)完全无法启动,界面停在"申请草稿已保存"。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main`;`HEAD..@{u}` 未发现 upstream 新提交;`@{u}..HEAD` 未发现本地 ahead 提交。工作区改动仅为本次 3 个源文件 + 2 个测试文件(另有一个预先存在的未提交改动 `server/rules/finance-rules/公司通信费报销规则.xlsx`,与本次无关)。
|
||||||
|
- 根因:`onPreviewReadyForNextTask` 回调在 task1 申请核对表**刚生成、用户还没看、还没点保存草稿**时就立刻触发 `startModelPlannedNextTask`,提前把 task2 招待费报销拉起(`startAiExpenseDraft` 会 push 一条"选择费用报销"用户消息 + 报销 prompt)。两条流程的消息和状态互相打架,用户再在 task1 上点保存草稿时 `onApplicationActionCompleted` 又试图拉起 task2,但 task2 状态已被前面 `onPreviewReadyForNextTask` 搞乱,最终表现为"完全无反应"。运行时复现脚本时序铁证:预览生成后立即出现 `!!! onPreviewReadyForNextTask 触发(task1预览刚生成,用户还没操作)`,与串行推进的正确语义(task1 完成后才推进 task2)冲突。这是早期实现的残留——引入 `onApplicationActionCompleted`(task1 完成后触发)后,`onPreviewReadyForNextTask` 职责重叠且时序错误。
|
||||||
|
- 修改(前端 web,3 个源文件):
|
||||||
|
- `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js`:删除 `startAiApplicationPreview` 预览生成后的 `else if (onPreviewReadyForNextTask ...)` 提前推进分支(原 L622-L628),并加注释说明 task2 推进统一交给 `onApplicationActionCompleted` 在 task1 真正完成后触发。`executeInlineApplicationPreviewAction` 里 L466 的 `actionCompletedHandler` 回落逻辑保留不动(手动点保存草稿走 actionRouter 不传 options 回调,回落到模块级 `startModelPlannedNextTask`,这是正确的续跑路径)。
|
||||||
|
- `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`:`startModelPlannedApplicationPreview` 调用 `startAiApplicationPreview` 时删除 `onPreviewReadyForNextTask: startModelPlannedNextTask` 一行,只保留 `onApplicationActionCompleted: startModelPlannedNextTask`(原 L769)。
|
||||||
|
- `web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js`:`ai_application_confirm_intent`(低置信确认按钮)分支删除 `onPreviewReadyForNextTask` 回调,只保留 `onApplicationActionCompleted`(原 L99-L104),消除低置信路径的同样时序缺陷。
|
||||||
|
- 操作:先写复现脚本 `/tmp/repro-timing.mjs`(applicationFlow + 两个回调)锁定时序根因——修复前预览生成后立即触发推进回调,修复后无提前触发;再按计划小步改 3 个源文件 + 2 个测试文件;未提交(工作区有预先存在的无关 xlsx 改动,未自动提交)。
|
||||||
|
- 验证:宿主机 node v22.22.3 跑 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-action-router.test.mjs web/tests/workbench-ai-application-context-submit.test.mjs` 通过 27/27(含新增的时序回归用例 `workbench application preview does not continue next task until draft is saved or submitted`:断言预览生成时 `continuedTasks.length === 0`、保存草稿后才推进 task2 且走模块级续跑回调、自动续跑时不展示重复的"继续处理"按钮);`npm --prefix web run build` 通过(3.97s);复现脚本 `/tmp/repro-timing.mjs` 修复后事件序列只剩用户消息、无提前推进;真实 `http://localhost:5173/api/v1/steward/plans` 与 `/api/v1/steward/plans/stream` 采样确认该句子仍返回 `expense_application` + `reimbursement` 两个 task(后端拆分正确,本次未动后端)。
|
||||||
|
- 影响:用户输入框提交"2月20-23日,去上海出差辅助国网仿生产服务器部署,并且报销昨天的上午招待费2000元"等多 task 时,task1 出差申请核对表生成后干净停下等用户操作,用户点保存草稿/直接提交成功后自动进入 task2 招待费报销(预填金额/时间/事由),不再出现两条流程打架导致 task2 完全无反应的问题。不影响后端、单 task 场景、autoSaveDraft 路径(它走 `executeInlineApplicationPreviewAction` 完成后触发 `onApplicationActionCompleted`,链路不变);低置信确认按钮路径也同步修复。
|
||||||
Binary file not shown.
@@ -766,7 +766,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
||||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
||||||
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
|
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
|
||||||
onPreviewReadyForNextTask: startModelPlannedNextTask,
|
|
||||||
onApplicationActionCompleted: startModelPlannedNextTask
|
onApplicationActionCompleted: startModelPlannedNextTask
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,12 +96,6 @@ export function useWorkbenchAiActionRouter({
|
|||||||
requestedSubmit: Boolean(actionPayload.requestedSubmit),
|
requestedSubmit: Boolean(actionPayload.requestedSubmit),
|
||||||
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation),
|
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation),
|
||||||
stewardRemainingTasks,
|
stewardRemainingTasks,
|
||||||
onPreviewReadyForNextTask: (remainingTasks = []) => {
|
|
||||||
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
|
||||||
if (nextTaskAction) {
|
|
||||||
handleInlineSuggestedAction(nextTaskAction)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onApplicationActionCompleted: (remainingTasks = []) => {
|
onApplicationActionCompleted: (remainingTasks = []) => {
|
||||||
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
||||||
if (nextTaskAction) {
|
if (nextTaskAction) {
|
||||||
|
|||||||
@@ -619,13 +619,10 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
userText: options.userMessage || '保存草稿',
|
userText: options.userMessage || '保存草稿',
|
||||||
onApplicationActionCompleted: options.onApplicationActionCompleted
|
onApplicationActionCompleted: options.onApplicationActionCompleted
|
||||||
})
|
})
|
||||||
} else if (
|
|
||||||
typeof options.onPreviewReadyForNextTask === 'function' &&
|
|
||||||
Array.isArray(previewMessage.stewardRemainingTasks) &&
|
|
||||||
previewMessage.stewardRemainingTasks.length
|
|
||||||
) {
|
|
||||||
options.onPreviewReadyForNextTask(previewMessage.stewardRemainingTasks, previewMessage)
|
|
||||||
}
|
}
|
||||||
|
// 多 task 串行推进:预览生成后不提前拉起下一个 task(避免和用户在 task1 核对表上的
|
||||||
|
// 保存草稿/提交操作互相打架,导致 task2 状态错乱)。task2 的推进统一交给
|
||||||
|
// onApplicationActionCompleted,在 task1 真正完成(保存草稿/提交成功)后再触发。
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||||
id: pendingMessage.id,
|
id: pendingMessage.id,
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ test('workbench low-confidence application confirmation forwards remaining tasks
|
|||||||
|
|
||||||
assert.ok(previewCall, 'startAiApplicationPreview 应被调用')
|
assert.ok(previewCall, 'startAiApplicationPreview 应被调用')
|
||||||
assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks)
|
assert.deepEqual(previewCall[3].stewardRemainingTasks, remainingTasks)
|
||||||
assert.equal(typeof previewCall[3].onPreviewReadyForNextTask, 'function')
|
// 低置信确认按钮只在 task1 完成后推进 task2,不再在预览生成时提前推进。
|
||||||
|
assert.equal(previewCall[3].onPreviewReadyForNextTask, undefined)
|
||||||
assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function')
|
assert.equal(typeof previewCall[3].onApplicationActionCompleted, 'function')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,63 @@ test('workbench auto-saved application draft continues remaining steward task',
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 () => {
|
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
const requests = []
|
const requests = []
|
||||||
|
|||||||
@@ -347,10 +347,12 @@ test('workbench AI mode asks steward model plan before fallback execution', () =
|
|||||||
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
|
assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/)
|
||||||
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
||||||
assert.match(personalWorkbenchAiModeScript, /stewardRemainingTasks:\s*travelApplicationRequest\.stewardRemainingTasks/)
|
assert.match(personalWorkbenchAiModeScript, /stewardRemainingTasks:\s*travelApplicationRequest\.stewardRemainingTasks/)
|
||||||
assert.match(personalWorkbenchAiModeScript, /onPreviewReadyForNextTask:\s*startModelPlannedNextTask/)
|
|
||||||
assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/)
|
assert.match(personalWorkbenchAiModeScript, /onApplicationActionCompleted:\s*startModelPlannedNextTask/)
|
||||||
|
// 多 task 串行推进:预览生成时不再提前拉起下一个 task(会与用户在 task1 上的操作互相打架),
|
||||||
|
// 改为只在 task1 完成(保存草稿/提交)后通过 onApplicationActionCompleted 推进 task2。
|
||||||
|
assert.doesNotMatch(personalWorkbenchAiModeScript, /onPreviewReadyForNextTask/)
|
||||||
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
||||||
assert.match(applicationPreviewFlowScript, /options\.onPreviewReadyForNextTask/)
|
assert.doesNotMatch(applicationPreviewFlowScript, /onPreviewReadyForNextTask/)
|
||||||
assert.match(applicationPreviewFlowScript, /const actionCompletedHandler = typeof options\.onApplicationActionCompleted === 'function'/)
|
assert.match(applicationPreviewFlowScript, /const actionCompletedHandler = typeof options\.onApplicationActionCompleted === 'function'/)
|
||||||
assert.match(applicationPreviewFlowScript, /actionCompletedHandler\(targetMessage\.stewardRemainingTasks/)
|
assert.match(applicationPreviewFlowScript, /actionCompletedHandler\(targetMessage\.stewardRemainingTasks/)
|
||||||
assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted:\s*options\.onApplicationActionCompleted/)
|
assert.match(applicationPreviewFlowScript, /onApplicationActionCompleted:\s*options\.onApplicationActionCompleted/)
|
||||||
|
|||||||
Reference in New Issue
Block a user