@reconcrap/boss-recommend-mcp 1.3.23 → 1.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.3.23",
3
+ "version": "1.3.25",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -16,6 +16,7 @@ import {
16
16
  import { __testables as cliTestables } from "./cli.js";
17
17
  import { __testables as indexTestables } from "./index.js";
18
18
  import { BossChatApp } from "../vendor/boss-chat-cli/src/app.js";
19
+ import { BossChatPage } from "../vendor/boss-chat-cli/src/browser/chat-page.js";
19
20
  import { LlmClient, parseLlmJson } from "../vendor/boss-chat-cli/src/services/llm.js";
20
21
 
21
22
  const { handleRequest } = indexTestables;
@@ -398,6 +399,90 @@ async function testBossChatPrepareShouldRetryWhenChatPageIsNotReady() {
398
399
  });
399
400
  }
400
401
 
402
+ async function testBossChatPageShouldTreatBlankChatShellAsOnChatPage() {
403
+ const fakeChromeClient = {
404
+ async callFunction() {
405
+ return {
406
+ href: "https://www.zhipin.com/web/chat/index",
407
+ readyState: "complete",
408
+ hasListContainer: false,
409
+ listItemCount: 0
410
+ };
411
+ }
412
+ };
413
+
414
+ const page = new BossChatPage(fakeChromeClient);
415
+ const pageState = await page.ensureOnChatPage();
416
+ assert.equal(pageState.href, "https://www.zhipin.com/web/chat/index");
417
+
418
+ await assert.rejects(
419
+ () => page.ensureReady(),
420
+ /CHAT_LIST_CONTAINER_NOT_FOUND/
421
+ );
422
+ }
423
+
424
+ async function testBossChatRecoverToChatIndexShouldForceNavigateAndWaitForCompleteLoad() {
425
+ const calls = [];
426
+ let stateIndex = 0;
427
+ const states = [
428
+ {
429
+ href: "https://www.zhipin.com/web/chat/index",
430
+ readyState: "loading",
431
+ hasListContainer: false,
432
+ listItemCount: 0
433
+ },
434
+ {
435
+ href: "https://www.zhipin.com/web/chat/index",
436
+ readyState: "interactive",
437
+ hasListContainer: false,
438
+ listItemCount: 0
439
+ },
440
+ {
441
+ href: "https://www.zhipin.com/web/chat/index",
442
+ readyState: "complete",
443
+ hasListContainer: false,
444
+ listItemCount: 0
445
+ }
446
+ ];
447
+
448
+ const fakeChromeClient = {
449
+ async callFunction(fn, arg) {
450
+ calls.push({ name: fn.name, arg });
451
+ if (fn.name === "browserGetCurrentHref") {
452
+ return { href: "https://www.zhipin.com/web/chat/index" };
453
+ }
454
+ if (fn.name === "browserNavigateToChatIndex") {
455
+ return { ok: true, changed: true, href: "https://www.zhipin.com/web/chat/index" };
456
+ }
457
+ if (fn.name === "browserGetPageState") {
458
+ const value = states[Math.min(stateIndex, states.length - 1)];
459
+ stateIndex += 1;
460
+ return value;
461
+ }
462
+ throw new Error(`unexpected function: ${fn.name}`);
463
+ }
464
+ };
465
+
466
+ const page = new BossChatPage(fakeChromeClient);
467
+ const result = await page.recoverToChatIndex({
468
+ forceNavigate: true,
469
+ waitForReadyState: "complete",
470
+ maxAttempts: 5,
471
+ delayMs: 0
472
+ });
473
+
474
+ assert.equal(result.changed, true);
475
+ assert.equal(result.href, "https://www.zhipin.com/web/chat/index");
476
+ assert.equal(
477
+ calls.some((entry) => entry.name === "browserNavigateToChatIndex" && entry.arg?.force === true),
478
+ true
479
+ );
480
+ assert.equal(
481
+ calls.filter((entry) => entry.name === "browserGetPageState").length >= 3,
482
+ true
483
+ );
484
+ }
485
+
401
486
  async function testBossChatMcpToolsShouldValidateAndRoute() {
402
487
  await withBossChatWorkspace(async (workspaceRoot) => {
403
488
  const toolsResponse = await handleRequest({
@@ -941,6 +1026,8 @@ async function testBossChatAppShouldPersistEvidenceArtifacts() {
941
1026
  async function main() {
942
1027
  await testBossChatAdapterShouldResolveSharedConfigAndInvokeLocalCli();
943
1028
  await testBossChatPrepareShouldRetryWhenChatPageIsNotReady();
1029
+ await testBossChatPageShouldTreatBlankChatShellAsOnChatPage();
1030
+ await testBossChatRecoverToChatIndexShouldForceNavigateAndWaitForCompleteLoad();
944
1031
  await testBossChatMcpToolsShouldValidateAndRoute();
945
1032
  await testBossChatCliShouldSupportRunAndFollowUpParsing();
946
1033
  testBossChatLlmEvidenceGateShouldDemoteMissingEvidence();
@@ -24,6 +24,7 @@ function browserGetPageState() {
24
24
 
25
25
  return {
26
26
  href: window.location.href,
27
+ readyState: document.readyState,
27
28
  hasListContainer: Boolean(listContainer),
28
29
  listItemCount: listItems.length,
29
30
  };
@@ -33,9 +34,10 @@ function browserGetCurrentHref() {
33
34
  return { href: window.location.href };
34
35
  }
35
36
 
36
- function browserNavigateToChatIndex() {
37
+ function browserNavigateToChatIndex(options = {}) {
37
38
  const chatUrl = 'https://www.zhipin.com/web/chat/index';
38
- if (!String(window.location.href || '').includes('/web/chat/index')) {
39
+ const force = options?.force === true;
40
+ if (force || !String(window.location.href || '').includes('/web/chat/index')) {
39
41
  window.location.assign(chatUrl);
40
42
  return { ok: true, changed: true, href: chatUrl };
41
43
  }
@@ -2236,11 +2238,20 @@ export class BossChatPage {
2236
2238
  return target?.type === 'page' && String(target.url || '').includes(CHAT_URL_TOKEN);
2237
2239
  }
2238
2240
 
2239
- async ensureReady() {
2240
- const pageState = await this.chromeClient.callFunction(browserGetPageState);
2241
+ async getPageState() {
2242
+ return this.chromeClient.callFunction(browserGetPageState);
2243
+ }
2244
+
2245
+ async ensureOnChatPage() {
2246
+ const pageState = await this.getPageState();
2241
2247
  if (!pageState?.href?.includes(CHAT_URL_TOKEN)) {
2242
2248
  throw new Error('ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE');
2243
2249
  }
2250
+ return pageState;
2251
+ }
2252
+
2253
+ async ensureReady() {
2254
+ const pageState = await this.ensureOnChatPage();
2244
2255
  if (!pageState.hasListContainer && Number(pageState.listItemCount || 0) <= 0) {
2245
2256
  throw new Error('CHAT_LIST_CONTAINER_NOT_FOUND');
2246
2257
  }
@@ -2250,16 +2261,20 @@ export class BossChatPage {
2250
2261
  async recoverToChatIndex(options = {}) {
2251
2262
  const maxAttempts = options.maxAttempts || 20;
2252
2263
  const delayMs = options.delayMs || 500;
2264
+ const forceNavigate = options.forceNavigate === true;
2265
+ const waitForReadyState = options.waitForReadyState || 'complete';
2253
2266
  const hrefResult = await this.chromeClient.callFunction(browserGetCurrentHref);
2254
- if (String(hrefResult?.href || '').includes(CHAT_URL_TOKEN)) {
2267
+ if (!forceNavigate && String(hrefResult?.href || '').includes(CHAT_URL_TOKEN)) {
2255
2268
  return { changed: false, href: hrefResult?.href || '' };
2256
2269
  }
2257
2270
 
2258
- await this.chromeClient.callFunction(browserNavigateToChatIndex);
2271
+ await this.chromeClient.callFunction(browserNavigateToChatIndex, { force: forceNavigate });
2259
2272
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
2260
2273
  await new Promise((resolve) => setTimeout(resolve, delayMs));
2261
- const state = await this.chromeClient.callFunction(browserGetPageState);
2262
- if (String(state?.href || '').includes(CHAT_URL_TOKEN)) {
2274
+ const state = await this.getPageState();
2275
+ const onChatPage = String(state?.href || '').includes(CHAT_URL_TOKEN);
2276
+ const ready = !waitForReadyState || String(state?.readyState || '').toLowerCase() === String(waitForReadyState).toLowerCase();
2277
+ if (onChatPage && ready) {
2263
2278
  return { changed: true, href: state.href };
2264
2279
  }
2265
2280
  }
@@ -44,6 +44,7 @@ const CLI_FILE_PATH = fileURLToPath(import.meta.url);
44
44
  const MINIMAL_TERMINAL_PATTERNS = [/^进度: /, /^候选人结果: /];
45
45
  const CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index';
46
46
  const CHAT_START_REQUIRED_FIELDS = ['job', 'start_from', 'target_count', 'criteria'];
47
+ const CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS = 3;
47
48
 
48
49
  function sanitizePathToken(value, fallback = 'run') {
49
50
  const token = String(value || '')
@@ -651,6 +652,8 @@ async function connectBossChatPage(chromeClient) {
651
652
  target?.type === 'page' && /zhipin\.com/i.test(String(target?.url || ''));
652
653
  let target = null;
653
654
  let recoveredToChatIndex = false;
655
+ let blankChatPage = false;
656
+ let renavigateAttempts = 0;
654
657
 
655
658
  try {
656
659
  target = await chromeClient.connect(BossChatPage.targetMatcher);
@@ -659,15 +662,41 @@ async function connectBossChatPage(chromeClient) {
659
662
  }
660
663
 
661
664
  const page = new BossChatPage(chromeClient);
662
- try {
663
- await page.ensureReady();
664
- } catch {
665
- await page.recoverToChatIndex();
666
- recoveredToChatIndex = true;
667
- await page.ensureReady();
665
+ for (let attempt = 1; attempt <= CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS + 1; attempt += 1) {
666
+ try {
667
+ await page.ensureReady();
668
+ return {
669
+ target,
670
+ page,
671
+ recoveredToChatIndex,
672
+ blankChatPage,
673
+ renavigateAttempts,
674
+ };
675
+ } catch (error) {
676
+ const message = String(error?.message || error || '');
677
+ const canRetry =
678
+ /ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE|CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)
679
+ && attempt <= CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS;
680
+
681
+ if (!canRetry) {
682
+ if (/CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)) {
683
+ blankChatPage = true;
684
+ await page.ensureOnChatPage();
685
+ break;
686
+ }
687
+ throw error;
688
+ }
689
+
690
+ await page.recoverToChatIndex({
691
+ forceNavigate: true,
692
+ waitForReadyState: 'complete',
693
+ });
694
+ recoveredToChatIndex = true;
695
+ renavigateAttempts += 1;
696
+ }
668
697
  }
669
698
 
670
- return { target, page, recoveredToChatIndex };
699
+ return { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts };
671
700
  }
672
701
 
673
702
  async function handlePrepareRunCommand(args, dataDir) {
@@ -704,7 +733,7 @@ async function handlePrepareRunCommand(args, dataDir) {
704
733
  let chromeClient = null;
705
734
  try {
706
735
  chromeClient = new ChromeClient(mergedProfile.chrome.port);
707
- const { target, page, recoveredToChatIndex } = await connectBossChatPage(chromeClient);
736
+ const { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts } = await connectBossChatPage(chromeClient);
708
737
  const jobs = await page.listJobs();
709
738
  if (!Array.isArray(jobs) || jobs.length === 0) {
710
739
  return {
@@ -723,6 +752,8 @@ async function handlePrepareRunCommand(args, dataDir) {
723
752
  page_url: CHAT_INDEX_URL,
724
753
  connected_target: target?.url || '',
725
754
  recovered_to_chat_index: recoveredToChatIndex,
755
+ blank_chat_page: blankChatPage,
756
+ renavigate_attempts: renavigateAttempts,
726
757
  required_fields: CHAT_START_REQUIRED_FIELDS.slice(),
727
758
  defaults: {
728
759
  profile: String(args.profile || 'default').trim() || 'default',
@@ -1159,10 +1190,13 @@ async function executeRunCommand(args, dataDir) {
1159
1190
 
1160
1191
  chromeClient = new ChromeClient(persistentProfile.chrome.port);
1161
1192
 
1162
- const { target, page, recoveredToChatIndex } = await connectBossChatPage(chromeClient);
1193
+ const { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts } = await connectBossChatPage(chromeClient);
1163
1194
  logger.log(`已连接 Chrome tab: ${target.title || target.url}`);
1164
1195
  if (recoveredToChatIndex) {
1165
- logger.log(`检测到当前标签不在聊天页,已自动跳转到 ${CHAT_INDEX_URL}`);
1196
+ logger.log(`检测到页面不符合预期,已重新跳转到 ${CHAT_INDEX_URL} 并等待加载完成。attempts=${renavigateAttempts}`);
1197
+ }
1198
+ if (blankChatPage) {
1199
+ logger.log('检测到聊天页处于空白未初始化状态,将继续通过岗位选择和首位候选人预热来恢复列表。');
1166
1200
  }
1167
1201
 
1168
1202
  const runProfile = await promptRunProfile({
@@ -475,15 +475,6 @@ function toStringArray(value, maxItems = 8) {
475
475
  return normalized;
476
476
  }
477
477
 
478
- function hasEvidenceGateSignal(parsed, parsedEvidence = null) {
479
- if (!parsed || typeof parsed !== "object") return false;
480
- if (parsed?.evidenceGateEligible === true) return true;
481
- if (Number.isFinite(Number(parsed?.evidenceRawCount))) return true;
482
- if (Number.isFinite(Number(parsed?.evidenceMatchedCount))) return true;
483
- const normalizedEvidence = Array.isArray(parsedEvidence) ? parsedEvidence : toStringArray(parsed?.evidence);
484
- return normalizedEvidence.length > 0;
485
- }
486
-
487
478
  function toLowerSafe(text) {
488
479
  return String(text || "").toLowerCase();
489
480
  }
@@ -4969,26 +4960,17 @@ class RecommendScreenCli {
4969
4960
  const parsed = result && typeof result === "object" ? result : {};
4970
4961
  const rawPassed = parsed?.rawPassed === true || parsed?.passed === true;
4971
4962
  const parsedEvidence = toStringArray(parsed?.evidence);
4972
- const evidenceGateEligible = hasEvidenceGateSignal(parsed, parsedEvidence);
4973
- const evidenceRawCount = evidenceGateEligible
4974
- ? (Number.isFinite(Number(parsed?.evidenceRawCount))
4975
- ? Number(parsed.evidenceRawCount)
4976
- : parsedEvidence.length)
4977
- : null;
4978
- const evidenceMatchedCount = evidenceGateEligible
4979
- ? (Number.isFinite(Number(parsed?.evidenceMatchedCount))
4980
- ? Number(parsed.evidenceMatchedCount)
4981
- : parsedEvidence.length)
4982
- : null;
4983
- const evidenceGateDemoted = parsed?.evidenceGateDemoted === true
4984
- || (evidenceGateEligible && rawPassed && evidenceMatchedCount <= 0);
4963
+ const evidenceRawCount = Number.isFinite(Number(parsed?.evidenceRawCount))
4964
+ ? Number(parsed.evidenceRawCount)
4965
+ : parsedEvidence.length;
4966
+ const evidenceMatchedCount = Number.isFinite(Number(parsed?.evidenceMatchedCount))
4967
+ ? Number(parsed.evidenceMatchedCount)
4968
+ : parsedEvidence.length;
4985
4969
  const cot = normalizeText(parsed?.cot || parsed?.reason || "");
4986
4970
  const summary = normalizeText(parsed?.summary || cot);
4987
- const finalReason = evidenceGateDemoted
4988
- ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${cot ? ` 原始判断依据(CoT): ${cot}` : ""}`
4989
- : (cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。"));
4971
+ const finalReason = cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
4990
4972
  return {
4991
- passed: evidenceGateDemoted ? false : rawPassed,
4973
+ passed: rawPassed,
4992
4974
  rawPassed,
4993
4975
  cot: finalReason,
4994
4976
  reason: finalReason,
@@ -4996,7 +4978,7 @@ class RecommendScreenCli {
4996
4978
  evidence: parsedEvidence,
4997
4979
  evidenceRawCount,
4998
4980
  evidenceMatchedCount,
4999
- evidenceGateDemoted
4981
+ evidenceGateDemoted: false
5000
4982
  };
5001
4983
  }
5002
4984
 
@@ -5280,19 +5262,14 @@ class RecommendScreenCli {
5280
5262
  const reason = cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5281
5263
  const summary = reason;
5282
5264
  const parsedEvidence = toStringArray(parsed?.evidence);
5283
- const evidenceGateEligible = hasEvidenceGateSignal(parsed, parsedEvidence);
5284
- const evidenceRawCount = evidenceGateEligible
5285
- ? (Number.isFinite(Number(parsed?.evidenceRawCount)) ? Number(parsed.evidenceRawCount) : parsedEvidence.length)
5286
- : null;
5287
- const evidenceMatchedCount = evidenceGateEligible
5288
- ? (Number.isFinite(Number(parsed?.evidenceMatchedCount)) ? Number(parsed.evidenceMatchedCount) : parsedEvidence.length)
5289
- : null;
5290
- const evidenceGateDemoted = evidenceGateEligible && rawPassed && (evidenceMatchedCount ?? 0) <= 0;
5291
- const finalReason = evidenceGateDemoted
5292
- ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始判断依据(CoT): ${reason}` : ""}`
5293
- : reason;
5294
- const passed = evidenceGateDemoted ? false : rawPassed;
5295
- const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, parsedEvidence, passed);
5265
+ const evidenceRawCount = Number.isFinite(Number(parsed?.evidenceRawCount))
5266
+ ? Number(parsed.evidenceRawCount)
5267
+ : parsedEvidence.length;
5268
+ const evidenceMatchedCount = Number.isFinite(Number(parsed?.evidenceMatchedCount))
5269
+ ? Number(parsed.evidenceMatchedCount)
5270
+ : parsedEvidence.length;
5271
+ const passed = rawPassed;
5272
+ const enrichedReason = enrichReasonWithEvidence(reason, summary || reason, parsedEvidence, passed);
5296
5273
  return {
5297
5274
  passed,
5298
5275
  rawPassed,
@@ -5302,8 +5279,8 @@ class RecommendScreenCli {
5302
5279
  evidence: parsedEvidence,
5303
5280
  evidenceRawCount,
5304
5281
  evidenceMatchedCount,
5305
- evidenceGateEligible,
5306
- evidenceGateDemoted
5282
+ evidenceGateEligible: false,
5283
+ evidenceGateDemoted: false
5307
5284
  };
5308
5285
  }
5309
5286
 
@@ -5445,17 +5422,11 @@ class RecommendScreenCli {
5445
5422
  const normalizedResume = normalizeText(safeResumeText);
5446
5423
  const normalizedResumeLower = toLowerSafe(normalizedResume);
5447
5424
  const parsedEvidence = toStringArray(parsed?.evidence);
5448
- const evidenceGateEligible = hasEvidenceGateSignal(parsed, parsedEvidence);
5449
5425
  const evidence = [];
5450
- const unmatchedEvidence = [];
5451
- if (evidenceGateEligible) {
5452
- for (const item of parsedEvidence) {
5453
- const matched = matchEvidenceAgainstResume(item, safeResumeText, normalizedResume, normalizedResumeLower);
5454
- if (matched.matched) {
5455
- evidence.push(item);
5456
- } else {
5457
- unmatchedEvidence.push(item);
5458
- }
5426
+ for (const item of parsedEvidence) {
5427
+ const matched = matchEvidenceAgainstResume(item, safeResumeText, normalizedResume, normalizedResumeLower);
5428
+ if (matched.matched) {
5429
+ evidence.push(item);
5459
5430
  }
5460
5431
  }
5461
5432
  const parsedPassed = parsePassedDecision(parsed?.passed);
@@ -5467,19 +5438,8 @@ class RecommendScreenCli {
5467
5438
  `Text model response missing boolean passed decision. content=${truncateText(content, 180)}`
5468
5439
  );
5469
5440
  }
5470
- let passed = rawPassed;
5471
- let finalReason = cot || (passed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5472
- const evidenceGateDemoted = evidenceGateEligible && rawPassed && evidence.length <= 0;
5473
- if (evidenceGateDemoted) {
5474
- passed = false;
5475
- finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${finalReason ? ` 原始判断依据(CoT): ${finalReason}` : ""}`;
5476
- if (unmatchedEvidence.length > 0) {
5477
- log(
5478
- `[EVIDENCE_GATE] passed=true 但证据未命中简历原文,已降级为不通过;` +
5479
- `chunk=${chunkIndex}/${chunkTotal}; unmatched=${unmatchedEvidence.slice(0, 3).join(" | ")}`
5480
- );
5481
- }
5482
- }
5441
+ const passed = rawPassed;
5442
+ const finalReason = cot || (passed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5483
5443
  const summary = finalReason;
5484
5444
  const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
5485
5445
  return {
@@ -5489,9 +5449,9 @@ class RecommendScreenCli {
5489
5449
  reason: enrichedReason,
5490
5450
  summary: summary || enrichedReason,
5491
5451
  evidence,
5492
- evidenceRawCount: evidenceGateEligible ? parsedEvidence.length : null,
5493
- evidenceMatchedCount: evidenceGateEligible ? evidence.length : null,
5494
- evidenceGateDemoted,
5452
+ evidenceRawCount: parsedEvidence.length,
5453
+ evidenceMatchedCount: evidence.length,
5454
+ evidenceGateDemoted: false,
5495
5455
  chunkIndex,
5496
5456
  chunkTotal
5497
5457
  };
@@ -1715,11 +1715,11 @@ async function testVisionEvidenceGateShouldKeepRawPassWithoutExplicitEvidencePro
1715
1715
  assert.equal(result.rawPassed, true);
1716
1716
  assert.equal(result.passed, true);
1717
1717
  assert.equal(result.evidenceGateDemoted, false);
1718
- assert.equal(result.evidenceRawCount, null);
1719
- assert.equal(result.evidenceMatchedCount, null);
1718
+ assert.equal(result.evidenceRawCount, 0);
1719
+ assert.equal(result.evidenceMatchedCount, 0);
1720
1720
  }
1721
1721
 
1722
- async function testVisionEvidenceGateShouldDemoteWhenExplicitlyArmedWithoutEvidence() {
1722
+ async function testVisionEvidenceGateShouldNotDemoteWhenExplicitlyArmedWithoutEvidence() {
1723
1723
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-vision-evidence-gate-explicit-"));
1724
1724
  const cli = new RecommendScreenCli(createArgs(tempDir));
1725
1725
  cli.prepareVisionImageSegmentsForModel = async () => ({
@@ -1740,12 +1740,48 @@ async function testVisionEvidenceGateShouldDemoteWhenExplicitlyArmedWithoutEvide
1740
1740
  });
1741
1741
  const result = await cli.callVisionModel(path.join(tempDir, "fake.png"));
1742
1742
  assert.equal(result.rawPassed, true);
1743
- assert.equal(result.passed, false);
1744
- assert.equal(result.evidenceGateDemoted, true);
1743
+ assert.equal(result.passed, true);
1744
+ assert.equal(result.evidenceGateDemoted, false);
1745
1745
  assert.equal(result.evidenceRawCount, 0);
1746
1746
  assert.equal(result.evidenceMatchedCount, 0);
1747
1747
  }
1748
1748
 
1749
+ async function testTextModelShouldNotDemoteRawPassWhenEvidenceDoesNotMatchResume() {
1750
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-text-no-demote-"));
1751
+ const cli = new RecommendScreenCli(createArgs(tempDir));
1752
+ const originalFetch = global.fetch;
1753
+ global.fetch = async () => ({
1754
+ ok: true,
1755
+ status: 200,
1756
+ async json() {
1757
+ return {
1758
+ choices: [
1759
+ {
1760
+ message: {
1761
+ content: JSON.stringify({
1762
+ passed: true,
1763
+ reason: "matched",
1764
+ summary: "matched",
1765
+ evidence: ["完全不在简历里的证据"]
1766
+ })
1767
+ }
1768
+ }
1769
+ ]
1770
+ };
1771
+ }
1772
+ });
1773
+ try {
1774
+ const result = await cli.callTextModel("这是简历原文,没有那条证据");
1775
+ assert.equal(result.rawPassed, true);
1776
+ assert.equal(result.passed, true);
1777
+ assert.equal(result.evidenceGateDemoted, false);
1778
+ assert.equal(result.evidenceRawCount, 1);
1779
+ assert.equal(result.evidenceMatchedCount, 0);
1780
+ } finally {
1781
+ global.fetch = originalFetch;
1782
+ }
1783
+ }
1784
+
1749
1785
  async function testVisionModelShouldSendAllOrderedChunks() {
1750
1786
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-vision-all-chunks-"));
1751
1787
  const chunkPaths = [];
@@ -1843,7 +1879,8 @@ async function main() {
1843
1879
  await testTextModelShouldSupportLowThinkingForVolcengine();
1844
1880
  await testPrepareVisionImageSegmentsShouldSplitLongImage();
1845
1881
  await testVisionEvidenceGateShouldKeepRawPassWithoutExplicitEvidenceProtocol();
1846
- await testVisionEvidenceGateShouldDemoteWhenExplicitlyArmedWithoutEvidence();
1882
+ await testVisionEvidenceGateShouldNotDemoteWhenExplicitlyArmedWithoutEvidence();
1883
+ await testTextModelShouldNotDemoteRawPassWhenEvidenceDoesNotMatchResume();
1847
1884
  await testVisionModelShouldSendAllOrderedChunks();
1848
1885
  testRecoverablePostActionErrorShouldTreatGreetContinueAndNoButtonAsRecoverable();
1849
1886
  await testRecoverableGreetContinueButtonShouldNotAbortWhenDetailCloseFails();