session-first-smoke-test.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. 'use strict';
  2. const assert = require('assert/strict');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const {
  7. detectThinRedLines,
  8. detectClaimedPastAction,
  9. } = require('../mcp/src/core/thin-red-lines');
  10. const {
  11. ClaudeCodeClient,
  12. claudeSessionResetReason,
  13. sessionFirstMode,
  14. sessionFirstSystemPrompt,
  15. applyThinRedLines,
  16. QiweiAgentRuntime,
  17. } = require('../mcp/src/core/agent-runtime');
  18. const { enforceHumanReplyStyle } = require('../mcp/src/core/response-human-style');
  19. function fakeClient(reply) {
  20. return {
  21. async complete() {
  22. return {
  23. content: JSON.stringify({
  24. reply,
  25. confidence: 0.9,
  26. intent: 'consult',
  27. reason: 'fixture',
  28. requiresHuman: false,
  29. profileUpdates: {},
  30. tasks: [],
  31. alerts: [],
  32. }),
  33. };
  34. },
  35. };
  36. }
  37. async function runOnce(conversationMode, reply, inbound = '怎么收费') {
  38. const runtime = new QiweiAgentRuntime({
  39. config: {
  40. conversationMode,
  41. maxToolRounds: 2,
  42. qualityPassScore: 82,
  43. },
  44. knowledge: { search: () => [], knowledgeIndex: () => 'knowledge/faq.md(收费)' },
  45. modelClient: fakeClient(reply),
  46. });
  47. return runtime.run({
  48. conversation: { id: 'c1', contact_id: 'u1' },
  49. messages: [{ direction: 'inbound', content: inbound }],
  50. profile: {},
  51. inboundContent: inbound,
  52. });
  53. }
  54. async function main() {
  55. assert.equal(sessionFirstMode({ conversationMode: 'session' }), true);
  56. assert.equal(sessionFirstMode({}), false);
  57. const system = sessionFirstSystemPrompt({ knowledge: { knowledgeIndex: () => 'knowledge/faq.md' } });
  58. assert.match(system, /行业通用客服/);
  59. assert.doesNotMatch(system, /22 问|房源|小牛看房|【本轮回复计划】/);
  60. const promised = '费用要按服务范围确认。我先帮您核实好再回复您。';
  61. const thickRewrite = enforceHumanReplyStyle({ reply: promised, reason: 'fixture' }, '怎么收费', {}, {});
  62. assert.notEqual(thickRewrite.reply, promised, '厚路径应当改写无凭据承诺');
  63. const sessionOut = await runOnce('session', promised);
  64. assert.equal(sessionOut.content, promised, 'session 模式不得改稿');
  65. assert.equal(sessionOut.rewriteCount, 0);
  66. assert.equal(sessionOut.qualityPassed, true);
  67. assert.equal(sessionOut.qualityPlan.version, 'session-thin-1.0');
  68. assert.equal(sessionOut.toolTrace.some(item => item.tool === 'response_quality_gate'), false);
  69. assert.equal(sessionOut.toolTrace.some(item => item.tool === 'sales_move_planner'), false);
  70. const claimed = '资料已经发给您了,您先看。';
  71. const flagged = applyThinRedLines({ reply: claimed, requiresHuman: false, reason: 'ok' }, {});
  72. assert.equal(flagged.reply, claimed);
  73. assert.equal(flagged.requiresHuman, true);
  74. assert.match(flagged.reason, /thin_redline:claimed_past_action/);
  75. const claimedOut = await runOnce('session', claimed, '资料发了吗');
  76. assert.equal(claimedOut.content, claimed);
  77. assert.equal(claimedOut.requiresHuman, true);
  78. const negated = '我不能空口说已经办好了,得核一下进度再回你。';
  79. assert.equal(detectThinRedLines(negated, {}).length, 0);
  80. assert.equal(detectClaimedPastAction(negated, {}).length, 0);
  81. const questioned = '已经发货了吗?';
  82. assert.equal(detectThinRedLines(questioned, {}).length, 0);
  83. const withTool = detectThinRedLines('系统里看了,流程在 knowledge/faq.md。', {
  84. toolTrace: [{ tool: 'search_knowledge', executed: true }],
  85. });
  86. assert.equal(withTool.length, 0);
  87. const thickOut = await runOnce('', promised);
  88. assert.notEqual(thickOut.content, promised, '未开 session 时厚路径仍应改写');
  89. assert.equal(thickOut.toolTrace.some(item => item.tool === 'response_quality_gate'), true);
  90. const sessionDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-reset-'));
  91. const sessionFile = path.join(sessionDirectory, 'sessions.json');
  92. try {
  93. const client = new ClaudeCodeClient({
  94. claudeExecutable: process.execPath,
  95. claudeWorkdir: process.cwd(),
  96. claudeSessionFile: sessionFile,
  97. claudeProjectId: 'session-reset-smoke',
  98. claudeSessionMaxTurns: 12,
  99. claudeSessionMaxAgeMs: 86400000,
  100. });
  101. const key = 'session-reset-contact';
  102. client.sessionStore.ensure(key, {});
  103. client.sessionStore.markInitialized(key, {});
  104. const originalId = client.sessionStore.get(key).id;
  105. const invokedSessionIds = [];
  106. client.invoke = async (_messages, _context, session) => {
  107. invokedSessionIds.push(session.id);
  108. if (invokedSessionIds.length === 1) throw new Error('Claude Code 处理超时');
  109. return { content: '{}', claudeCode: {} };
  110. };
  111. await assert.rejects(
  112. client.complete([], [], { conversation: { id: key } }),
  113. /处理超时/,
  114. );
  115. const reset = client.sessionStore.get(key);
  116. assert.equal(claudeSessionResetReason(new Error('Claude Code 处理超时')), 'session_timeout');
  117. assert.equal(invokedSessionIds.length, 1, '超时会话应立即结束本轮,避免重复等待');
  118. assert.equal(invokedSessionIds[0], originalId);
  119. assert.notEqual(reset.id, originalId);
  120. assert.equal(reset.initialized, false);
  121. assert.equal(reset.epochHistory.at(-1).closedReason, 'session_timeout');
  122. await client.complete([], [], { conversation: { id: key } });
  123. assert.equal(invokedSessionIds.length, 2, '下一条消息必须使用重建后的新会话');
  124. assert.equal(invokedSessionIds[1], reset.id);
  125. const repaired = client.sessionStore.get(key);
  126. assert.equal(repaired.initialized, true);
  127. } finally {
  128. fs.rmSync(sessionDirectory, { recursive: true, force: true });
  129. }
  130. process.stdout.write('session-first smoke ok\n');
  131. }
  132. main().catch((error) => {
  133. process.stderr.write(`${error.stack || error.message}\n`);
  134. process.exit(1);
  135. });