metheus-governance-mcp-cli 0.2.199 → 0.2.201

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/cli.mjs CHANGED
@@ -2553,6 +2553,76 @@ async function findServerRunnerRequestForMessageID({
2553
2553
  }
2554
2554
  }
2555
2555
 
2556
+ async function loadRunnerArchiveThreadMessageIndex({
2557
+ runtime,
2558
+ threadID,
2559
+ cache = null,
2560
+ }) {
2561
+ const normalizedThreadID = String(threadID || "").trim();
2562
+ if (
2563
+ cache
2564
+ && String(cache.threadID || "").trim() === normalizedThreadID
2565
+ && cache.messageIndex instanceof Map
2566
+ ) {
2567
+ return cache;
2568
+ }
2569
+ if (!normalizedThreadID || !runtime?.baseURL || !runtime?.token) {
2570
+ return {
2571
+ threadID: normalizedThreadID,
2572
+ messageIndex: new Map(),
2573
+ };
2574
+ }
2575
+ try {
2576
+ const comments = await listThreadCommentsTail({
2577
+ siteBaseURL: runtime.baseURL,
2578
+ threadID: normalizedThreadID,
2579
+ token: runtime.token,
2580
+ timeoutSeconds: runtime.timeoutSeconds,
2581
+ actorUserID: runtime.actor?.user_id,
2582
+ tailLimit: 300,
2583
+ scanLimit: 1200,
2584
+ });
2585
+ const messageIndex = new Map();
2586
+ for (const commentRaw of ensureArray(comments)) {
2587
+ const comment = safeObject(commentRaw);
2588
+ const parsedArchive = safeObject(comment.parsedArchive || parseArchivedChatComment(comment.body));
2589
+ const messageID = intFromRawAllowZero(parsedArchive.messageID, 0);
2590
+ if (messageID > 0 && !messageIndex.has(messageID)) {
2591
+ messageIndex.set(messageID, {
2592
+ ...comment,
2593
+ parsedArchive,
2594
+ });
2595
+ }
2596
+ }
2597
+ return {
2598
+ threadID: normalizedThreadID,
2599
+ messageIndex,
2600
+ };
2601
+ } catch {
2602
+ return {
2603
+ threadID: normalizedThreadID,
2604
+ messageIndex: new Map(),
2605
+ };
2606
+ }
2607
+ }
2608
+
2609
+ async function findRunnerArchiveThreadMessageByID({
2610
+ runtime,
2611
+ threadID,
2612
+ messageID,
2613
+ cache = null,
2614
+ }) {
2615
+ const nextCache = await loadRunnerArchiveThreadMessageIndex({
2616
+ runtime,
2617
+ threadID,
2618
+ cache,
2619
+ });
2620
+ return {
2621
+ cache: nextCache,
2622
+ record: safeObject(nextCache.messageIndex.get(intFromRawAllowZero(messageID, 0))),
2623
+ };
2624
+ }
2625
+
2556
2626
  function resolveRunnerReplyChainConversationContext(state, normalizedRoute, selectedRecord) {
2557
2627
  const parsed = safeObject(selectedRecord?.parsedArchive);
2558
2628
  const explicitConversationID = String(parsed.conversationID || "").trim();
@@ -2600,6 +2670,8 @@ async function resolveRunnerReplyChainConversationContextWithServerFallback({
2600
2670
  normalizedRoute,
2601
2671
  selectedRecord,
2602
2672
  runtime,
2673
+ archiveThreadID = "",
2674
+ hydrationAttempted = false,
2603
2675
  }) {
2604
2676
  const initialState = safeObject(state);
2605
2677
  const initialContext = resolveRunnerReplyChainConversationContext(initialState, normalizedRoute, selectedRecord);
@@ -2611,11 +2683,11 @@ async function resolveRunnerReplyChainConversationContextWithServerFallback({
2611
2683
  };
2612
2684
  }
2613
2685
  const parsed = safeObject(selectedRecord?.parsedArchive);
2614
- const replyToMessageID = intFromRawAllowZero(
2686
+ const initialReplyToMessageID = intFromRawAllowZero(
2615
2687
  parsed.replyToMessageID || safeObject(initialContext).replyToMessageID,
2616
2688
  0,
2617
2689
  );
2618
- if (replyToMessageID <= 0 || !runtime?.baseURL || !runtime?.token) {
2690
+ if (initialReplyToMessageID <= 0) {
2619
2691
  return {
2620
2692
  state: initialState,
2621
2693
  replyChainContext: initialContext,
@@ -2623,43 +2695,142 @@ async function resolveRunnerReplyChainConversationContextWithServerFallback({
2623
2695
  };
2624
2696
  }
2625
2697
  const chatID = String(parsed.chatID || parsed.chatId || "").trim();
2626
- const serverReferencedRequest = await findServerRunnerRequestForMessageID({
2627
- normalizedRoute,
2628
- runtime,
2629
- chatID,
2630
- messageID: replyToMessageID,
2631
- });
2632
- if (serverReferencedRequest.request_key) {
2633
- const requestIndex = normalizeBotRunnerRequests(initialState.requests);
2634
- requestIndex[String(serverReferencedRequest.request_key || "").trim()] = serverReferencedRequest;
2635
- const anchorMessageID = intFromRawAllowZero(serverReferencedRequest.source_message_id, 0) || replyToMessageID;
2698
+ const normalizedArchiveThreadID = firstNonEmptyString([
2699
+ archiveThreadID,
2700
+ selectedRecord?.thread_id,
2701
+ selectedRecord?.threadID,
2702
+ selectedRecord?.threadId,
2703
+ ]);
2704
+ let stateForLookup = initialState;
2705
+ let replyToMessageID = initialReplyToMessageID;
2706
+ let archiveThreadCache = null;
2707
+ let lastAnchorMessageID = intFromRawAllowZero(safeObject(initialContext).anchorMessageID, 0) || initialReplyToMessageID;
2708
+ const visitedMessageIDs = new Set();
2709
+
2710
+ const buildContextFromRequest = (referencedRequestRaw, reasonSuffix = "") => {
2711
+ const referencedRequest = safeObject(referencedRequestRaw);
2712
+ const anchorMessageID = intFromRawAllowZero(referencedRequest.source_message_id, 0) || replyToMessageID;
2636
2713
  return {
2637
- state: {
2638
- ...initialState,
2639
- requests: requestIndex,
2640
- },
2714
+ state: stateForLookup,
2641
2715
  replyChainContext: {
2642
- conversationID: String(serverReferencedRequest.conversation_id || "").trim()
2716
+ conversationID: String(referencedRequest.conversation_id || "").trim()
2643
2717
  || buildSyntheticReplyChainConversationID(normalizedRoute, chatID, anchorMessageID),
2644
- replyToMessageID,
2718
+ replyToMessageID: initialReplyToMessageID,
2645
2719
  anchorMessageID,
2646
- reason: String(serverReferencedRequest.conversation_id || "").trim()
2647
- ? "reply_request_conversation_server"
2648
- : "reply_request_synthetic_server",
2649
- referencedRequest: serverReferencedRequest,
2720
+ reason: String(referencedRequest.conversation_id || "").trim()
2721
+ ? `reply_request_conversation${reasonSuffix}`
2722
+ : `reply_request_synthetic${reasonSuffix}`,
2723
+ referencedRequest,
2650
2724
  },
2651
- hydrated: false,
2725
+ hydrated: hydrationAttempted,
2652
2726
  };
2727
+ };
2728
+
2729
+ for (let hop = 0; hop < 8 && replyToMessageID > 0; hop += 1) {
2730
+ if (visitedMessageIDs.has(replyToMessageID)) {
2731
+ break;
2732
+ }
2733
+ visitedMessageIDs.add(replyToMessageID);
2734
+
2735
+ const localReferencedRequest = safeObject(findRunnerRequestsForMessageID(stateForLookup, normalizedRoute, {
2736
+ chatID,
2737
+ messageID: replyToMessageID,
2738
+ })[0]);
2739
+ if (localReferencedRequest.request_key) {
2740
+ return buildContextFromRequest(localReferencedRequest, hop > 0 ? "_chain" : "");
2741
+ }
2742
+
2743
+ if (runtime?.baseURL && runtime?.token) {
2744
+ const serverReferencedRequest = await findServerRunnerRequestForMessageID({
2745
+ normalizedRoute,
2746
+ runtime,
2747
+ chatID,
2748
+ messageID: replyToMessageID,
2749
+ });
2750
+ if (serverReferencedRequest.request_key) {
2751
+ const requestIndex = normalizeBotRunnerRequests(stateForLookup.requests);
2752
+ requestIndex[String(serverReferencedRequest.request_key || "").trim()] = serverReferencedRequest;
2753
+ stateForLookup = {
2754
+ ...stateForLookup,
2755
+ requests: requestIndex,
2756
+ };
2757
+ return buildContextFromRequest(serverReferencedRequest, hop > 0 ? "_chain_server" : "_server");
2758
+ }
2759
+ }
2760
+
2761
+ if (!normalizedArchiveThreadID || !runtime?.baseURL || !runtime?.token) {
2762
+ break;
2763
+ }
2764
+ const archivedMessageLookup = await findRunnerArchiveThreadMessageByID({
2765
+ runtime,
2766
+ threadID: normalizedArchiveThreadID,
2767
+ messageID: replyToMessageID,
2768
+ cache: archiveThreadCache,
2769
+ });
2770
+ archiveThreadCache = archivedMessageLookup.cache;
2771
+ const archivedRecord = safeObject(archivedMessageLookup.record);
2772
+ const archivedParsed = safeObject(archivedRecord.parsedArchive || parseArchivedChatComment(archivedRecord.body));
2773
+ const archivedMessageID = intFromRawAllowZero(archivedParsed.messageID, 0) || replyToMessageID;
2774
+ if (archivedMessageID > 0) {
2775
+ lastAnchorMessageID = archivedMessageID;
2776
+ }
2777
+ const archivedConversationID = String(archivedParsed.conversationID || "").trim();
2778
+ if (archivedConversationID) {
2779
+ return {
2780
+ state: stateForLookup,
2781
+ replyChainContext: {
2782
+ conversationID: archivedConversationID,
2783
+ replyToMessageID: initialReplyToMessageID,
2784
+ anchorMessageID: archivedMessageID,
2785
+ reason: "reply_chain_archive_conversation",
2786
+ referencedRequest: null,
2787
+ },
2788
+ hydrated: hydrationAttempted,
2789
+ };
2790
+ }
2791
+ const nextReplyToMessageID = intFromRawAllowZero(archivedParsed.replyToMessageID, 0);
2792
+ if (nextReplyToMessageID <= 0) {
2793
+ return {
2794
+ state: stateForLookup,
2795
+ replyChainContext: {
2796
+ conversationID: buildSyntheticReplyChainConversationID(normalizedRoute, chatID, lastAnchorMessageID || archivedMessageID),
2797
+ replyToMessageID: initialReplyToMessageID,
2798
+ anchorMessageID: lastAnchorMessageID || archivedMessageID,
2799
+ reason: "reply_chain_archive_synthetic",
2800
+ referencedRequest: null,
2801
+ },
2802
+ hydrated: hydrationAttempted,
2803
+ };
2804
+ }
2805
+ replyToMessageID = nextReplyToMessageID;
2653
2806
  }
2654
- const hydratedState = await hydrateRunnerRequestLedgerFromServer({
2655
- normalizedRoute,
2656
- runtime,
2657
- });
2658
- const hydratedContext = resolveRunnerReplyChainConversationContext(hydratedState, normalizedRoute, selectedRecord);
2807
+
2808
+ if (!hydrationAttempted && runtime?.baseURL && runtime?.token) {
2809
+ const hydratedState = await hydrateRunnerRequestLedgerFromServer({
2810
+ normalizedRoute,
2811
+ runtime,
2812
+ });
2813
+ const hydratedResolution = await resolveRunnerReplyChainConversationContextWithServerFallback({
2814
+ state: hydratedState,
2815
+ normalizedRoute,
2816
+ selectedRecord,
2817
+ runtime,
2818
+ archiveThreadID: normalizedArchiveThreadID,
2819
+ hydrationAttempted: true,
2820
+ });
2821
+ return {
2822
+ state: hydratedResolution.state,
2823
+ replyChainContext: hydratedResolution.replyChainContext,
2824
+ hydrated: true,
2825
+ };
2826
+ }
2827
+
2659
2828
  return {
2660
- state: hydratedState,
2661
- replyChainContext: hydratedContext,
2662
- hydrated: true,
2829
+ state: stateForLookup,
2830
+ replyChainContext: hydrationAttempted
2831
+ ? resolveRunnerReplyChainConversationContext(stateForLookup, normalizedRoute, selectedRecord)
2832
+ : initialContext,
2833
+ hydrated: hydrationAttempted,
2663
2834
  };
2664
2835
  }
2665
2836
 
@@ -2709,6 +2880,7 @@ async function claimRunnerRequestForHumanComment({
2709
2880
  selectedBotUsernames = [],
2710
2881
  normalizedIntent = "",
2711
2882
  runtime = null,
2883
+ archiveThreadID = "",
2712
2884
  }) {
2713
2885
  const parsed = safeObject(selectedRecord?.parsedArchive);
2714
2886
  const commentKind = String(parsed.kind || "").trim().toLowerCase();
@@ -2724,6 +2896,7 @@ async function claimRunnerRequestForHumanComment({
2724
2896
  normalizedRoute,
2725
2897
  selectedRecord,
2726
2898
  runtime,
2899
+ archiveThreadID,
2727
2900
  });
2728
2901
  const replyChainContext = safeObject(replyChainResolution.replyChainContext);
2729
2902
  const referencedRequest = safeObject(replyChainContext.referencedRequest);
@@ -3314,6 +3487,81 @@ function buildRunnerRootWorkItemTransitionPath(currentStatusRaw, targetStatusRaw
3314
3487
  return [];
3315
3488
  }
3316
3489
 
3490
+ function buildRunnerRequestRecoveryPatchFromRouteState(currentStateRaw, requestRaw, routeKeyHint = "") {
3491
+ const currentState = safeObject(currentStateRaw);
3492
+ const request = safeObject(requestRaw);
3493
+ const requestKey = String(request.request_key || "").trim();
3494
+ if (!requestKey) {
3495
+ return {};
3496
+ }
3497
+ const requestSourceMessageID = intFromRawAllowZero(request.last_source_message_id, 0)
3498
+ || intFromRawAllowZero(request.source_message_id, 0);
3499
+ const routeKeys = uniqueOrderedStrings(
3500
+ [
3501
+ routeKeyHint,
3502
+ String(request.claimed_by_route || "").trim(),
3503
+ ],
3504
+ (value) => String(value || "").trim(),
3505
+ ).filter(Boolean);
3506
+ for (const routeKey of routeKeys) {
3507
+ const routeState = safeObject(safeObject(currentState.routes)[routeKey]);
3508
+ if (!Object.keys(routeState).length) {
3509
+ continue;
3510
+ }
3511
+ const routeSourceMessageID = intFromRawAllowZero(routeState.last_source_message_id, 0)
3512
+ || intFromRawAllowZero(routeState.source_message_id, 0);
3513
+ if (requestSourceMessageID && routeSourceMessageID && requestSourceMessageID !== routeSourceMessageID) {
3514
+ continue;
3515
+ }
3516
+ const patch = {};
3517
+ const recoveredRootWorkItemID = firstNonEmptyString([
3518
+ routeState.active_root_work_item_id,
3519
+ routeState.last_root_work_item_id,
3520
+ ]);
3521
+ if (!String(request.root_work_item_id || "").trim() && recoveredRootWorkItemID) {
3522
+ patch.root_work_item_id = recoveredRootWorkItemID;
3523
+ patch.root_work_item_title = firstNonEmptyString([
3524
+ request.root_work_item_title,
3525
+ routeState.active_root_work_item_title,
3526
+ routeState.last_root_work_item_title,
3527
+ ]);
3528
+ patch.root_work_item_status = normalizeRunnerWorkItemStatus(
3529
+ request.root_work_item_status
3530
+ || routeState.active_root_work_item_status
3531
+ || routeState.last_root_work_item_status,
3532
+ );
3533
+ }
3534
+ const requestStatus = normalizeRunnerRequestStatus(request.status);
3535
+ if (!isFinalRunnerRequestStatus(requestStatus)) {
3536
+ const routeAction = String(routeState.last_action || "").trim().toLowerCase();
3537
+ if (routeAction === "replied") {
3538
+ patch.status = "completed";
3539
+ patch.completed_at = firstNonEmptyString([
3540
+ request.completed_at,
3541
+ request.updated_at,
3542
+ new Date().toISOString(),
3543
+ ]);
3544
+ } else if (routeAction === "error" || routeAction === "skipped" || routeAction === "closed") {
3545
+ patch.status = "closed";
3546
+ patch.closed_at = firstNonEmptyString([
3547
+ request.closed_at,
3548
+ request.updated_at,
3549
+ new Date().toISOString(),
3550
+ ]);
3551
+ patch.closed_reason = firstNonEmptyString([
3552
+ request.closed_reason,
3553
+ routeState.last_reason,
3554
+ routeAction,
3555
+ ]);
3556
+ }
3557
+ }
3558
+ if (Object.keys(patch).length) {
3559
+ return patch;
3560
+ }
3561
+ }
3562
+ return {};
3563
+ }
3564
+
3317
3565
  async function syncRunnerRequestRootWorkItemForOutcome({
3318
3566
  normalizedRoute,
3319
3567
  runtime,
@@ -3327,7 +3575,19 @@ async function syncRunnerRequestRootWorkItemForOutcome({
3327
3575
  };
3328
3576
  }
3329
3577
  const currentState = loadBotRunnerState();
3330
- const request = safeObject(normalizeBotRunnerRequests(currentState.requests)[key]);
3578
+ let request = safeObject(normalizeBotRunnerRequests(currentState.requests)[key]);
3579
+ const recoveryPatch = buildRunnerRequestRecoveryPatchFromRouteState(currentState, request);
3580
+ if (Object.keys(recoveryPatch).length) {
3581
+ const recovered = upsertRunnerRequest(currentState, key, recoveryPatch);
3582
+ request = safeObject(recovered.request);
3583
+ saveBotRunnerState({
3584
+ routes: currentState.routes,
3585
+ sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
3586
+ excludedComments: currentState.excludedComments || currentState.excluded_comments,
3587
+ requests: recovered.requests,
3588
+ consumedComments: currentState.consumedComments || currentState.consumed_comments,
3589
+ });
3590
+ }
3331
3591
  const rootWorkItemID = String(request.root_work_item_id || "").trim();
3332
3592
  if (!rootWorkItemID) {
3333
3593
  return {
@@ -3746,6 +4006,20 @@ function mergeRunnerRequestForServerHydration(localEntryRaw, serverEntryRaw) {
3746
4006
  preserveLocalStringWhenServerBlank("root_thread_id");
3747
4007
  preserveLocalStringWhenServerBlank("root_work_item_created_at");
3748
4008
  preserveLocalStringWhenServerBlank("root_work_item_last_error");
4009
+ const localStatus = normalizeRunnerRequestStatus(localEntry.status);
4010
+ const serverStatus = normalizeRunnerRequestStatus(serverEntry.status);
4011
+ if (isFinalRunnerRequestStatus(localStatus) && !isFinalRunnerRequestStatus(serverStatus)) {
4012
+ merged.status = localStatus;
4013
+ if (String(localEntry.completed_at || "").trim()) {
4014
+ merged.completed_at = String(localEntry.completed_at || "").trim();
4015
+ }
4016
+ if (String(localEntry.closed_at || "").trim()) {
4017
+ merged.closed_at = String(localEntry.closed_at || "").trim();
4018
+ }
4019
+ if (String(localEntry.closed_reason || "").trim()) {
4020
+ merged.closed_reason = String(localEntry.closed_reason || "").trim();
4021
+ }
4022
+ }
3749
4023
  return merged;
3750
4024
  }
3751
4025
 
@@ -3930,9 +4204,18 @@ async function syncRunnerRequestLedgerForProjectToServer({ normalizedRoute, runt
3930
4204
  const commentStates = buildProjectRunnerRequestCommentStatesForSync(state, normalizedRoute);
3931
4205
 
3932
4206
  for (const request of requests) {
4207
+ const recoveryPatch = buildRunnerRequestRecoveryPatchFromRouteState(state, request);
4208
+ const recoveredRequest = Object.keys(recoveryPatch).length
4209
+ ? normalizeBotRunnerRequests({
4210
+ [String(request.request_key || "").trim()]: {
4211
+ ...safeObject(request),
4212
+ ...recoveryPatch,
4213
+ },
4214
+ })[String(request.request_key || "").trim()]
4215
+ : request;
3933
4216
  if (
3934
- isActionableRunnerRequestIntent(request.normalized_intent)
3935
- && !String(request.root_work_item_id || "").trim()
4217
+ isActionableRunnerRequestIntent(recoveredRequest.normalized_intent)
4218
+ && !String(recoveredRequest.root_work_item_id || "").trim()
3936
4219
  ) {
3937
4220
  continue;
3938
4221
  }
@@ -3942,7 +4225,7 @@ async function syncRunnerRequestLedgerForProjectToServer({ normalizedRoute, runt
3942
4225
  token: runtime.token,
3943
4226
  timeoutSeconds: runtime.timeoutSeconds,
3944
4227
  actorUserID: runtime.actor?.user_id,
3945
- request,
4228
+ request: recoveredRequest,
3946
4229
  });
3947
4230
  }
3948
4231
  for (const commentState of commentStates) {
@@ -5321,9 +5604,6 @@ function formatTelegramInboundArchiveComment(normalized) {
5321
5604
  if (normalized.mentionUsernames.length > 0) {
5322
5605
  headerLines.push(`mention_usernames: ${normalized.mentionUsernames.map((item) => `@${item}`).join(", ")}`);
5323
5606
  }
5324
- if (normalized.messageThreadID) {
5325
- headerLines.push(`telegram_topic_id: ${normalized.messageThreadID}`);
5326
- }
5327
5607
  if (normalized.replyToMessageID > 0) {
5328
5608
  headerLines.push(`reply_to_message_id: ${normalized.replyToMessageID}`);
5329
5609
  headerLines.push(`reply_to_sender_is_bot: ${normalized.replyToFromIsBot ? "true" : "false"}`);
@@ -6076,6 +6356,29 @@ function buildRunnerSmallTalkReply({ route, executionPlan } = {}) {
6076
6356
  return templatePool[stableTextModulo(`${displayName}:${roleProfileName}`, templatePool.length)];
6077
6357
  }
6078
6358
 
6359
+ function buildInformationalMiniExecutionOverride({ route, executionPlan } = {}) {
6360
+ const safeRoute = safeObject(route);
6361
+ const safeExecutionPlan = safeObject(executionPlan);
6362
+ const roleProfileName = normalizeRunnerRoleProfileName(
6363
+ safeExecutionPlan.roleProfileName || safeRoute.roleProfile || safeRoute.role || "monitor",
6364
+ ) || "monitor";
6365
+ const lightweightModel = String(
6366
+ resolveResponderAdjudicatorModelDisplayName({ client: "gpt", env: process.env }) || "gpt-5.3-codex-spark",
6367
+ ).trim() || "gpt-5.3-codex-spark";
6368
+ return {
6369
+ mode: "role_profile",
6370
+ role_profile_name: roleProfileName,
6371
+ role_profile: normalizeRunnerRoleProfile(roleProfileName, {
6372
+ client: "gpt",
6373
+ model: lightweightModel,
6374
+ permission_mode: "read_only",
6375
+ reasoning_effort: "low",
6376
+ }),
6377
+ workspace_dir: String(safeExecutionPlan.workspaceDir || safeRoute.workspaceDir || "").trim(),
6378
+ workspace_source: String(safeExecutionPlan.workspaceSource || "").trim(),
6379
+ };
6380
+ }
6381
+
6079
6382
  function summarizeRunnerRequestForStatusLookup(entryRaw) {
6080
6383
  const entry = safeObject(entryRaw);
6081
6384
  return {
@@ -6301,11 +6604,21 @@ async function resolveInformationalQueryReply({
6301
6604
  }) {
6302
6605
  const normalizedIntentType = String(intentType || "").trim();
6303
6606
  const messageText = String(safeObject(selectedRecord?.parsedArchive).body || "").trim();
6607
+ const executionOverride = buildInformationalMiniExecutionOverride({ route, executionPlan });
6304
6608
  if (normalizedIntentType === "small_talk") {
6305
6609
  return {
6306
6610
  handled: true,
6307
- reply: buildRunnerSmallTalkReply({ route, executionPlan }),
6611
+ response_mode: "lookup_only",
6612
+ reply: "",
6308
6613
  source: "small_talk",
6614
+ lookup: {
6615
+ intent_type: "small_talk",
6616
+ user_message: messageText,
6617
+ bot_name: firstNonEmptyString([route?.botName, route?.serverBotName, route?.server_bot_name, route?.name]),
6618
+ bot_role: String(route?.role || route?.roleProfile || "").trim(),
6619
+ proposed_summary: buildRunnerSmallTalkReply({ route, executionPlan }),
6620
+ },
6621
+ execution_override: executionOverride,
6309
6622
  };
6310
6623
  }
6311
6624
  if (false && normalizedIntentType === "small_talk") {
@@ -6323,9 +6636,14 @@ async function resolveInformationalQueryReply({
6323
6636
  : "현재 이 프로젝트는 project-workspaces.json에 로컬 작업 폴더가 바인딩되어 있지 않습니다.";
6324
6637
  return {
6325
6638
  handled: true,
6326
- reply,
6639
+ response_mode: "lookup_only",
6640
+ reply: "",
6327
6641
  source: "project.workspace",
6328
- lookup: workspace,
6642
+ lookup: {
6643
+ ...workspace,
6644
+ proposed_summary: reply,
6645
+ },
6646
+ execution_override: executionOverride,
6329
6647
  };
6330
6648
  }
6331
6649
  if (normalizedIntentType === "bot_role_query") {
@@ -6337,9 +6655,14 @@ async function resolveInformationalQueryReply({
6337
6655
  });
6338
6656
  return {
6339
6657
  handled: true,
6340
- reply: buildProjectBotRolesText(payload),
6658
+ response_mode: "lookup_only",
6659
+ reply: "",
6341
6660
  source: "project.bot_roles",
6342
- lookup: payload,
6661
+ lookup: {
6662
+ ...payload,
6663
+ proposed_summary: buildProjectBotRolesText(payload),
6664
+ },
6665
+ execution_override: executionOverride,
6343
6666
  };
6344
6667
  }
6345
6668
  if (normalizedIntentType === "status_query") {
@@ -6402,9 +6725,14 @@ async function resolveInformationalQueryReply({
6402
6725
  });
6403
6726
  return {
6404
6727
  handled: true,
6405
- reply: buildProjectFileLocateText(payload),
6728
+ response_mode: "lookup_only",
6729
+ reply: "",
6406
6730
  source: "project.file.locate",
6407
- lookup: payload,
6731
+ lookup: {
6732
+ ...payload,
6733
+ proposed_summary: buildProjectFileLocateText(payload),
6734
+ },
6735
+ execution_override: executionOverride,
6408
6736
  };
6409
6737
  }
6410
6738
  return null;
@@ -7032,6 +7360,7 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
7032
7360
  selectedRecord,
7033
7361
  selectedBotUsernames: selectedResponderSelectors,
7034
7362
  runtime,
7363
+ archiveThreadID: archiveThread.threadID,
7035
7364
  });
7036
7365
  };
7037
7366
  if (deferExecution) {
@@ -14080,6 +14409,44 @@ async function runSelftest(flags = {}) {
14080
14409
  && monitorGreeting !== reviewGreeting,
14081
14410
  `monitor=${monitorGreeting} review=${reviewGreeting}`,
14082
14411
  );
14412
+ const informationalMiniReply = await resolveInformationalQueryReply({
14413
+ intentType: "small_talk",
14414
+ route: { botName: "RyoAI_bot", role: "monitor", roleProfile: "monitor" },
14415
+ routeState: {},
14416
+ selectedRecord: {
14417
+ parsedArchive: {
14418
+ body: "@RyoAI_bot 하이",
14419
+ },
14420
+ },
14421
+ runtime: {},
14422
+ executionPlan: {
14423
+ mode: "role_profile",
14424
+ roleProfileName: "monitor",
14425
+ roleProfile: {
14426
+ client: "gpt",
14427
+ model: "gpt-5.4",
14428
+ permissionMode: "workspace_write",
14429
+ reasoningEffort: "medium",
14430
+ },
14431
+ workspaceDir: "C:\\selftest-workspace",
14432
+ workspaceSource: "selftest",
14433
+ },
14434
+ });
14435
+ const expectedInformationalModel = resolveResponderAdjudicatorModelDisplayName({
14436
+ client: "gpt",
14437
+ env: process.env,
14438
+ });
14439
+ push(
14440
+ "small_talk_reply_uses_lookup_only_mini_override",
14441
+ safeObject(informationalMiniReply).handled === true
14442
+ && String(safeObject(informationalMiniReply).response_mode || "") === "lookup_only"
14443
+ && String(safeObject(informationalMiniReply).reply || "") === ""
14444
+ && String(safeObject(safeObject(informationalMiniReply).execution_override).role_profile?.client || "") === "gpt"
14445
+ && String(safeObject(safeObject(informationalMiniReply).execution_override).role_profile?.model || "") === String(expectedInformationalModel || "")
14446
+ && String(safeObject(safeObject(informationalMiniReply).execution_override).role_profile?.permissionMode || "") === "read_only"
14447
+ && String(safeObject(safeObject(informationalMiniReply).execution_override).role_profile?.reasoningEffort || "") === "low",
14448
+ JSON.stringify(safeObject(informationalMiniReply).execution_override || {}),
14449
+ );
14083
14450
  const telegramEnvV2Parsed = parseSimpleEnvText(`
14084
14451
  TELEGRAM_API_BASE_URL=http://127.0.0.1:8999/api
14085
14452
  TELEGRAM_AUTO_CLEAR_WEBHOOK=false
@@ -827,6 +827,7 @@ export async function discoverArchiveThreadForDestination(
827
827
  const providerEnvConfig = requireDependency(deps, "providerEnvConfig");
828
828
  const parseArchivedChatComment = requireDependency(deps, "parseArchivedChatComment");
829
829
  const providerLabel = providerEnvConfig(provider).label;
830
+ const hasConfiguredArchiveWorkItem = archiveWorkItemID && isUUID(archiveWorkItemID);
830
831
 
831
832
  if (archiveThreadID && isUUID(archiveThreadID)) {
832
833
  return {
@@ -837,8 +838,7 @@ export async function discoverArchiveThreadForDestination(
837
838
  }
838
839
 
839
840
  const candidateWorkItemIDs = [];
840
- let reusableWorkItemID = "";
841
- if (archiveWorkItemID && isUUID(archiveWorkItemID)) {
841
+ if (hasConfiguredArchiveWorkItem) {
842
842
  candidateWorkItemIDs.push(archiveWorkItemID);
843
843
  } else {
844
844
  const workItems = await listProjectWorkItems({
@@ -864,11 +864,9 @@ export async function discoverArchiveThreadForDestination(
864
864
  token,
865
865
  timeoutSeconds,
866
866
  }, deps);
867
- let fallbackThreadID = "";
868
867
  for (const thread of threads) {
869
868
  const threadID = String(thread.id || "").trim();
870
869
  if (!threadID) continue;
871
- if (!fallbackThreadID) fallbackThreadID = threadID;
872
870
  const comments = await listThreadComments({
873
871
  siteBaseURL,
874
872
  threadID,
@@ -889,15 +887,8 @@ export async function discoverArchiveThreadForDestination(
889
887
  };
890
888
  }
891
889
  }
892
- if (fallbackThreadID) {
893
- return {
894
- threadID: fallbackThreadID,
895
- workItemID,
896
- source: "fallback-thread",
897
- };
898
- }
899
- if (!reusableWorkItemID) {
900
- reusableWorkItemID = workItemID;
890
+ if (hasConfiguredArchiveWorkItem) {
891
+ break;
901
892
  }
902
893
  }
903
894
 
@@ -907,7 +898,9 @@ export async function discoverArchiveThreadForDestination(
907
898
  const archiveDescription = `Auto-created archive work item for ${providerLabel} destination ${destinationLabel}.`;
908
899
  try {
909
900
  const threadBody = `Auto-created archive thread for ${providerLabel} destination ${destinationLabel} (chat_id=${destination.chatID}).`;
910
- let resolvedWorkItemID = String(reusableWorkItemID || "").trim();
901
+ let resolvedWorkItemID = hasConfiguredArchiveWorkItem
902
+ ? String(archiveWorkItemID || "").trim()
903
+ : "";
911
904
  if (!resolvedWorkItemID) {
912
905
  const createdWorkItem = await createProjectWorkItem(
913
906
  {
@@ -941,7 +934,9 @@ export async function discoverArchiveThreadForDestination(
941
934
  return {
942
935
  threadID: createdThreadID,
943
936
  workItemID: resolvedWorkItemID,
944
- source: reusableWorkItemID ? "auto-created-thread-on-existing-work-item" : "auto-created-thread",
937
+ source: hasConfiguredArchiveWorkItem
938
+ ? "auto-created-thread-on-configured-work-item"
939
+ : "auto-created-thread",
945
940
  };
946
941
  }
947
942
  }
@@ -653,6 +653,62 @@ function isInformationalHumanIntentType(intentType) {
653
653
  ].includes(normalizeHumanIntentType(intentType));
654
654
  }
655
655
 
656
+ function resolveInformationalQueryExecutionPlan(executionPlan, directInformationalReply, route) {
657
+ const basePlan = safeObject(executionPlan);
658
+ const override = safeObject(safeObject(directInformationalReply).execution_override);
659
+ const overrideRoleProfile = safeObject(override.role_profile || override.roleProfile);
660
+ if (String(override.mode || "").trim().toLowerCase() !== "role_profile") {
661
+ return basePlan;
662
+ }
663
+ if (!String(overrideRoleProfile.client || "").trim()) {
664
+ return basePlan;
665
+ }
666
+ return {
667
+ ...basePlan,
668
+ mode: "role_profile",
669
+ roleProfileName: String(
670
+ override.role_profile_name
671
+ || override.roleProfileName
672
+ || basePlan.roleProfileName
673
+ || route?.roleProfile
674
+ || route?.role
675
+ || "",
676
+ ).trim(),
677
+ roleProfile: {
678
+ ...safeObject(basePlan.roleProfile),
679
+ client: String(overrideRoleProfile.client || safeObject(basePlan.roleProfile).client || "").trim(),
680
+ model: String(overrideRoleProfile.model || safeObject(basePlan.roleProfile).model || "").trim(),
681
+ permissionMode: String(
682
+ overrideRoleProfile.permission_mode
683
+ || overrideRoleProfile.permissionMode
684
+ || safeObject(basePlan.roleProfile).permissionMode
685
+ || "",
686
+ ).trim(),
687
+ reasoningEffort: String(
688
+ overrideRoleProfile.reasoning_effort
689
+ || overrideRoleProfile.reasoningEffort
690
+ || safeObject(basePlan.roleProfile).reasoningEffort
691
+ || "",
692
+ ).trim(),
693
+ },
694
+ workspaceDir: String(
695
+ override.workspace_dir
696
+ || override.workspaceDir
697
+ || basePlan.workspaceDir
698
+ || route?.workspaceDir
699
+ || "",
700
+ ).trim(),
701
+ workspaceSource: String(
702
+ override.workspace_source
703
+ || override.workspaceSource
704
+ || basePlan.workspaceSource
705
+ || "",
706
+ ).trim(),
707
+ usedCommandFallback: false,
708
+ fallbackReason: "",
709
+ };
710
+ }
711
+
656
712
  function buildHumanIntentContextWindowOptions(intentType) {
657
713
  const normalizedIntentType = normalizeHumanIntentType(intentType);
658
714
  if (normalizedIntentType === "small_talk") {
@@ -3168,7 +3224,7 @@ async function resolvePublicConversationContext({
3168
3224
  managedMentions,
3169
3225
  peerMap,
3170
3226
  deps,
3171
- executionPlan,
3227
+ executionPlan: effectiveExecutionPlan,
3172
3228
  });
3173
3229
  const participantSelectors = uniqueOrdered(ensureArray(humanIntent.participantSelectors));
3174
3230
  const initialResponderSelectors = uniqueOrdered(
@@ -4263,6 +4319,11 @@ export async function processRunnerSelectedRecord({
4263
4319
  };
4264
4320
  }
4265
4321
  }
4322
+ const effectiveExecutionPlan = resolveInformationalQueryExecutionPlan(
4323
+ executionPlan,
4324
+ directInformationalReply,
4325
+ normalizedRoute,
4326
+ );
4266
4327
  const aiPayload = buildRunnerInputPayload({
4267
4328
  route: normalizedRoute,
4268
4329
  bot: {
@@ -4277,7 +4338,7 @@ export async function processRunnerSelectedRecord({
4277
4338
  selectedRecord,
4278
4339
  contextWindow,
4279
4340
  projectContextItems,
4280
- executionPlan,
4341
+ executionPlan: effectiveExecutionPlan,
4281
4342
  serializedTriggerPolicy: serializeRunnerTriggerPolicy(normalizedRoute.triggerPolicy),
4282
4343
  serializedArchivePolicy: serializeRunnerArchivePolicy(normalizedRoute.archivePolicy),
4283
4344
  triggerDecision: effectiveTriggerDecision,
@@ -4402,7 +4463,7 @@ export async function processRunnerSelectedRecord({
4402
4463
  bot,
4403
4464
  destination,
4404
4465
  archiveThread,
4405
- executionPlan,
4466
+ executionPlan: effectiveExecutionPlan,
4406
4467
  runtime,
4407
4468
  executionDeps,
4408
4469
  triggerDecision: effectiveTriggerDecision,
@@ -4426,7 +4487,7 @@ export async function processRunnerSelectedRecord({
4426
4487
  inputPayload: aiPayload,
4427
4488
  route: normalizedRoute,
4428
4489
  destination,
4429
- executionPlan,
4490
+ executionPlan: effectiveExecutionPlan,
4430
4491
  deps: executionDeps,
4431
4492
  });
4432
4493
  }
@@ -4482,12 +4543,12 @@ export async function processRunnerSelectedRecord({
4482
4543
 
4483
4544
  const artifactValidation = artifactValidationOverride && Object.keys(artifactValidationOverride).length > 0
4484
4545
  ? artifactValidationOverride
4485
- : validateWorkspaceArtifacts && String(executionPlan.workspaceDir || "").trim()
4546
+ : validateWorkspaceArtifacts && String(effectiveExecutionPlan.workspaceDir || "").trim()
4486
4547
  ? validateWorkspaceArtifacts(
4487
4548
  ensureArray(aiResult?.artifacts),
4488
- executionPlan.workspaceDir,
4549
+ effectiveExecutionPlan.workspaceDir,
4489
4550
  {
4490
- permissionMode: String(executionPlan.roleProfile?.permissionMode || "workspace_write").trim() || "workspace_write",
4551
+ permissionMode: String(effectiveExecutionPlan.roleProfile?.permissionMode || "workspace_write").trim() || "workspace_write",
4491
4552
  },
4492
4553
  )
4493
4554
  : {
@@ -4520,7 +4581,7 @@ export async function processRunnerSelectedRecord({
4520
4581
  last_artifact_paths: summarizeValidatedArtifactPaths(artifactValidation),
4521
4582
  last_artifact_errors: artifactErrors,
4522
4583
  last_boundary_violations: boundaryViolations,
4523
- last_workspace_dir: String(executionPlan.workspaceDir || "").trim(),
4584
+ last_workspace_dir: String(effectiveExecutionPlan.workspaceDir || "").trim(),
4524
4585
  ...intentStatePatch,
4525
4586
  }),
4526
4587
  );
@@ -4541,8 +4602,8 @@ export async function processRunnerSelectedRecord({
4541
4602
  conversation_lead_bot: String(conversationContext?.leadBotUsername || "").trim(),
4542
4603
  conversation_summary_bot: String(conversationContext?.summaryBotUsername || "").trim(),
4543
4604
  conversation_allowed_responders: ensureArray(conversationContext?.allowedResponderSelectors),
4544
- execution_mode: executionPlan.mode,
4545
- role_profile: executionPlan.roleProfileName,
4605
+ execution_mode: effectiveExecutionPlan.mode,
4606
+ role_profile: effectiveExecutionPlan.roleProfileName,
4546
4607
  artifact_validation: String(artifactValidation.status || "").trim() || "policy_violation",
4547
4608
  artifact_paths: summarizeValidatedArtifactPaths(artifactValidation),
4548
4609
  artifact_errors: artifactErrors,
@@ -4615,7 +4676,7 @@ export async function processRunnerSelectedRecord({
4615
4676
  botReplyText: String(aiResult.reply || "").trim(),
4616
4677
  currentBotUsername: String(bot?.username || bot?.name || "").trim(),
4617
4678
  managedBots: buildConversationParticipantViews(Array.from(auditPeerMap.keys()), auditPeerMap),
4618
- workspaceDir: String(executionPlan.workspaceDir || process.cwd()).trim() || process.cwd(),
4679
+ workspaceDir: String(effectiveExecutionPlan.workspaceDir || process.cwd()).trim() || process.cwd(),
4619
4680
  });
4620
4681
  if (audit?.requires_actionable_contract === true && audit?.reply_satisfies_request !== true) {
4621
4682
  requiresActionableContract = true;
@@ -4661,7 +4722,7 @@ export async function processRunnerSelectedRecord({
4661
4722
  inputPayload: forcedContractPayload,
4662
4723
  route: normalizedRoute,
4663
4724
  destination,
4664
- executionPlan,
4725
+ executionPlan: effectiveExecutionPlan,
4665
4726
  deps: executionDeps,
4666
4727
  });
4667
4728
  executionContract = conversationContext?.mode === "public_multi_bot"
@@ -4727,7 +4788,7 @@ export async function processRunnerSelectedRecord({
4727
4788
  last_reason: reason,
4728
4789
  last_conversation_id: String(conversationContext?.id || "").trim(),
4729
4790
  last_conversation_stage: String(conversationContext?.stage || "").trim(),
4730
- last_workspace_dir: String(executionPlan.workspaceDir || "").trim(),
4791
+ last_workspace_dir: String(effectiveExecutionPlan.workspaceDir || "").trim(),
4731
4792
  ...intentStatePatch,
4732
4793
  }),
4733
4794
  );
@@ -5022,7 +5083,7 @@ export async function processRunnerSelectedRecord({
5022
5083
  ? "error"
5023
5084
  : "",
5024
5085
  last_context_suggestion_error: String(projectContextSuggestion?.error || "").trim(),
5025
- last_workspace_dir: String(executionPlan.workspaceDir || "").trim(),
5086
+ last_workspace_dir: String(effectiveExecutionPlan.workspaceDir || "").trim(),
5026
5087
  last_execution_plan: executedRolePlan && Object.keys(executedRolePlan).length > 0 ? executedRolePlan : undefined,
5027
5088
  ...intentStatePatch,
5028
5089
  }),
@@ -5036,7 +5097,7 @@ export async function processRunnerSelectedRecord({
5036
5097
  route_name: normalizedRoute.name,
5037
5098
  outcome: deliveryResult.delivery.dryRun ? "dry_run" : "replied",
5038
5099
  detail: [
5039
- `${deliveryResult.delivery.dryRun ? "dry-run prepared" : "replied"} as ${bot.name || bot.id}${executionPlan.usedCommandFallback ? " (legacy command fallback)" : ""}`,
5100
+ `${deliveryResult.delivery.dryRun ? "dry-run prepared" : "replied"} as ${bot.name || bot.id}${effectiveExecutionPlan.usedCommandFallback ? " (legacy command fallback)" : ""}`,
5040
5101
  effectiveTriggerDecision?.trigger ? `trigger=${String(effectiveTriggerDecision.trigger || "").trim()}` : "",
5041
5102
  effectiveConversationContext?.mode === "public_multi_bot"
5042
5103
  ? [
@@ -5082,8 +5143,8 @@ export async function processRunnerSelectedRecord({
5082
5143
  evidence_ids: ensureArray(aiResult?.evidenceItems).map((item) => String(safeObject(item).id || "").trim()).filter(Boolean),
5083
5144
  evidence_paths: ensureArray(aiResult?.evidenceItems).map((item) => String(safeObject(item).path || "").trim()).filter(Boolean),
5084
5145
  reply_chars: String(sanitizedReplyText || "").length,
5085
- execution_mode: executionPlan.mode,
5086
- role_profile: executionPlan.roleProfileName,
5146
+ execution_mode: effectiveExecutionPlan.mode,
5147
+ role_profile: effectiveExecutionPlan.roleProfileName,
5087
5148
  executed_role_plan: executedRolePlan && Object.keys(executedRolePlan).length > 0 ? executedRolePlan : undefined,
5088
5149
  archive_status: deliveryResult.archive?.dry_run
5089
5150
  ? "dry_run"
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import http from "node:http";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import process from "node:process";
@@ -22,6 +23,67 @@ function ensureArray(value) {
22
23
  return Array.isArray(value) ? value : [];
23
24
  }
24
25
 
26
+ async function startReplyChainSelftestServer({
27
+ projectID,
28
+ threadID,
29
+ comments = [],
30
+ }) {
31
+ const state = {
32
+ comments: ensureArray(comments).slice(),
33
+ };
34
+ const writeJSON = (res, statusCode, payload) => {
35
+ const body = `${JSON.stringify(payload)}\n`;
36
+ res.writeHead(statusCode, {
37
+ "content-type": "application/json",
38
+ "content-length": Buffer.byteLength(body),
39
+ connection: "close",
40
+ });
41
+ res.end(body);
42
+ };
43
+ const server = http.createServer((req, res) => {
44
+ const requestURL = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
45
+ const pathname = requestURL.pathname;
46
+ if (req.method === "GET" && pathname === `/api/v1/projects/${encodeURIComponent(projectID)}/runner-requests`) {
47
+ writeJSON(res, 200, []);
48
+ return;
49
+ }
50
+ if (req.method === "GET" && pathname === `/api/v1/projects/${encodeURIComponent(projectID)}/runner-request-comments`) {
51
+ writeJSON(res, 200, []);
52
+ return;
53
+ }
54
+ if (req.method === "GET" && pathname === `/api/v1/threads/${encodeURIComponent(threadID)}/comments`) {
55
+ writeJSON(res, 200, state.comments);
56
+ return;
57
+ }
58
+ writeJSON(res, 404, { error: "not_found" });
59
+ });
60
+ await new Promise((resolve, reject) => {
61
+ server.listen(0, "127.0.0.1", (error) => {
62
+ if (error) {
63
+ reject(error);
64
+ return;
65
+ }
66
+ resolve();
67
+ });
68
+ });
69
+ const address = server.address();
70
+ const port = typeof address === "object" && address ? address.port : 0;
71
+ return {
72
+ baseURL: `http://127.0.0.1:${port}`,
73
+ async close() {
74
+ await new Promise((resolve, reject) => {
75
+ server.close((error) => {
76
+ if (error) {
77
+ reject(error);
78
+ return;
79
+ }
80
+ resolve();
81
+ });
82
+ });
83
+ },
84
+ };
85
+ }
86
+
25
87
  export async function runSelftestRunnerScenarios(push, deps) {
26
88
  const selftestProjectID = String(requireValue(deps, "selftestProjectID"));
27
89
  const allowLegacyRunnerCommandEnvKey = String(requireValue(deps, "allowLegacyRunnerCommandEnvKey"));
@@ -569,8 +631,11 @@ export async function runSelftestRunnerScenarios(push, deps) {
569
631
  },
570
632
  postJSONWithAuthHeaders: async (url, _timeoutSeconds, _token, payload) => {
571
633
  createdCalls.push({ url, payload });
572
- if (url.includes("/api/v1/workitems/existing-workitem-1/threads")) {
573
- return JSON.stringify({ id: "thread-existing-1" });
634
+ if (url.endsWith("/api/v1/workitems")) {
635
+ return JSON.stringify({ work_item_id: "workitem-new-1" });
636
+ }
637
+ if (url.includes("/api/v1/workitems/workitem-new-1/threads")) {
638
+ return JSON.stringify({ id: "thread-new-1" });
574
639
  }
575
640
  throw new Error(`unexpected url ${url}`);
576
641
  },
@@ -585,16 +650,72 @@ export async function runSelftestRunnerScenarios(push, deps) {
585
650
  },
586
651
  );
587
652
  push(
588
- "archive_thread_reuses_existing_work_item_without_thread",
589
- archiveThread.threadID === "thread-existing-1"
590
- && archiveThread.workItemID === "existing-workitem-1"
591
- && archiveThread.source === "auto-created-thread-on-existing-work-item"
653
+ "archive_thread_does_not_reuse_title_only_work_item_without_chat_match",
654
+ archiveThread.threadID === "thread-new-1"
655
+ && archiveThread.workItemID === "workitem-new-1"
656
+ && archiveThread.source === "auto-created-thread"
592
657
  && workItemListHeaders[0]?.["X-Actor-User-Id"] === "99999999-9999-9999-9999-999999999999"
593
- && createdCalls.length === 1,
658
+ && createdCalls.length === 2,
594
659
  `thread=${archiveThread.threadID} work_item=${archiveThread.workItemID} created_calls=${createdCalls.length} actor_header=${String(workItemListHeaders[0]?.["X-Actor-User-Id"] || "")}`,
595
660
  );
596
661
  } catch (err) {
597
- push("archive_thread_reuses_existing_work_item_without_thread", false, String(err?.message || err));
662
+ push("archive_thread_does_not_reuse_title_only_work_item_without_chat_match", false, String(err?.message || err));
663
+ }
664
+
665
+ try {
666
+ const createdCalls = [];
667
+ const archiveThread = await discoverArchiveThreadForDestination(
668
+ {
669
+ siteBaseURL: "https://example.test",
670
+ projectID: selftestProjectID,
671
+ provider: "telegram",
672
+ destination: {
673
+ id: "dest-1",
674
+ provider: "telegram",
675
+ label: "Main Room",
676
+ chatID: "-1001",
677
+ isActive: true,
678
+ },
679
+ token: "test-token",
680
+ timeoutSeconds: 10,
681
+ actorUserID: "99999999-9999-9999-9999-999999999999",
682
+ archiveWorkItemID: "12345678-1234-1234-1234-1234567890ab",
683
+ },
684
+ {
685
+ getJSONWithAuth: async () => [],
686
+ getJSONWithAuthHeaders: async (url) => {
687
+ if (url.includes("/api/v1/workitems/12345678-1234-1234-1234-1234567890ab/threads")) {
688
+ return [];
689
+ }
690
+ return [];
691
+ },
692
+ postJSONWithAuthHeaders: async (url, _timeoutSeconds, _token, payload) => {
693
+ createdCalls.push({ url, payload });
694
+ if (url.includes("/api/v1/workitems/12345678-1234-1234-1234-1234567890ab/threads")) {
695
+ return JSON.stringify({ id: "thread-configured-1" });
696
+ }
697
+ throw new Error(`unexpected url ${url}`);
698
+ },
699
+ parseJSONText: JSON.parse,
700
+ normalizeChatDestination: (value) => value,
701
+ normalizeBotProvider: (value) => value,
702
+ normalizeBotRole: (value) => value,
703
+ normalizeRunnerRoute: (value) => value,
704
+ providerEnvConfig: () => ({ label: "Telegram" }),
705
+ parseArchivedChatComment: () => null,
706
+ isUUID: (value) => /^[0-9a-f-]{36}$/i.test(String(value || "")),
707
+ },
708
+ );
709
+ push(
710
+ "archive_thread_reuses_only_configured_archive_work_item_without_chat_match",
711
+ archiveThread.threadID === "thread-configured-1"
712
+ && archiveThread.workItemID === "12345678-1234-1234-1234-1234567890ab"
713
+ && archiveThread.source === "auto-created-thread-on-configured-work-item"
714
+ && createdCalls.length === 1,
715
+ `thread=${archiveThread.threadID} work_item=${archiveThread.workItemID} created_calls=${createdCalls.length}`,
716
+ );
717
+ } catch (err) {
718
+ push("archive_thread_reuses_only_configured_archive_work_item_without_chat_match", false, String(err?.message || err));
598
719
  }
599
720
 
600
721
  const aliasMaps = buildToolAliasMaps([
@@ -1716,6 +1837,103 @@ export async function runSelftestRunnerScenarios(push, deps) {
1716
1837
  `conversation=${String(replyChainStatusLoopLookup.resolved_conversation_id || "(none)")} source_message=${String(safeObject(replyChainStatusLoopLookup.related_request).source_message_id || "(none)")} intent=${String(safeObject(replyChainStatusLoopLookup.related_request).normalized_intent || "(none)")} root_work_item=${String(safeObject(replyChainStatusLoopLookup.root_work_item).id || "(none)")}`,
1717
1838
  );
1718
1839
 
1840
+ const multihopConversationID = "reply_chain:telegram:-100123:701";
1841
+ const multihopThreadID = "reply-chain-thread-701";
1842
+ const multihopBotReplyBody = formatBotReplyArchiveComment({
1843
+ provider: "telegram",
1844
+ bot: {
1845
+ id: "bot-ryoai",
1846
+ name: "RyoAI_bot",
1847
+ username: "RyoAI_bot",
1848
+ role: "monitor",
1849
+ },
1850
+ destination: {
1851
+ chatID: "-100123",
1852
+ label: "Main Room",
1853
+ },
1854
+ replyText: "Still working on the original task.",
1855
+ messageID: 702,
1856
+ replyToMessageID: 701,
1857
+ });
1858
+ const multihopServer = await startReplyChainSelftestServer({
1859
+ projectID: selftestProjectID,
1860
+ threadID: multihopThreadID,
1861
+ comments: [
1862
+ {
1863
+ id: "comment-bot-reply-702",
1864
+ body: multihopBotReplyBody,
1865
+ },
1866
+ ],
1867
+ });
1868
+ try {
1869
+ saveBotRunnerState({
1870
+ routes: {},
1871
+ sharedInboxes: {},
1872
+ excludedComments: {},
1873
+ requests: {
1874
+ "request-key-701": {
1875
+ request_key: "request-key-701",
1876
+ project_id: selftestProjectID,
1877
+ provider: "telegram",
1878
+ chat_id: "-100123",
1879
+ source_message_id: 701,
1880
+ conversation_id: "",
1881
+ selected_bot_usernames: ["ryoai_bot"],
1882
+ normalized_intent: "ctxpack_mutation",
1883
+ status: "running",
1884
+ claimed_by_route: requestRouteKey,
1885
+ root_work_item_id: "root-work-item-701",
1886
+ root_work_item_title: "Root reply-chain task",
1887
+ root_work_item_status: "doing",
1888
+ root_thread_id: "root-thread-701",
1889
+ updated_at: "2026-03-22T02:03:00.000Z",
1890
+ },
1891
+ },
1892
+ consumedComments: {},
1893
+ });
1894
+ const multihopReplyClaim = await claimRunnerRequestForHumanComment({
1895
+ normalizedRoute: requestRoute,
1896
+ routeKey: requestRouteKey,
1897
+ selectedRecord: {
1898
+ id: "comment-request-root-task-5",
1899
+ createdAt: "2026-03-22T00:07:00.000Z",
1900
+ updatedAt: "2026-03-22T00:07:00.000Z",
1901
+ parsedArchive: {
1902
+ kind: "telegram_message",
1903
+ chatID: "-100123",
1904
+ chatType: "supergroup",
1905
+ body: "@RyoAI_bot is the original task done now?",
1906
+ messageID: 704,
1907
+ replyToMessageID: 702,
1908
+ senderIsBot: false,
1909
+ mentionUsernames: ["ryoai_bot"],
1910
+ },
1911
+ },
1912
+ selectedBotUsernames: ["ryoai_bot"],
1913
+ normalizedIntent: "status_query",
1914
+ runtime: {
1915
+ baseURL: multihopServer.baseURL,
1916
+ token: "selftest-token",
1917
+ timeoutSeconds: 5,
1918
+ actor: { user_id: "selftest-user" },
1919
+ },
1920
+ archiveThreadID: multihopThreadID,
1921
+ });
1922
+ const multihopClaimedRequest = safeObject(
1923
+ safeObject(loadBotRunnerState().requests)[multihopReplyClaim.requestKey],
1924
+ );
1925
+ push(
1926
+ "runner_request_claim_resolves_multihop_reply_chain_anchor",
1927
+ multihopReplyClaim.ok === true
1928
+ && String(multihopClaimedRequest.conversation_id || "") === multihopConversationID
1929
+ && String(multihopClaimedRequest.root_work_item_id || "") === "root-work-item-701"
1930
+ && String(multihopClaimedRequest.root_thread_id || "") === "root-thread-701",
1931
+ `conversation=${String(multihopClaimedRequest.conversation_id || "(none)")} root_work_item=${String(multihopClaimedRequest.root_work_item_id || "(none)")} root_thread=${String(multihopClaimedRequest.root_thread_id || "(none)")}`,
1932
+ );
1933
+ } finally {
1934
+ await multihopServer.close();
1935
+ }
1936
+
1719
1937
  const stateBeforeCleanup = loadBotRunnerState();
1720
1938
  saveBotRunnerState({
1721
1939
  ...stateBeforeCleanup,
@@ -8401,6 +8619,159 @@ export async function runSelftestRunnerScenarios(push, deps) {
8401
8619
  push("small_talk_direct_mention_skips_dynamic_execution_plan", false, String(err?.message || err));
8402
8620
  }
8403
8621
 
8622
+ try {
8623
+ let capturedExecutionPlan = null;
8624
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-runner-selftest-small-talk-mini-"));
8625
+ const processed = await processRunnerSelectedRecord({
8626
+ routeKey: "small-talk-mini-execution-key",
8627
+ normalizedRoute: normalizeRunnerRoute({
8628
+ name: "telegram-monitor-small-talk-mini",
8629
+ project_id: selftestProjectID,
8630
+ provider: "telegram",
8631
+ role: "monitor",
8632
+ role_profile: "monitor",
8633
+ destination_id: "dest-1",
8634
+ destination_label: "Main Room",
8635
+ server_bot_name: "RyoAI_bot",
8636
+ server_bot_id: "bot-1",
8637
+ trigger_policy: {
8638
+ mentions_only: true,
8639
+ direct_messages: true,
8640
+ reply_to_bot_messages: true,
8641
+ },
8642
+ archive_policy: {
8643
+ mirror_replies: true,
8644
+ dedupe_inbound: true,
8645
+ dedupe_outbound: true,
8646
+ skip_bot_messages: true,
8647
+ },
8648
+ dry_run_delivery: true,
8649
+ }),
8650
+ selectedRecord: {
8651
+ id: "comment-small-talk-mini-execution",
8652
+ createdAt: "2026-03-24T00:11:00.000Z",
8653
+ parsedArchive: {
8654
+ kind: "telegram_message",
8655
+ chatID: "-100123",
8656
+ chatType: "supergroup",
8657
+ body: "@RyoAI_bot 하이",
8658
+ messageID: 212,
8659
+ sender: "human",
8660
+ senderIsBot: false,
8661
+ mentionUsernames: ["ryoai_bot"],
8662
+ },
8663
+ },
8664
+ pendingOrdered: [],
8665
+ bot: {
8666
+ id: "bot-1",
8667
+ name: "RyoAI_bot",
8668
+ username: "RyoAI_bot",
8669
+ role: "monitor",
8670
+ provider: "telegram",
8671
+ },
8672
+ destination: {
8673
+ id: "dest-1",
8674
+ label: "Main Room",
8675
+ provider: "telegram",
8676
+ chatID: "-100123",
8677
+ },
8678
+ archiveThread: {
8679
+ threadID: "thread-1",
8680
+ workItemID: "work-item-1",
8681
+ },
8682
+ executionPlan: {
8683
+ mode: "role_profile",
8684
+ roleProfileName: "monitor",
8685
+ roleProfile: {
8686
+ client: "gpt",
8687
+ model: "gpt-5.4",
8688
+ permissionMode: "workspace_write",
8689
+ reasoningEffort: "medium",
8690
+ },
8691
+ workspaceDir,
8692
+ workspaceSource: "selftest",
8693
+ usedCommandFallback: false,
8694
+ },
8695
+ runtime: {
8696
+ baseURL: "https://example.test",
8697
+ token: "selftest-token",
8698
+ timeoutSeconds: 30,
8699
+ actor: { user_id: "user-1" },
8700
+ },
8701
+ deps: {
8702
+ saveRunnerRouteState: () => {},
8703
+ startRunnerTypingHeartbeat: () => ({ async stop() {} }),
8704
+ runRunnerAIExecution: async ({ executionPlan }) => {
8705
+ capturedExecutionPlan = safeObject(executionPlan);
8706
+ return {
8707
+ skip: false,
8708
+ reply: "안녕하세요.",
8709
+ replyToMessageID: 212,
8710
+ };
8711
+ },
8712
+ performLocalBotDelivery: async () => ({
8713
+ delivery: { dryRun: true, body: {} },
8714
+ archive: {},
8715
+ }),
8716
+ serializeRunnerTriggerPolicy: (value) => value,
8717
+ serializeRunnerArchivePolicy: (value) => value,
8718
+ buildRunnerExecutionDeps: () => ({
8719
+ resolveInformationalQueryReply: async () => ({
8720
+ handled: true,
8721
+ response_mode: "lookup_only",
8722
+ reply: "",
8723
+ source: "small_talk",
8724
+ lookup: { intent_type: "small_talk" },
8725
+ execution_override: {
8726
+ mode: "role_profile",
8727
+ role_profile_name: "monitor",
8728
+ role_profile: {
8729
+ client: "gpt",
8730
+ model: "gpt-5-mini",
8731
+ permissionMode: "read_only",
8732
+ reasoningEffort: "low",
8733
+ },
8734
+ workspace_dir: workspaceDir,
8735
+ workspace_source: "selftest",
8736
+ },
8737
+ }),
8738
+ planRoleExecutionWithAI: async () => ({
8739
+ requiresExecution: true,
8740
+ summaryRole: "worker",
8741
+ steps: [{ role: "worker", goal: "unexpected", artifactsRequired: true }],
8742
+ }),
8743
+ resolveRunnerExecutionPlanForRole: () => ({
8744
+ mode: "role_profile",
8745
+ roleProfileName: "worker",
8746
+ roleProfile: {
8747
+ client: "sample",
8748
+ model: "",
8749
+ permissionMode: "workspace_write",
8750
+ reasoningEffort: "medium",
8751
+ },
8752
+ workspaceDir,
8753
+ workspaceSource: "selftest",
8754
+ usedCommandFallback: false,
8755
+ }),
8756
+ }),
8757
+ buildRunnerDeliveryDeps: () => ({}),
8758
+ buildRunnerRuntimeDeps: () => ({}),
8759
+ resolveConversationPeerBots: () => [],
8760
+ },
8761
+ });
8762
+ push(
8763
+ "small_talk_lookup_only_uses_mini_execution_plan",
8764
+ processed.kind === "replied"
8765
+ && String(safeObject(capturedExecutionPlan).roleProfile?.client || "") === "gpt"
8766
+ && ["gpt-5-mini", "gpt-5.3-codex-spark"].includes(String(safeObject(capturedExecutionPlan).roleProfile?.model || ""))
8767
+ && String(safeObject(capturedExecutionPlan).roleProfile?.permissionMode || "") === "read_only"
8768
+ && String(safeObject(capturedExecutionPlan).roleProfile?.reasoningEffort || "") === "low",
8769
+ JSON.stringify(capturedExecutionPlan || {}),
8770
+ );
8771
+ } catch (err) {
8772
+ push("small_talk_lookup_only_uses_mini_execution_plan", false, String(err?.message || err));
8773
+ }
8774
+
8404
8775
  try {
8405
8776
  let aiCalls = 0;
8406
8777
  let capturedInputPayload = null;
@@ -868,6 +868,57 @@ export async function runSelftestTelegramE2E(push, deps) {
868
868
  `count=${telegramE2EServer.state.comments.filter((item) => String(item.body || "").includes("message_id: 353")).length}`,
869
869
  );
870
870
 
871
+ telegramE2EServer.state.comments = [];
872
+ telegramE2EServer.state.updates = [
873
+ {
874
+ update_id: 354,
875
+ message: {
876
+ message_id: 354,
877
+ message_thread_id: 999,
878
+ date: Math.floor(Date.now() / 1000),
879
+ chat: {
880
+ id: Number(e2eDestination.chat_id),
881
+ type: "supergroup",
882
+ title: e2eDestination.label,
883
+ },
884
+ from: {
885
+ id: 5001,
886
+ is_bot: false,
887
+ first_name: "Operator",
888
+ username: "operator_user",
889
+ },
890
+ text: "topic metadata should not be archived",
891
+ },
892
+ },
893
+ ];
894
+ await archiveLocalTelegramMessagesForRoute({
895
+ routeKey: pruneRouteKey,
896
+ route: pruneRoute,
897
+ routeState: safeObject(loadBotRunnerState().routes[pruneRouteKey]),
898
+ runtime: {
899
+ baseURL: telegramE2EServer.baseURL,
900
+ timeoutSeconds: 10,
901
+ token: e2eToken,
902
+ actor: {
903
+ user_id: e2eActorUserID,
904
+ },
905
+ },
906
+ bot: e2eBot,
907
+ destination: {
908
+ chatID: e2eDestination.chat_id,
909
+ },
910
+ archiveThread: {
911
+ threadID: e2eThreadID,
912
+ },
913
+ deps: buildRunnerRuntimeDeps(),
914
+ });
915
+ const topicArchiveBody = String(safeObject(telegramE2EServer.state.comments[0]).body || "");
916
+ push(
917
+ "telegram_inbound_archive_omits_unused_topic_metadata",
918
+ topicArchiveBody.includes("message_id: 354") && !topicArchiveBody.includes("telegram_topic_id:"),
919
+ topicArchiveBody,
920
+ );
921
+
871
922
  telegramE2EServer.state.comments = [];
872
923
  telegramE2EServer.state.updates = [
873
924
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.199",
3
+ "version": "0.2.201",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [