import assert from 'node:assert/strict' import test from 'node:test' import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT, useTravelReimbursementApplicationPreviewActions } from '../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js' import { normalizeSnapshotMessage, serializeSessionMessages } from '../src/views/scripts/travelReimbursementConversationStateModel.js' import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js' function createPreview(overrides = {}) { return { fields: { applicationType: '差旅费用申请', time: '2026-07-20 至 2026-07-22', location: '上海', reason: '客户现场实施', days: '3天', transportMode: '火车', amount: '1800元' }, ...overrides } } function createActions(overrides = {}) { const persisted = [] const emitted = [] const toasts = [] const state = { conversationId: { value: 'conversation-financial-assistant' }, currentUser: { value: { username: 'zhangsan', name: '张三' } }, draftClaimId: { value: '' }, linkedRequest: { value: null }, reviewActionBusy: { value: false }, sessionSwitchBusy: { value: false }, submitting: { value: false }, emitDraftSaved: (payload) => emitted.push(payload), entrySource: 'application', persistSessionState: () => persisted.push(Date.now()), toast: (message) => toasts.push(message), ...overrides } return { actions: useTravelReimbursementApplicationPreviewActions(state), emitted, persisted, state, toasts } } test('完整 preview 展示前写入稳定签发/动作 request id,并使用 canonical decision', async () => { const targetMessage = { id: 'preview-1', applicationPreview: null } const { actions, persisted } = createActions() let pendingIssueRequestId = '' let pendingActionRequestId = '' global.fetch = async (_url, options) => { const body = JSON.parse(options.body) pendingIssueRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionIssueRequestId pendingActionRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionActionRequestId assert.equal(body.request_id, pendingIssueRequestId) assert.ok(pendingActionRequestId.startsWith('action:')) assert.equal(targetMessage.applicationPreview, null) return { ok: true, async json() { return { decision_id: 'decision-assistant-1', decision_source: 'rule', expires_at: '2026-07-14T10:30:00Z', application_preview: { fields: { reason: '服务端规范事由', location: '上海' } } } } } } const preview = await actions.registerApplicationPreviewDecision({ applicationPreview: createPreview(), sourceText: '7月20日去上海做客户现场实施,坐火车', targetMessage }) assert.equal(preview.decisionId, 'decision-assistant-1') assert.equal(preview.decisionTrackingStatus, 'registered') assert.equal(preview.fields.reason, '服务端规范事由') assert.equal(preview.decisionIssueRequestId, pendingIssueRequestId) assert.equal(preview.decisionActionRequestId, pendingActionRequestId) assert.equal(targetMessage.pendingApplicationPreviewDecision, undefined) assert.ok(persisted.length >= 2) }) test('签发失败后 fail-closed,可编辑并以相同 request id 重试签发', async () => { const targetMessage = { id: 'preview-2', applicationPreview: null } const { actions } = createActions() const requestIds = [] let attempt = 0 global.fetch = async (_url, options) => { attempt += 1 requestIds.push(JSON.parse(options.body).request_id) if (attempt === 1) { return { ok: false, status: 503, async json() { return { detail: '签发服务暂不可用' } } } } return { ok: true, async json() { return { decision_id: 'decision-assistant-retry', application_preview: { fields: { reason: '服务端规范事由' } } } } } } const failed = await actions.registerApplicationPreviewDecision({ applicationPreview: createPreview(), sourceText: '去上海做客户现场实施', targetMessage }) assert.equal(failed.decisionTrackingStatus, 'registration_failed') assert.equal(failed.decisionId, '') assert.equal(actions.canSaveApplicationPreviewDraft(targetMessage), false) await assert.rejects( actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, targetMessage), /尚未通过服务端签发/ ) targetMessage.applicationPreview.fields.reason = '用户修正后的事由' const retried = await actions.retryApplicationPreviewDecision(targetMessage) assert.equal(retried, true) assert.equal(requestIds[1], requestIds[0]) assert.equal(targetMessage.applicationPreview.fields.reason, '用户修正后的事由') assert.equal(targetMessage.applicationPreview.decisionId, 'decision-assistant-retry') }) test('动作失败重试复用 action request id;保存成功更新草稿和 next decision', async () => { const message = { id: 'preview-3', applicationPreview: createPreview({ decisionId: 'decision-assistant-action', decisionTrackingStatus: 'registered', decisionActionRequestId: 'action:stable-retry' }), draftPayload: null } const { actions, emitted, state } = createActions() const actionBodies = [] let attempt = 0 global.fetch = async (_url, options) => { attempt += 1 actionBodies.push(JSON.parse(options.body)) if (attempt === 1) { return { ok: false, status: 503, async json() { return { detail: '动作服务暂不可用' } } } } return { ok: true, async json() { return { status: 'succeeded', result: { draft_payload: { claim_id: 'claim-assistant-draft', claim_no: 'AP-20260714-001', approval_stage: '待提交' }, decision_id: 'decision-assistant-next', decision_expires_at: '2026-07-14T11:00:00Z' } } } } } await assert.rejects( actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message), /动作服务暂不可用/ ) assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry') await actions.saveApplicationPreviewDraft(message) assert.equal(actionBodies[0].request_id, 'action:stable-retry') assert.equal(actionBodies[1].request_id, 'action:stable-retry') assert.equal(actionBodies[1].context_json.entry_source, 'application') assert.equal(actionBodies[1].context_json.source, 'financial_assistant') assert.equal(message.draftPayload.claim_id, 'claim-assistant-draft') assert.equal(state.draftClaimId.value, 'claim-assistant-draft') assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next') assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry') assert.equal(emitted[0].status, 'draft') }) test('decision 过期后清空旧 ID 并开放重新签发', async () => { const message = { id: 'preview-expired', applicationPreview: createPreview({ decisionId: 'decision-expired', decisionTrackingStatus: 'registered', decisionIssueRequestId: 'preview:old', decisionActionRequestId: 'action:expired' }) } const { actions } = createActions() global.fetch = async () => ({ ok: false, status: 409, async json() { return { detail: '该预览决策已过期,请重新生成预览。' } } }) await assert.rejects( actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, message), /已过期/ ) assert.equal(message.applicationPreview.decisionId, '') assert.equal(message.applicationPreview.decisionTrackingStatus, 'registration_failed') assert.notEqual(message.applicationPreview.decisionIssueRequestId, 'preview:old') assert.equal(message.applicationPreview.decisionActionRequestId, '') assert.equal(actions.canRetryApplicationPreviewDecision(message), true) }) test('本地快照保留签发与动作 request id,并把中断中的签发恢复为可重试状态', () => { const pending = createPreview({ decisionIssueRequestId: 'preview:recover', decisionActionRequestId: 'action:recover', decisionTrackingStatus: 'registering' }) const serialized = serializeSessionMessages([{ id: 'preview-4', role: 'assistant', text: '正在签发', attachments: [], pendingApplicationPreviewDecision: pending }]) assert.equal( serialized[0].pendingApplicationPreviewDecision.decisionIssueRequestId, 'preview:recover' ) const restored = normalizeSnapshotMessage(serialized[0]) assert.equal(restored.applicationPreview.decisionIssueRequestId, 'preview:recover') assert.equal(restored.applicationPreview.decisionActionRequestId, 'action:recover') assert.equal(restored.applicationPreview.decisionTrackingStatus, 'registration_failed') }) function createSubmitConfirm({ message, runApplicationPreviewAction, submitComposer }) { const applicationSubmitConfirmDialog = { value: { open: true, message } } const reviewActionBusy = { value: false } const toasts = [] const api = useTravelReimbursementApplicationSubmitConfirm({ activeSessionType: { value: 'application' }, applicationSubmitConfirmDialog, buildStewardFieldItems: () => [], createMessage: (role, text, _attachments, extras = {}) => ({ role, text, ...extras }), emitDraftSaved: () => {}, formatStewardMissingFieldList: () => '', formatStewardOntologyFields: () => '', linkedRequest: { value: null }, messages: { value: [] }, nextTick: (callback) => callback?.(), persistSessionState: () => {}, reviewActionBusy, runApplicationPreviewAction, scrollToBottom: () => {}, submitComposer, submitting: { value: false }, toast: (text) => toasts.push(text) }) return { api, toasts } } test('structured preview 只走认证 action;未签发失败时也不降级到 Orchestrator', async () => { let actionCalls = 0 let orchestratorCalls = 0 const unsignedMessage = { applicationPreview: createPreview({ decisionTrackingStatus: 'registration_failed', decisionId: '' }) } const { api, toasts } = createSubmitConfirm({ message: unsignedMessage, runApplicationPreviewAction: async () => { actionCalls += 1 throw new Error('申请预览尚未通过服务端签发') }, submitComposer: async () => { orchestratorCalls += 1 return null } }) await api.confirmApplicationSubmit() assert.equal(actionCalls, 1) assert.equal(orchestratorCalls, 0) assert.match(toasts[0], /尚未通过服务端签发/) }) test('旧消息没有 structured preview 时保留 Orchestrator fallback', async () => { let actionCalls = 0 let orchestratorCalls = 0 const { api } = createSubmitConfirm({ message: { text: '旧版确认消息' }, runApplicationPreviewAction: async () => { actionCalls += 1 return null }, submitComposer: async () => { orchestratorCalls += 1 return { status: 'succeeded', result: {} } } }) await api.confirmApplicationSubmit() assert.equal(actionCalls, 0) assert.equal(orchestratorCalls, 1) })