switchroom 0.14.89 → 0.14.91
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/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78 -11
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +154 -132
- package/telegram-plugin/gateway/answer-thread-resolve.ts +79 -52
- package/telegram-plugin/gateway/gateway.ts +99 -7
- package/telegram-plugin/gateway/subagent-status-surface.test.ts +118 -0
- package/telegram-plugin/gateway/subagent-status-surface.ts +69 -0
- package/telegram-plugin/tests/answer-thread-resolve.test.ts +30 -5
package/dist/cli/switchroom.js
CHANGED
|
@@ -49815,8 +49815,8 @@ var {
|
|
|
49815
49815
|
} = import__.default;
|
|
49816
49816
|
|
|
49817
49817
|
// src/build-info.ts
|
|
49818
|
-
var VERSION = "0.14.
|
|
49819
|
-
var COMMIT_SHA = "
|
|
49818
|
+
var VERSION = "0.14.91";
|
|
49819
|
+
var COMMIT_SHA = "e938daab";
|
|
49820
49820
|
|
|
49821
49821
|
// src/cli/agent.ts
|
|
49822
49822
|
init_source();
|
package/package.json
CHANGED
|
@@ -78,7 +78,7 @@ const mcp = new Server(
|
|
|
78
78
|
'',
|
|
79
79
|
'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
|
|
80
80
|
'',
|
|
81
|
-
'If a message includes message_thread_id, it came from a forum topic. The reply tool
|
|
81
|
+
'If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from — the framework owns the answer\'s topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
|
|
82
82
|
'',
|
|
83
83
|
'The default format is "html" — write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
84
84
|
'',
|
|
@@ -24571,7 +24571,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
|
|
|
24571
24571
|
"",
|
|
24572
24572
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24573
24573
|
"",
|
|
24574
|
-
"If a message includes message_thread_id, it came from a forum topic. The reply tool
|
|
24574
|
+
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
24575
24575
|
"",
|
|
24576
24576
|
'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
24577
24577
|
"",
|
|
@@ -48000,12 +48000,23 @@ function decideFeedReopen(input) {
|
|
|
48000
48000
|
|
|
48001
48001
|
// gateway/answer-thread-resolve.ts
|
|
48002
48002
|
function resolveAnswerThreadId(input) {
|
|
48003
|
-
if (input.
|
|
48004
|
-
|
|
48003
|
+
if (input.frameworkTopicAuthority === false) {
|
|
48004
|
+
if (input.explicitThreadId != null)
|
|
48005
|
+
return input.explicitThreadId;
|
|
48006
|
+
if (input.originResolved)
|
|
48007
|
+
return input.originThreadId;
|
|
48008
|
+
if (input.liveThreadId != null)
|
|
48009
|
+
return input.liveThreadId;
|
|
48010
|
+
if (input.lastEndedResolvedForChat)
|
|
48011
|
+
return input.lastEndedThreadIdForChat;
|
|
48012
|
+
return input.liveThreadId;
|
|
48013
|
+
}
|
|
48005
48014
|
if (input.originResolved)
|
|
48006
48015
|
return input.originThreadId;
|
|
48007
|
-
if (input.
|
|
48016
|
+
if (input.liveTurnPresent)
|
|
48008
48017
|
return input.liveThreadId;
|
|
48018
|
+
if (input.explicitThreadId != null)
|
|
48019
|
+
return input.explicitThreadId;
|
|
48009
48020
|
if (input.lastEndedResolvedForChat)
|
|
48010
48021
|
return input.lastEndedThreadIdForChat;
|
|
48011
48022
|
return input.liveThreadId;
|
|
@@ -52889,11 +52900,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52889
52900
|
}
|
|
52890
52901
|
|
|
52891
52902
|
// ../src/build-info.ts
|
|
52892
|
-
var VERSION = "0.14.
|
|
52893
|
-
var COMMIT_SHA = "
|
|
52894
|
-
var COMMIT_DATE = "2026-06-
|
|
52895
|
-
var LATEST_PR =
|
|
52896
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52903
|
+
var VERSION = "0.14.91";
|
|
52904
|
+
var COMMIT_SHA = "e938daab";
|
|
52905
|
+
var COMMIT_DATE = "2026-06-09T03:14:21Z";
|
|
52906
|
+
var LATEST_PR = 2241;
|
|
52907
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52897
52908
|
|
|
52898
52909
|
// gateway/boot-version.ts
|
|
52899
52910
|
function formatRelativeAgo(iso) {
|
|
@@ -53534,6 +53545,21 @@ function resolveWorkerFeedDispatch(sub, watcherDescription) {
|
|
|
53534
53545
|
};
|
|
53535
53546
|
}
|
|
53536
53547
|
|
|
53548
|
+
// gateway/subagent-status-surface.ts
|
|
53549
|
+
function resolveSubagentStatusSurface(input) {
|
|
53550
|
+
if (!input.isBackground) {
|
|
53551
|
+
if (input.liveTurnPresent)
|
|
53552
|
+
return "nest";
|
|
53553
|
+
if (!input.orphanStatusEnabled)
|
|
53554
|
+
return "skip";
|
|
53555
|
+
return input.workerFeedEnabled ? "worker-feed" : "skip";
|
|
53556
|
+
}
|
|
53557
|
+
return input.workerFeedEnabled ? "worker-feed" : "legacy-relay";
|
|
53558
|
+
}
|
|
53559
|
+
function isOrphanSubagentStatusEnabled(envVal) {
|
|
53560
|
+
return envVal !== "0";
|
|
53561
|
+
}
|
|
53562
|
+
|
|
53537
53563
|
// gateway/resolve-calling-subagent.ts
|
|
53538
53564
|
function resolveCallingSubagent(opts) {
|
|
53539
53565
|
if (opts.db == null)
|
|
@@ -54140,6 +54166,7 @@ var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ?
|
|
|
54140
54166
|
var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
|
|
54141
54167
|
var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
|
|
54142
54168
|
var FRAMEWORK_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== "0";
|
|
54169
|
+
var REPLY_TOPIC_AUTHORITY_ENABLED = process.env.SWITCHROOM_REPLY_TOPIC_AUTHORITY !== "0";
|
|
54143
54170
|
var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
|
|
54144
54171
|
var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
|
|
54145
54172
|
var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
|
|
@@ -54233,15 +54260,18 @@ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, origin
|
|
|
54233
54260
|
originResolved: originTurn != null,
|
|
54234
54261
|
originThreadId: originTurn?.sessionThreadId,
|
|
54235
54262
|
liveThreadId: liveTurn?.sessionThreadId,
|
|
54263
|
+
liveTurnPresent: liveTurn != null,
|
|
54236
54264
|
lastEndedResolvedForChat: recovered != null,
|
|
54237
|
-
lastEndedThreadIdForChat: recovered?.sessionThreadId
|
|
54265
|
+
lastEndedThreadIdForChat: recovered?.sessionThreadId,
|
|
54266
|
+
frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED
|
|
54238
54267
|
});
|
|
54239
|
-
const via = explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
|
|
54268
|
+
const via = REPLY_TOPIC_AUTHORITY_ENABLED ? originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn != null ? "live" : explicitThreadId != null ? "explicit" : recovered != null ? "recovered" : "none" : explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
|
|
54269
|
+
const explicitOverridden = REPLY_TOPIC_AUTHORITY_ENABLED && explicitThreadId != null && (originTurn != null || liveTurn != null) && threadId !== explicitThreadId;
|
|
54240
54270
|
const ownerTurn = originTurn ?? recovered ?? liveTurn;
|
|
54241
54271
|
const isSupergroup = chatId.startsWith("-100");
|
|
54242
54272
|
const unrouted = isSupergroup && threadId == null && ownerTurn == null;
|
|
54243
54273
|
const misrouteRisk = isSupergroup && via === "live" && hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId);
|
|
54244
|
-
process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (via === "quoted" ? " QUOTED(framework-origin)" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + (misrouteRisk ? " MISROUTE_RISK(no-echo\u2192live-successor)" : "") + `
|
|
54274
|
+
process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (via === "quoted" ? " QUOTED(framework-origin)" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + (misrouteRisk ? " MISROUTE_RISK(no-echo\u2192live-successor)" : "") + (explicitOverridden ? ` EXPLICIT_OVERRIDDEN(model\u2192${explicitThreadId},routed\u2192${threadId ?? "-"})` : "") + `
|
|
54245
54275
|
`);
|
|
54246
54276
|
return threadId;
|
|
54247
54277
|
}
|
|
@@ -64554,6 +64584,7 @@ var didOneTimeSetup = false;
|
|
|
64554
64584
|
if (watcherAgentDir != null) {
|
|
64555
64585
|
const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED);
|
|
64556
64586
|
const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== "0";
|
|
64587
|
+
const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS);
|
|
64557
64588
|
const workerActivityFeed = createWorkerActivityFeed({
|
|
64558
64589
|
bot: {
|
|
64559
64590
|
sendMessage: async (cid, text, sendOpts) => {
|
|
@@ -64635,6 +64666,22 @@ var didOneTimeSetup = false;
|
|
|
64635
64666
|
}
|
|
64636
64667
|
}
|
|
64637
64668
|
}
|
|
64669
|
+
return;
|
|
64670
|
+
}
|
|
64671
|
+
if (resolveSubagentStatusSurface({
|
|
64672
|
+
isBackground: false,
|
|
64673
|
+
liveTurnPresent: false,
|
|
64674
|
+
workerFeedEnabled,
|
|
64675
|
+
orphanStatusEnabled
|
|
64676
|
+
}) === "worker-feed") {
|
|
64677
|
+
workerActivityFeed.finish(agentId, {
|
|
64678
|
+
description: dispatch.feedDescription,
|
|
64679
|
+
lastTool: null,
|
|
64680
|
+
toolCount,
|
|
64681
|
+
latestSummary: resultText,
|
|
64682
|
+
elapsedMs: durationMs,
|
|
64683
|
+
state: outcome === "failed" ? "failed" : "done"
|
|
64684
|
+
});
|
|
64638
64685
|
}
|
|
64639
64686
|
return;
|
|
64640
64687
|
}
|
|
@@ -64701,6 +64748,26 @@ var didOneTimeSetup = false;
|
|
|
64701
64748
|
}
|
|
64702
64749
|
const isBackground = dispatch.isBackground;
|
|
64703
64750
|
if (!isBackground) {
|
|
64751
|
+
const surface = resolveSubagentStatusSurface({
|
|
64752
|
+
isBackground: false,
|
|
64753
|
+
liveTurnPresent: currentTurn != null,
|
|
64754
|
+
workerFeedEnabled,
|
|
64755
|
+
orphanStatusEnabled
|
|
64756
|
+
});
|
|
64757
|
+
if (surface === "worker-feed") {
|
|
64758
|
+
const origin = resolveSubagentOriginChat(agentId);
|
|
64759
|
+
workerActivityFeed.update(agentId, origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
|
|
64760
|
+
description: dispatch.feedDescription,
|
|
64761
|
+
lastTool,
|
|
64762
|
+
toolCount,
|
|
64763
|
+
latestSummary,
|
|
64764
|
+
elapsedMs,
|
|
64765
|
+
state: "running"
|
|
64766
|
+
}, origin?.threadId);
|
|
64767
|
+
return;
|
|
64768
|
+
}
|
|
64769
|
+
if (surface !== "nest")
|
|
64770
|
+
return;
|
|
64704
64771
|
const turn = currentTurn;
|
|
64705
64772
|
if (turn == null)
|
|
64706
64773
|
return;
|
|
@@ -24268,7 +24268,7 @@ var init_bridge = __esm(async () => {
|
|
|
24268
24268
|
"",
|
|
24269
24269
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24270
24270
|
"",
|
|
24271
|
-
"If a message includes message_thread_id, it came from a forum topic. The reply tool
|
|
24271
|
+
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
24272
24272
|
"",
|
|
24273
24273
|
'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
24274
24274
|
"",
|
|
@@ -1,160 +1,190 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { resolveAnswerThreadId, type AnswerThreadInput } from './answer-thread-resolve.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Distinct symbolic thread ids so an output's provenance is unambiguous (no two
|
|
5
|
+
// tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
|
|
6
|
+
const T = 70 // explicit
|
|
7
|
+
const O = 50 // origin
|
|
8
|
+
const L = 30 // live
|
|
9
|
+
const E = 90 // lastEnded
|
|
10
|
+
|
|
11
|
+
// ── Framework-authority (DEFAULT) — human-readable map ──────────────────────
|
|
12
|
+
describe('resolveAnswerThreadId — framework authority (default)', () => {
|
|
13
|
+
it('origin turn wins over the model explicit (the General→CRM fix)', () => {
|
|
6
14
|
expect(
|
|
7
15
|
resolveAnswerThreadId({
|
|
8
|
-
explicitThreadId:
|
|
16
|
+
explicitThreadId: T,
|
|
9
17
|
originResolved: true,
|
|
10
|
-
originThreadId:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
lastEndedThreadIdForChat: 9,
|
|
18
|
+
originThreadId: O,
|
|
19
|
+
liveTurnPresent: true,
|
|
20
|
+
liveThreadId: L,
|
|
14
21
|
}),
|
|
15
|
-
).toBe(
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('(2) origin turn thread wins over the live turn (the Brevo→Meta fix)', () => {
|
|
19
|
-
expect(
|
|
20
|
-
resolveAnswerThreadId({ originResolved: true, originThreadId: 3, liveThreadId: 4 }),
|
|
21
|
-
).toBe(3)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('(2) a DM origin (resolved, thread undefined) pins to undefined, not the live thread', () => {
|
|
25
|
-
expect(
|
|
26
|
-
resolveAnswerThreadId({ originResolved: true, originThreadId: undefined, liveThreadId: 4 }),
|
|
27
|
-
).toBeUndefined()
|
|
22
|
+
).toBe(O)
|
|
28
23
|
})
|
|
29
24
|
|
|
30
|
-
it('
|
|
25
|
+
it('THE marko case: a General-origin question (origin resolved, thread undefined) + model explicit ⇒ General, NOT the model topic', () => {
|
|
31
26
|
expect(
|
|
32
|
-
resolveAnswerThreadId({
|
|
33
|
-
|
|
27
|
+
resolveAnswerThreadId({
|
|
28
|
+
explicitThreadId: 4, // model tried to send the answer to CRM (topic 4)
|
|
29
|
+
originResolved: true,
|
|
30
|
+
originThreadId: undefined, // General origin carries no thread
|
|
31
|
+
liveTurnPresent: true,
|
|
32
|
+
liveThreadId: undefined,
|
|
33
|
+
}),
|
|
34
|
+
).toBeUndefined() // → General, where it was asked
|
|
34
35
|
})
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
it('(4) no explicit, no origin, NO live turn → recovers the most-recent ended turn thread', () => {
|
|
38
|
-
// The marko bug: a reply that fired after the orphaned-reply backstop ended
|
|
39
|
-
// its turn. Pre-fix this returned undefined (General); now it recovers topic 3.
|
|
37
|
+
it('a live in-flight turn wins over the model explicit even with no origin echo', () => {
|
|
40
38
|
expect(
|
|
41
39
|
resolveAnswerThreadId({
|
|
40
|
+
explicitThreadId: T,
|
|
42
41
|
originResolved: false,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
lastEndedThreadIdForChat: 3,
|
|
42
|
+
liveTurnPresent: true,
|
|
43
|
+
liveThreadId: L,
|
|
46
44
|
}),
|
|
47
|
-
).toBe(
|
|
45
|
+
).toBe(L)
|
|
48
46
|
})
|
|
49
47
|
|
|
50
|
-
it('
|
|
48
|
+
it('a General live turn (present, thread undefined) still beats the model explicit', () => {
|
|
51
49
|
expect(
|
|
52
50
|
resolveAnswerThreadId({
|
|
51
|
+
explicitThreadId: T,
|
|
53
52
|
originResolved: false,
|
|
53
|
+
liveTurnPresent: true,
|
|
54
54
|
liveThreadId: undefined,
|
|
55
|
-
lastEndedResolvedForChat: true,
|
|
56
|
-
lastEndedThreadIdForChat: undefined,
|
|
57
55
|
}),
|
|
58
56
|
).toBeUndefined()
|
|
59
57
|
})
|
|
60
58
|
|
|
61
|
-
it('
|
|
59
|
+
it('model explicit is honoured only when there is NO origin and NO live turn (orphaned/proactive)', () => {
|
|
62
60
|
expect(
|
|
63
61
|
resolveAnswerThreadId({
|
|
62
|
+
explicitThreadId: T,
|
|
64
63
|
originResolved: false,
|
|
65
|
-
|
|
66
|
-
lastEndedResolvedForChat: true,
|
|
67
|
-
lastEndedThreadIdForChat: 3,
|
|
64
|
+
liveTurnPresent: false,
|
|
68
65
|
}),
|
|
69
|
-
).toBe(
|
|
66
|
+
).toBe(T)
|
|
70
67
|
})
|
|
71
68
|
|
|
72
|
-
it('
|
|
69
|
+
it('late reply, no anchor, no explicit → recovers the last-ended topic', () => {
|
|
73
70
|
expect(
|
|
74
71
|
resolveAnswerThreadId({
|
|
75
72
|
originResolved: false,
|
|
76
|
-
|
|
77
|
-
lastEndedResolvedForChat:
|
|
73
|
+
liveTurnPresent: false,
|
|
74
|
+
lastEndedResolvedForChat: true,
|
|
75
|
+
lastEndedThreadIdForChat: E,
|
|
78
76
|
}),
|
|
79
|
-
).
|
|
77
|
+
).toBe(E)
|
|
80
78
|
})
|
|
81
79
|
|
|
82
|
-
it('pure DM (
|
|
80
|
+
it('pure DM (no anchors) → undefined', () => {
|
|
83
81
|
expect(resolveAnswerThreadId({ originResolved: false })).toBeUndefined()
|
|
84
82
|
})
|
|
85
83
|
})
|
|
86
84
|
|
|
85
|
+
// ── Legacy precedence (kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) ───────
|
|
86
|
+
describe('resolveAnswerThreadId — legacy (frameworkTopicAuthority:false)', () => {
|
|
87
|
+
it('explicit wins outright (the old behaviour)', () => {
|
|
88
|
+
expect(
|
|
89
|
+
resolveAnswerThreadId({
|
|
90
|
+
frameworkTopicAuthority: false,
|
|
91
|
+
explicitThreadId: T,
|
|
92
|
+
originResolved: true,
|
|
93
|
+
originThreadId: O,
|
|
94
|
+
liveThreadId: L,
|
|
95
|
+
}),
|
|
96
|
+
).toBe(T)
|
|
97
|
+
})
|
|
98
|
+
it('origin beats live when no explicit (unchanged)', () => {
|
|
99
|
+
expect(
|
|
100
|
+
resolveAnswerThreadId({
|
|
101
|
+
frameworkTopicAuthority: false,
|
|
102
|
+
originResolved: true,
|
|
103
|
+
originThreadId: O,
|
|
104
|
+
liveThreadId: L,
|
|
105
|
+
}),
|
|
106
|
+
).toBe(O)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
87
110
|
// ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
|
|
88
111
|
//
|
|
89
|
-
// The operator standard (memory feedback_prove_finite_fsm_not_sample): a
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
// The operator standard (memory feedback_prove_finite_fsm_not_sample): a passing
|
|
113
|
+
// sample is not a proof. `resolveAnswerThreadId` is a PURE decision function over
|
|
114
|
+
// a FINITE input space — so we prove its behaviour by CONSTRUCTION: enumerate
|
|
115
|
+
// every REACHABLE input (origin/live/ended each have 3 reachable states ×
|
|
116
|
+
// explicit present/absent) for BOTH precedence modes, and assert totality,
|
|
117
|
+
// determinism, no-fabrication, the documented precedence, and the load-bearing
|
|
118
|
+
// guarantee: under framework authority the model's explicit thread CANNOT
|
|
119
|
+
// redirect a reply that has a framework anchor.
|
|
120
|
+
|
|
121
|
+
// Reachable sub-states (a thread value is only present when its 'resolved' flag is).
|
|
122
|
+
const ORIGIN_STATES: Array<Pick<AnswerThreadInput, 'originResolved' | 'originThreadId'>> = [
|
|
123
|
+
{ originResolved: false },
|
|
124
|
+
{ originResolved: true, originThreadId: undefined }, // DM/General origin
|
|
125
|
+
{ originResolved: true, originThreadId: O },
|
|
126
|
+
]
|
|
127
|
+
const LIVE_STATES: Array<Pick<AnswerThreadInput, 'liveTurnPresent' | 'liveThreadId'>> = [
|
|
128
|
+
{ liveTurnPresent: false },
|
|
129
|
+
{ liveTurnPresent: true, liveThreadId: undefined }, // General live turn
|
|
130
|
+
{ liveTurnPresent: true, liveThreadId: L },
|
|
131
|
+
]
|
|
132
|
+
const ENDED_STATES: Array<
|
|
133
|
+
Pick<AnswerThreadInput, 'lastEndedResolvedForChat' | 'lastEndedThreadIdForChat'>
|
|
134
|
+
> = [
|
|
135
|
+
{ lastEndedResolvedForChat: false },
|
|
136
|
+
{ lastEndedResolvedForChat: true, lastEndedThreadIdForChat: undefined },
|
|
137
|
+
{ lastEndedResolvedForChat: true, lastEndedThreadIdForChat: E },
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
function reachableInputs(frameworkTopicAuthority: boolean): AnswerThreadInput[] {
|
|
106
141
|
const rows: AnswerThreadInput[] = []
|
|
107
142
|
for (const explicitThreadId of [undefined, T])
|
|
108
|
-
for (const
|
|
109
|
-
for (const
|
|
110
|
-
for (const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
rows.push({
|
|
114
|
-
explicitThreadId,
|
|
115
|
-
originResolved,
|
|
116
|
-
originThreadId,
|
|
117
|
-
lastEndedResolvedForChat,
|
|
118
|
-
lastEndedThreadIdForChat,
|
|
119
|
-
liveThreadId,
|
|
120
|
-
})
|
|
121
|
-
return rows
|
|
143
|
+
for (const o of ORIGIN_STATES)
|
|
144
|
+
for (const lv of LIVE_STATES)
|
|
145
|
+
for (const en of ENDED_STATES)
|
|
146
|
+
rows.push({ explicitThreadId, ...o, ...lv, ...en, frameworkTopicAuthority })
|
|
147
|
+
return rows // 2 × 3 × 3 × 3 = 54
|
|
122
148
|
}
|
|
123
149
|
|
|
124
|
-
// Independent
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (i.
|
|
129
|
-
if (i.
|
|
130
|
-
if (i.
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
// Independent SPEC encodings, kept separate from the implementation so a
|
|
151
|
+
// regression in either surfaces as a divergence.
|
|
152
|
+
function specFramework(i: AnswerThreadInput): number | undefined {
|
|
153
|
+
if (i.originResolved) return i.originThreadId // (1)
|
|
154
|
+
if (i.liveTurnPresent) return i.liveThreadId // (2)
|
|
155
|
+
if (i.explicitThreadId != null) return i.explicitThreadId // (3)
|
|
156
|
+
if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat // (4)
|
|
157
|
+
return i.liveThreadId
|
|
158
|
+
}
|
|
159
|
+
function specLegacy(i: AnswerThreadInput): number | undefined {
|
|
160
|
+
if (i.explicitThreadId != null) return i.explicitThreadId
|
|
161
|
+
if (i.originResolved) return i.originThreadId
|
|
162
|
+
if (i.liveThreadId != null) return i.liveThreadId
|
|
163
|
+
if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat
|
|
164
|
+
return i.liveThreadId
|
|
133
165
|
}
|
|
134
166
|
|
|
135
|
-
describe('resolveAnswerThreadId — total-enumeration
|
|
136
|
-
const
|
|
167
|
+
describe('resolveAnswerThreadId — total-enumeration proof (54 reachable inputs × 2 modes)', () => {
|
|
168
|
+
const FA = reachableInputs(true)
|
|
169
|
+
const LEGACY = reachableInputs(false)
|
|
137
170
|
|
|
138
|
-
it('the input space is exactly
|
|
139
|
-
expect(
|
|
171
|
+
it('the reachable input space is exactly 54 rows per mode', () => {
|
|
172
|
+
expect(FA.length).toBe(54)
|
|
173
|
+
expect(LEGACY.length).toBe(54)
|
|
140
174
|
})
|
|
141
175
|
|
|
142
|
-
it('TOTAL: every input returns without throwing', () => {
|
|
143
|
-
for (const i of
|
|
144
|
-
expect(() => resolveAnswerThreadId(i)).not.toThrow()
|
|
145
|
-
}
|
|
176
|
+
it('TOTAL: every input returns without throwing (both modes)', () => {
|
|
177
|
+
for (const i of [...FA, ...LEGACY]) expect(() => resolveAnswerThreadId(i)).not.toThrow()
|
|
146
178
|
})
|
|
147
179
|
|
|
148
|
-
it('DETERMINISTIC: each input maps to exactly one output
|
|
149
|
-
for (const i of
|
|
150
|
-
|
|
151
|
-
const b = resolveAnswerThreadId({ ...i })
|
|
152
|
-
expect(b).toBe(a)
|
|
180
|
+
it('DETERMINISTIC: each input maps to exactly one output across repeated calls', () => {
|
|
181
|
+
for (const i of [...FA, ...LEGACY]) {
|
|
182
|
+
expect(resolveAnswerThreadId({ ...i })).toBe(resolveAnswerThreadId(i))
|
|
153
183
|
}
|
|
154
184
|
})
|
|
155
185
|
|
|
156
186
|
it('NO FABRICATION: every output is undefined or one of the four input thread fields', () => {
|
|
157
|
-
for (const i of
|
|
187
|
+
for (const i of [...FA, ...LEGACY]) {
|
|
158
188
|
const out = resolveAnswerThreadId(i)
|
|
159
189
|
const provenance = new Set([
|
|
160
190
|
undefined,
|
|
@@ -167,53 +197,45 @@ describe('resolveAnswerThreadId — total-enumeration determinism proof (all 64
|
|
|
167
197
|
}
|
|
168
198
|
})
|
|
169
199
|
|
|
170
|
-
it('PRECEDENCE: matches the documented spec on all
|
|
171
|
-
for (const i of
|
|
172
|
-
expect(resolveAnswerThreadId(i)).toBe(specExpected(i))
|
|
173
|
-
}
|
|
200
|
+
it('PRECEDENCE (framework): matches the documented spec on all 54 inputs', () => {
|
|
201
|
+
for (const i of FA) expect(resolveAnswerThreadId(i)).toBe(specFramework(i))
|
|
174
202
|
})
|
|
175
203
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// can perturb a higher tier's decision. These are the routing guarantees
|
|
179
|
-
// the resolver exists to provide. ─────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
it('INV-1 explicit DOMINANCE: explicit set ⇒ output === explicit, independent of all other fields', () => {
|
|
182
|
-
for (const i of ROWS) {
|
|
183
|
-
if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
|
|
184
|
-
}
|
|
204
|
+
it('PRECEDENCE (legacy): matches the legacy spec on all 54 inputs', () => {
|
|
205
|
+
for (const i of LEGACY) expect(resolveAnswerThreadId(i)).toBe(specLegacy(i))
|
|
185
206
|
})
|
|
186
207
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
208
|
+
// ── The load-bearing guarantee ────────────────────────────────────────────
|
|
209
|
+
it('INV-ANCHOR (framework): the model explicit CANNOT redirect a reply that has a framework anchor — output is independent of explicitThreadId whenever originResolved OR liveTurnPresent', () => {
|
|
210
|
+
for (const i of FA) {
|
|
211
|
+
if (i.originResolved || i.liveTurnPresent) {
|
|
212
|
+
const withExplicit = resolveAnswerThreadId({ ...i, explicitThreadId: T })
|
|
213
|
+
const without = resolveAnswerThreadId({ ...i, explicitThreadId: undefined })
|
|
214
|
+
expect(withExplicit).toBe(without)
|
|
191
215
|
}
|
|
192
216
|
}
|
|
193
217
|
})
|
|
194
218
|
|
|
195
|
-
it('INV-
|
|
196
|
-
for (const i of
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
expect(i.explicitThreadId).toBeUndefined()
|
|
203
|
-
expect(i.originResolved).toBe(false)
|
|
204
|
-
expect(i.liveThreadId).toBeUndefined()
|
|
205
|
-
expect(i.lastEndedResolvedForChat).toBe(true)
|
|
206
|
-
}
|
|
219
|
+
it('INV-ORIGIN (framework): originResolved ⇒ output === originThreadId, for EVERY explicit/live/ended combo (flip-immunity + explicit-immunity)', () => {
|
|
220
|
+
for (const i of FA) if (i.originResolved) expect(resolveAnswerThreadId(i)).toBe(i.originThreadId)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('INV-LIVE (framework): ¬originResolved ∧ liveTurnPresent ⇒ output === liveThreadId, regardless of explicit', () => {
|
|
224
|
+
for (const i of FA) {
|
|
225
|
+
if (!i.originResolved && i.liveTurnPresent) expect(resolveAnswerThreadId(i)).toBe(i.liveThreadId)
|
|
207
226
|
}
|
|
208
227
|
})
|
|
209
228
|
|
|
210
|
-
it('INV-
|
|
211
|
-
for (const i of
|
|
212
|
-
|
|
213
|
-
if (out === L) {
|
|
214
|
-
expect(i.explicitThreadId).toBeUndefined()
|
|
229
|
+
it('INV-EXPLICIT-LAST (framework): an explicit-tier result occurs ONLY when no origin AND no live turn', () => {
|
|
230
|
+
for (const i of FA) {
|
|
231
|
+
if (resolveAnswerThreadId(i) === T) {
|
|
215
232
|
expect(i.originResolved).toBe(false)
|
|
233
|
+
expect(i.liveTurnPresent).toBe(false)
|
|
216
234
|
}
|
|
217
235
|
}
|
|
218
236
|
})
|
|
237
|
+
|
|
238
|
+
it('INV-EXPLICIT-DOMINANCE (legacy): explicit set ⇒ output === explicit, independent of all other fields', () => {
|
|
239
|
+
for (const i of LEGACY) if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
|
|
240
|
+
})
|
|
219
241
|
})
|
|
@@ -5,35 +5,48 @@
|
|
|
5
5
|
* Pure decision: which forum-topic thread should an ANSWER reply
|
|
6
6
|
* (`reply` / `stream_reply`) land in?
|
|
7
7
|
*
|
|
8
|
-
* ## The
|
|
8
|
+
* ## The bugs this closes
|
|
9
9
|
*
|
|
10
|
-
* In a forum supergroup one sequential
|
|
11
|
-
* a singleton `currentTurn`.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the model omitted `message_thread_id`, resolved the thread from
|
|
15
|
-
* `turn.sessionThreadId` (Meta's thread) — so Brevo's answer landed in
|
|
16
|
-
* Meta. A successor turn stole a predecessor's late reply.
|
|
10
|
+
* **(a) currentTurn flip (2026-06-05).** In a forum supergroup one sequential
|
|
11
|
+
* `claude` CLI owns every topic with a singleton `currentTurn`. A late reply
|
|
12
|
+
* landed after `currentTurn` had flipped to a successor topic, so the answer
|
|
13
|
+
* went to the wrong topic. Fixed by pinning to the ORIGIN turn's thread (tier 1).
|
|
17
14
|
*
|
|
18
|
-
*
|
|
15
|
+
* **(b) model-chosen topic override (2026-06-08).** A General-topic question's
|
|
16
|
+
* answer landed in the CRM topic because the model passed an explicit
|
|
17
|
+
* `message_thread_id` and the resolver let that win OUTRIGHT over the
|
|
18
|
+
* framework's record of where the question came from. General is the trap: its
|
|
19
|
+
* messages carry no thread id, so nothing forces the reply back — and the
|
|
20
|
+
* model's explicit "post to CRM" overrode the General origin. The user, reading
|
|
21
|
+
* General, saw silence. This is a model-dependency: a reply's topic must not be
|
|
22
|
+
* the model's free choice.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
24
|
+
* ## The precedence (answer paths) — FRAMEWORK AUTHORITY (default)
|
|
25
|
+
*
|
|
26
|
+
* The topic a reply lands in is owned by the TURN it answers, not the model's
|
|
27
|
+
* `message_thread_id`. The model's explicit thread is demoted to a last resort
|
|
28
|
+
* — used only when the framework has NO turn anchor at all (a genuinely
|
|
29
|
+
* orphaned / proactive send). Precedence:
|
|
30
|
+
*
|
|
31
|
+
* 1. ORIGIN turn's thread — the turn matched by the reply's `origin_turn_id`
|
|
32
|
+
* (echoed) or a quoted `reply_to` (framework reverse-index). Authoritative
|
|
33
|
+
* even when `currentTurn` flipped (closes bug a). A General/DM origin
|
|
34
|
+
* yields `undefined`, which correctly routes to the main chat / General.
|
|
35
|
+
* 2. LIVE in-flight turn's thread — keyed on the turn's PRESENCE, not its
|
|
36
|
+
* thread value, so a General live turn (thread `undefined`) still anchors
|
|
37
|
+
* the reply to the General conversation. The model's explicit cannot
|
|
38
|
+
* redirect a reply that belongs to an in-flight turn (closes bug b).
|
|
39
|
+
* 3. EXPLICIT model thread — only now, when there is neither an origin nor a
|
|
40
|
+
* live turn (a late / proactive send with no framework anchor). Here the
|
|
41
|
+
* model's `message_thread_id` is the only signal, so honour it.
|
|
42
|
+
* 4. LATE-reply recovery — no explicit, no origin, no live turn: recover the
|
|
43
|
+
* origin topic from the most-recently-ended turn for this chat, so an
|
|
44
|
+
* orphaned-backstop reply lands in its topic instead of defaulting to the
|
|
45
|
+
* main chat (General). Not the `chatThreadMap` last-seen heuristic.
|
|
46
|
+
*
|
|
47
|
+
* Setting `frameworkTopicAuthority: false` (kill switch
|
|
48
|
+
* SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) restores the legacy explicit-first
|
|
49
|
+
* precedence (the model's thread wins outright).
|
|
37
50
|
*
|
|
38
51
|
* The `chatThreadMap` last-seen fallback is preserved for NON-answer
|
|
39
52
|
* surfaces (`send_typing`, `forward_message`, `progress_update`) by NOT
|
|
@@ -50,13 +63,17 @@ export interface AnswerThreadInput {
|
|
|
50
63
|
* `originResolved` is true. */
|
|
51
64
|
originThreadId?: number | undefined
|
|
52
65
|
/** Whether an origin turn was resolved at all. Distinguishes
|
|
53
|
-
* "origin turn exists and its thread is undefined (a DM origin)" from
|
|
66
|
+
* "origin turn exists and its thread is undefined (a DM/General origin)" from
|
|
54
67
|
* "no origin turn" — both surface as `originThreadId === undefined`. */
|
|
55
68
|
originResolved: boolean
|
|
56
69
|
/** Thread of the LIVE `currentTurn` at execution time, or undefined
|
|
57
|
-
* (no live turn, or a DM live turn).
|
|
58
|
-
* no origin turn is resolvable. */
|
|
70
|
+
* (no live turn, or a DM/General live turn). */
|
|
59
71
|
liveThreadId?: number | undefined
|
|
72
|
+
/** Whether a LIVE in-flight turn exists at execution time. Distinguishes a
|
|
73
|
+
* General (thread-undefined) live turn — still a valid framework anchor —
|
|
74
|
+
* from no live turn at all. Mirrors `originResolved`. Absent in the legacy
|
|
75
|
+
* path (which keys off `liveThreadId != null`). */
|
|
76
|
+
liveTurnPresent?: boolean
|
|
60
77
|
/**
|
|
61
78
|
* Late-reply topic recovery (2026-06-05). Thread of the most-recently-ended
|
|
62
79
|
* turn for THIS chat (from `recentTurnsById`), used as a deterministic
|
|
@@ -64,42 +81,52 @@ export interface AnswerThreadInput {
|
|
|
64
81
|
* turn — the late-reply-after-turn-end case. Without it, a reply that fires
|
|
65
82
|
* after the orphaned-reply backstop closed its turn defaults to the main chat
|
|
66
83
|
* (General topic in a supergroup), so its answer vanishes from the topic the
|
|
67
|
-
* user is reading.
|
|
68
|
-
* which is correct.
|
|
84
|
+
* user is reading. A DM origin yields undefined, which is correct.
|
|
69
85
|
*/
|
|
70
86
|
lastEndedThreadIdForChat?: number | undefined
|
|
71
87
|
/** Whether a recently-ended turn exists for this chat — distinguishes
|
|
72
88
|
* "ended turn exists, DM (thread undefined)" from "no ended turn at all". */
|
|
73
89
|
lastEndedResolvedForChat?: boolean
|
|
90
|
+
/**
|
|
91
|
+
* When true (default), the framework's turn anchor (origin → live) owns the
|
|
92
|
+
* reply topic and the model's `explicitThreadId` is a last resort (consulted
|
|
93
|
+
* only when no anchor exists). When false, the legacy explicit-first
|
|
94
|
+
* precedence (the model's thread wins outright). Undefined is treated as
|
|
95
|
+
* true. Kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0.
|
|
96
|
+
*/
|
|
97
|
+
frameworkTopicAuthority?: boolean
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
/**
|
|
77
101
|
* Pure. Returns the thread id to send the answer to, or undefined for the
|
|
78
|
-
* main chat (DM / no thread).
|
|
102
|
+
* main chat (DM / General / no thread).
|
|
79
103
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* heuristic is what produced the Brevo→Meta wrong-topic bug, so answer
|
|
85
|
-
* paths never reach it.
|
|
104
|
+
* Default (framework authority): origin turn → live in-flight turn → explicit
|
|
105
|
+
* model thread → late-ended recovery. The model's `explicitThreadId` cannot
|
|
106
|
+
* override a resolved origin or a live turn — it is consulted only when neither
|
|
107
|
+
* exists. The chat last-seen `chatThreadMap` heuristic is NOT in this chain.
|
|
86
108
|
*/
|
|
87
109
|
export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefined {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
if (input.frameworkTopicAuthority === false) {
|
|
111
|
+
// ── Legacy precedence (kill switch): the model's explicit thread wins. ──
|
|
112
|
+
if (input.explicitThreadId != null) return input.explicitThreadId
|
|
113
|
+
if (input.originResolved) return input.originThreadId
|
|
114
|
+
if (input.liveThreadId != null) return input.liveThreadId
|
|
115
|
+
if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
|
|
116
|
+
return input.liveThreadId
|
|
117
|
+
}
|
|
118
|
+
// ── Framework-authority precedence (default) ───────────────────────────────
|
|
119
|
+
// (1) origin turn → its thread (authoritative across a currentTurn flip; a
|
|
120
|
+
// General/DM origin yields undefined → main chat / General).
|
|
93
121
|
if (input.originResolved) return input.originThreadId
|
|
94
|
-
// (
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
// fall through to liveThreadId (undefined) — the legacy result.
|
|
122
|
+
// (2) a live in-flight turn → its thread. Key off PRESENCE, not the thread
|
|
123
|
+
// value: a General live turn has an undefined thread but is still the
|
|
124
|
+
// anchor, so the model's explicit can't pull the reply out of it.
|
|
125
|
+
if (input.liveTurnPresent) return input.liveThreadId
|
|
126
|
+
// (3) no framework anchor (genuinely orphaned / proactive) → honour the
|
|
127
|
+
// model's explicit thread, its only signal here.
|
|
128
|
+
if (input.explicitThreadId != null) return input.explicitThreadId
|
|
129
|
+
// (4) late reply, no anchor, no explicit → recover the chat's last-ended topic.
|
|
103
130
|
if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
|
|
104
131
|
return input.liveThreadId
|
|
105
132
|
}
|
|
@@ -471,6 +471,10 @@ import {
|
|
|
471
471
|
} from './resume-inbound-builder.js'
|
|
472
472
|
import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
|
|
473
473
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
474
|
+
import {
|
|
475
|
+
resolveSubagentStatusSurface,
|
|
476
|
+
isOrphanSubagentStatusEnabled,
|
|
477
|
+
} from './subagent-status-surface.js'
|
|
474
478
|
import { formatIdleFooter } from '../idle-footer.js'
|
|
475
479
|
import { resolveCallingSubagent } from './resolve-calling-subagent.js'
|
|
476
480
|
|
|
@@ -1566,6 +1570,15 @@ const TURN_ORIGIN_ROUTING_ENABLED =
|
|
|
1566
1570
|
// mask a misroute. Kill switch off (=0) → echo-only origin (today's behaviour).
|
|
1567
1571
|
const FRAMEWORK_ORIGIN_ROUTING_ENABLED =
|
|
1568
1572
|
process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'
|
|
1573
|
+
// Reply-topic framework authority (2026-06-08 determinism pass). The topic an
|
|
1574
|
+
// ANSWER lands in is owned by the TURN it answers (origin → live), NOT the
|
|
1575
|
+
// model's `message_thread_id`. The model's explicit thread is demoted to a
|
|
1576
|
+
// last resort (consulted only when there is no origin AND no live turn). Closes
|
|
1577
|
+
// the model-chosen-topic override that sent a General-topic question's answer
|
|
1578
|
+
// into the CRM topic (marko) — the model could redirect any reply. Kill switch
|
|
1579
|
+
// off (=0) → legacy explicit-first precedence (the model's thread wins).
|
|
1580
|
+
const REPLY_TOPIC_AUTHORITY_ENABLED =
|
|
1581
|
+
process.env.SWITCHROOM_REPLY_TOPIC_AUTHORITY !== '0'
|
|
1569
1582
|
// Component 4 (per-turn topic framing). Add a one-line directive to the
|
|
1570
1583
|
// channel meta + bridge instructions telling the model to answer ONLY the
|
|
1571
1584
|
// current message's topic. Kill switch off (=0) → no framing field.
|
|
@@ -2033,15 +2046,32 @@ function resolveAnswerThreadWithLog(
|
|
|
2033
2046
|
originResolved: originTurn != null,
|
|
2034
2047
|
originThreadId: originTurn?.sessionThreadId,
|
|
2035
2048
|
liveThreadId: liveTurn?.sessionThreadId,
|
|
2049
|
+
liveTurnPresent: liveTurn != null,
|
|
2036
2050
|
lastEndedResolvedForChat: recovered != null,
|
|
2037
2051
|
lastEndedThreadIdForChat: recovered?.sessionThreadId,
|
|
2052
|
+
frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED,
|
|
2038
2053
|
})
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2054
|
+
// `via` reflects the ACTIVE precedence so telemetry matches routing.
|
|
2055
|
+
const via = REPLY_TOPIC_AUTHORITY_ENABLED
|
|
2056
|
+
? (originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
2057
|
+
: liveTurn != null ? 'live'
|
|
2058
|
+
: explicitThreadId != null ? 'explicit'
|
|
2059
|
+
: recovered != null ? 'recovered'
|
|
2060
|
+
: 'none')
|
|
2061
|
+
: (explicitThreadId != null ? 'explicit'
|
|
2062
|
+
: originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
|
|
2063
|
+
: liveTurn?.sessionThreadId != null ? 'live'
|
|
2064
|
+
: recovered != null ? 'recovered'
|
|
2065
|
+
: 'none')
|
|
2066
|
+
// Observability: the model passed an explicit topic but a framework anchor
|
|
2067
|
+
// (origin/live) overrode it. This is the deterministic correction that fixes
|
|
2068
|
+
// the General→CRM misroute; surface it so the model's topic-grabbing is
|
|
2069
|
+
// visible rather than silent.
|
|
2070
|
+
const explicitOverridden =
|
|
2071
|
+
REPLY_TOPIC_AUTHORITY_ENABLED &&
|
|
2072
|
+
explicitThreadId != null &&
|
|
2073
|
+
(originTurn != null || liveTurn != null) &&
|
|
2074
|
+
threadId !== explicitThreadId
|
|
2045
2075
|
const ownerTurn = originTurn ?? recovered ?? liveTurn
|
|
2046
2076
|
const isSupergroup = chatId.startsWith('-100')
|
|
2047
2077
|
// UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
|
|
@@ -2068,6 +2098,9 @@ function resolveAnswerThreadWithLog(
|
|
|
2068
2098
|
(via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
|
|
2069
2099
|
(unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
|
|
2070
2100
|
(misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
|
|
2101
|
+
(explicitOverridden
|
|
2102
|
+
? ` EXPLICIT_OVERRIDDEN(model→${explicitThreadId},routed→${threadId ?? '-'})`
|
|
2103
|
+
: '') +
|
|
2071
2104
|
'\n',
|
|
2072
2105
|
)
|
|
2073
2106
|
return threadId
|
|
@@ -20381,6 +20414,11 @@ void (async () => {
|
|
|
20381
20414
|
// compose draft, so no answer-stream contention). The kill-switch
|
|
20382
20415
|
// disables only the nesting; the parent's own feed is unaffected.
|
|
20383
20416
|
const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'
|
|
20417
|
+
// Orphaned-foreground status (2026-06-09): a FOREGROUND sub-agent
|
|
20418
|
+
// with no live parent turn to nest into (dispatched outside a turn,
|
|
20419
|
+
// or the turn ended while it kept running — extended autonomous
|
|
20420
|
+
// work) is surfaced via the worker feed instead of vanishing.
|
|
20421
|
+
const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
|
|
20384
20422
|
const workerActivityFeed = createWorkerActivityFeed({
|
|
20385
20423
|
bot: {
|
|
20386
20424
|
sendMessage: async (cid, text, sendOpts) => {
|
|
@@ -20580,6 +20618,29 @@ void (async () => {
|
|
|
20580
20618
|
}
|
|
20581
20619
|
}
|
|
20582
20620
|
}
|
|
20621
|
+
return
|
|
20622
|
+
}
|
|
20623
|
+
// Not nested → an orphaned foreground sub-agent that was
|
|
20624
|
+
// surfaced via the worker feed (no live turn to nest into):
|
|
20625
|
+
// finalize its message (no-op if none was posted). A
|
|
20626
|
+
// foreground result returns inline as the Task tool result, so
|
|
20627
|
+
// there is no handback to deliver — return after.
|
|
20628
|
+
if (
|
|
20629
|
+
resolveSubagentStatusSurface({
|
|
20630
|
+
isBackground: false,
|
|
20631
|
+
liveTurnPresent: false,
|
|
20632
|
+
workerFeedEnabled,
|
|
20633
|
+
orphanStatusEnabled,
|
|
20634
|
+
}) === 'worker-feed'
|
|
20635
|
+
) {
|
|
20636
|
+
void workerActivityFeed.finish(agentId, {
|
|
20637
|
+
description: dispatch.feedDescription,
|
|
20638
|
+
lastTool: null,
|
|
20639
|
+
toolCount,
|
|
20640
|
+
latestSummary: resultText,
|
|
20641
|
+
elapsedMs: durationMs,
|
|
20642
|
+
state: outcome === 'failed' ? 'failed' : 'done',
|
|
20643
|
+
})
|
|
20583
20644
|
}
|
|
20584
20645
|
return
|
|
20585
20646
|
}
|
|
@@ -20709,8 +20770,39 @@ void (async () => {
|
|
|
20709
20770
|
// activity draft rather than a separate worker message. Pure
|
|
20710
20771
|
// jsonl-tail → render (no model call), inside the
|
|
20711
20772
|
// subscription-honest boundary.
|
|
20773
|
+
//
|
|
20774
|
+
// But a foreground sub-agent with NO live turn to nest into
|
|
20775
|
+
// (dispatched outside a turn, or the turn ended while it kept
|
|
20776
|
+
// running — extended autonomous work) has nowhere to nest, and
|
|
20777
|
+
// pre-fix it silently returned here → invisible. Route through
|
|
20778
|
+
// the proven decision: an orphaned foreground sub-agent goes to
|
|
20779
|
+
// the worker feed (owner-DM fallback), not into the void.
|
|
20780
|
+
const surface = resolveSubagentStatusSurface({
|
|
20781
|
+
isBackground: false,
|
|
20782
|
+
liveTurnPresent: currentTurn != null,
|
|
20783
|
+
workerFeedEnabled,
|
|
20784
|
+
orphanStatusEnabled,
|
|
20785
|
+
})
|
|
20786
|
+
if (surface === 'worker-feed') {
|
|
20787
|
+
const origin = resolveSubagentOriginChat(agentId)
|
|
20788
|
+
void workerActivityFeed.update(
|
|
20789
|
+
agentId,
|
|
20790
|
+
origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
|
|
20791
|
+
{
|
|
20792
|
+
description: dispatch.feedDescription,
|
|
20793
|
+
lastTool,
|
|
20794
|
+
toolCount,
|
|
20795
|
+
latestSummary,
|
|
20796
|
+
elapsedMs,
|
|
20797
|
+
state: 'running',
|
|
20798
|
+
},
|
|
20799
|
+
origin?.threadId,
|
|
20800
|
+
)
|
|
20801
|
+
return
|
|
20802
|
+
}
|
|
20803
|
+
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
20712
20804
|
const turn = currentTurn
|
|
20713
|
-
if (turn == null) return
|
|
20805
|
+
if (turn == null) return // defensive: 'nest' implies a live turn
|
|
20714
20806
|
// Render regardless of `replyCalled` — a foreground Task
|
|
20715
20807
|
// blocks the parent, so any reply seen while it runs is an
|
|
20716
20808
|
// interim ack, never the final answer. Gating on replyCalled
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
resolveSubagentStatusSurface,
|
|
4
|
+
isOrphanSubagentStatusEnabled,
|
|
5
|
+
type SubagentStatusSurface,
|
|
6
|
+
type SubagentStatusSurfaceInput,
|
|
7
|
+
} from './subagent-status-surface.js'
|
|
8
|
+
|
|
9
|
+
// ── Human-readable map ──────────────────────────────────────────────────────
|
|
10
|
+
describe('resolveSubagentStatusSurface', () => {
|
|
11
|
+
const base: SubagentStatusSurfaceInput = {
|
|
12
|
+
isBackground: false,
|
|
13
|
+
liveTurnPresent: true,
|
|
14
|
+
workerFeedEnabled: true,
|
|
15
|
+
orphanStatusEnabled: true,
|
|
16
|
+
}
|
|
17
|
+
it('foreground + live turn → nest (unchanged default)', () => {
|
|
18
|
+
expect(resolveSubagentStatusSurface(base)).toBe('nest')
|
|
19
|
+
})
|
|
20
|
+
it('THE fix: orphaned foreground (no live turn) → worker-feed', () => {
|
|
21
|
+
expect(resolveSubagentStatusSurface({ ...base, liveTurnPresent: false })).toBe('worker-feed')
|
|
22
|
+
})
|
|
23
|
+
it('kill switch: orphaned foreground with orphanStatus OFF → skip (pre-fix invisible)', () => {
|
|
24
|
+
expect(
|
|
25
|
+
resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, orphanStatusEnabled: false }),
|
|
26
|
+
).toBe('skip')
|
|
27
|
+
})
|
|
28
|
+
it('orphaned foreground but feed OFF → skip (nothing to surface through)', () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, workerFeedEnabled: false }),
|
|
31
|
+
).toBe('skip')
|
|
32
|
+
})
|
|
33
|
+
it('background + feed on → worker-feed', () => {
|
|
34
|
+
expect(resolveSubagentStatusSurface({ ...base, isBackground: true, liveTurnPresent: false })).toBe('worker-feed')
|
|
35
|
+
})
|
|
36
|
+
it('background + feed off → legacy-relay', () => {
|
|
37
|
+
expect(
|
|
38
|
+
resolveSubagentStatusSurface({ ...base, isBackground: true, workerFeedEnabled: false }),
|
|
39
|
+
).toBe('legacy-relay')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('isOrphanSubagentStatusEnabled — default ON, =0 kill switch', () => {
|
|
44
|
+
it('undefined / "1" / "" → on; "0" → off', () => {
|
|
45
|
+
expect(isOrphanSubagentStatusEnabled(undefined)).toBe(true)
|
|
46
|
+
expect(isOrphanSubagentStatusEnabled('1')).toBe(true)
|
|
47
|
+
expect(isOrphanSubagentStatusEnabled('')).toBe(true)
|
|
48
|
+
expect(isOrphanSubagentStatusEnabled('0')).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
|
|
53
|
+
// 4 booleans = 16 reachable inputs. Enumerate all, assert totality, determinism,
|
|
54
|
+
// the documented table (independent spec), and the load-bearing invariants.
|
|
55
|
+
// (operator standard feedback_prove_finite_fsm_not_sample.)
|
|
56
|
+
function allInputs(): SubagentStatusSurfaceInput[] {
|
|
57
|
+
const rows: SubagentStatusSurfaceInput[] = []
|
|
58
|
+
for (const isBackground of [false, true])
|
|
59
|
+
for (const liveTurnPresent of [false, true])
|
|
60
|
+
for (const workerFeedEnabled of [false, true])
|
|
61
|
+
for (const orphanStatusEnabled of [false, true])
|
|
62
|
+
rows.push({ isBackground, liveTurnPresent, workerFeedEnabled, orphanStatusEnabled })
|
|
63
|
+
return rows
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Independent spec encoding (kept separate from the impl).
|
|
67
|
+
function spec(i: SubagentStatusSurfaceInput): SubagentStatusSurface {
|
|
68
|
+
if (i.isBackground) return i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
|
|
69
|
+
if (i.liveTurnPresent) return 'nest'
|
|
70
|
+
if (!i.orphanStatusEnabled) return 'skip'
|
|
71
|
+
return i.workerFeedEnabled ? 'worker-feed' : 'skip'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('resolveSubagentStatusSurface — total enumeration (16 inputs)', () => {
|
|
75
|
+
const ROWS = allInputs()
|
|
76
|
+
|
|
77
|
+
it('exactly 16 reachable inputs (2^4)', () => {
|
|
78
|
+
expect(ROWS.length).toBe(16)
|
|
79
|
+
})
|
|
80
|
+
it('TOTAL + DETERMINISTIC: every input returns one of the four surfaces, idempotently', () => {
|
|
81
|
+
const surfaces = new Set<SubagentStatusSurface>(['nest', 'worker-feed', 'legacy-relay', 'skip'])
|
|
82
|
+
for (const i of ROWS) {
|
|
83
|
+
const a = resolveSubagentStatusSurface(i)
|
|
84
|
+
expect(surfaces.has(a)).toBe(true)
|
|
85
|
+
expect(resolveSubagentStatusSurface({ ...i })).toBe(a)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
it('PRECEDENCE: matches the documented spec on all 16 inputs', () => {
|
|
89
|
+
for (const i of ROWS) expect(resolveSubagentStatusSurface(i)).toBe(spec(i))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('INV-ORPHAN-VISIBLE: an orphaned foreground sub-agent is NEVER skip when orphanStatus + feed are on', () => {
|
|
93
|
+
for (const i of ROWS) {
|
|
94
|
+
if (!i.isBackground && !i.liveTurnPresent && i.orphanStatusEnabled && i.workerFeedEnabled) {
|
|
95
|
+
expect(resolveSubagentStatusSurface(i)).toBe('worker-feed')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
it('INV-KILL-SWITCH: orphanStatus OFF ⇒ an orphaned foreground sub-agent is exactly the pre-fix behaviour (skip)', () => {
|
|
100
|
+
for (const i of ROWS) {
|
|
101
|
+
if (!i.isBackground && !i.liveTurnPresent && !i.orphanStatusEnabled) {
|
|
102
|
+
expect(resolveSubagentStatusSurface(i)).toBe('skip')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
it('INV-NEST-UNCHANGED: a foreground sub-agent with a live turn is ALWAYS nest, independent of the other flags', () => {
|
|
107
|
+
for (const i of ROWS) {
|
|
108
|
+
if (!i.isBackground && i.liveTurnPresent) expect(resolveSubagentStatusSurface(i)).toBe('nest')
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
it('INV-BACKGROUND-UNCHANGED: background routing depends ONLY on the feed flag, never on liveTurn/orphanStatus', () => {
|
|
112
|
+
for (const i of ROWS) {
|
|
113
|
+
if (i.isBackground) {
|
|
114
|
+
expect(resolveSubagentStatusSurface(i)).toBe(i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where does a sub-agent's live status go?
|
|
3
|
+
*
|
|
4
|
+
* A sub-agent's progress is surfaced on one of four "surfaces". This pure
|
|
5
|
+
* decision picks which, so the routing is provable by total enumeration rather
|
|
6
|
+
* than buried in the gateway's imperative branches.
|
|
7
|
+
*
|
|
8
|
+
* - `nest` — a FOREGROUND sub-agent running inside a LIVE parent turn:
|
|
9
|
+
* its narrative nests under the parent's activity draft
|
|
10
|
+
* (the progress card). The default, unchanged.
|
|
11
|
+
* - `worker-feed` — a BACKGROUND worker (the `🛠 Worker` edit-in-place
|
|
12
|
+
* message), OR a FOREGROUND sub-agent that has NO live
|
|
13
|
+
* parent turn to nest into (dispatched outside a turn, or
|
|
14
|
+
* the turn ended while it kept running). The latter is the
|
|
15
|
+
* 2026-06-09 fix: extended autonomous work was invisible
|
|
16
|
+
* because foreground status was turn-scoped and a sub-agent
|
|
17
|
+
* with no turn silently returned. It now reuses the worker
|
|
18
|
+
* feed (with the same owner-DM fallback background workers
|
|
19
|
+
* already use), so post-turn work is always visible.
|
|
20
|
+
* - `legacy-relay` — a BACKGROUND worker when the worker feed is OFF: fall
|
|
21
|
+
* back to the legacy "still working" injected-inbound relay.
|
|
22
|
+
* - `skip` — nothing to surface (kill-switch off for an orphaned
|
|
23
|
+
* foreground, or no feed to surface it through).
|
|
24
|
+
*
|
|
25
|
+
* Determinism: the input space is 4 booleans = 16 rows, enumerated and proven
|
|
26
|
+
* in subagent-status-surface.test.ts (operator standard
|
|
27
|
+
* feedback_prove_finite_fsm_not_sample). The load-bearing invariant: an
|
|
28
|
+
* orphaned foreground sub-agent (no live turn) is `worker-feed`, never `skip`,
|
|
29
|
+
* whenever the orphan-status flag and the feed are both on.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type SubagentStatusSurface = 'nest' | 'worker-feed' | 'legacy-relay' | 'skip'
|
|
33
|
+
|
|
34
|
+
export interface SubagentStatusSurfaceInput {
|
|
35
|
+
/** run_in_background dispatch (registry `subagents.background`). */
|
|
36
|
+
isBackground: boolean
|
|
37
|
+
/**
|
|
38
|
+
* A LIVE parent turn exists to nest this (foreground) sub-agent into.
|
|
39
|
+
* onProgress: `currentTurn != null`. onFinish: `currentTurn != null && it was
|
|
40
|
+
* actually nested` (so a foreground sub-agent that was surfaced via the worker
|
|
41
|
+
* feed — never nested — finalizes through the feed, not a turn collapse).
|
|
42
|
+
* Ignored for background sub-agents.
|
|
43
|
+
*/
|
|
44
|
+
liveTurnPresent: boolean
|
|
45
|
+
/** SWITCHROOM_WORKER_ACTIVITY_FEED on. */
|
|
46
|
+
workerFeedEnabled: boolean
|
|
47
|
+
/** SWITCHROOM_ORPHAN_SUBAGENT_STATUS on — surface no-parent-turn foreground
|
|
48
|
+
* sub-agents via the worker feed. Off = pre-fix behaviour (invisible). */
|
|
49
|
+
orphanStatusEnabled: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveSubagentStatusSurface(
|
|
53
|
+
input: SubagentStatusSurfaceInput,
|
|
54
|
+
): SubagentStatusSurface {
|
|
55
|
+
if (!input.isBackground) {
|
|
56
|
+
// Foreground sub-agent.
|
|
57
|
+
if (input.liveTurnPresent) return 'nest'
|
|
58
|
+
// Orphaned foreground: no live turn to nest into — the invisible case.
|
|
59
|
+
if (!input.orphanStatusEnabled) return 'skip' // kill switch: pre-fix behaviour
|
|
60
|
+
return input.workerFeedEnabled ? 'worker-feed' : 'skip' // surfacing needs the feed
|
|
61
|
+
}
|
|
62
|
+
// Background worker.
|
|
63
|
+
return input.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** SWITCHROOM_ORPHAN_SUBAGENT_STATUS — default ON; `=0` restores pre-fix (invisible) behaviour. */
|
|
67
|
+
export function isOrphanSubagentStatusEnabled(envVal: string | undefined): boolean {
|
|
68
|
+
return envVal !== '0'
|
|
69
|
+
}
|
|
@@ -16,12 +16,25 @@ describe('resolveAnswerThreadId', () => {
|
|
|
16
16
|
const BREVO = 4
|
|
17
17
|
const META = 3
|
|
18
18
|
|
|
19
|
-
it('
|
|
19
|
+
it('the model explicit no longer wins over a framework anchor (origin wins); kill switch restores legacy', () => {
|
|
20
|
+
// Default (framework authority): the resolved origin owns the topic — the
|
|
21
|
+
// model passing a different explicit thread cannot redirect the reply.
|
|
20
22
|
expect(
|
|
21
23
|
resolveAnswerThreadId({
|
|
24
|
+
explicitThreadId: BREVO, // model tried to send it to Brevo
|
|
25
|
+
originResolved: true,
|
|
26
|
+
originThreadId: META, // but the question came from Meta
|
|
27
|
+
liveTurnPresent: true,
|
|
28
|
+
liveThreadId: META,
|
|
29
|
+
}),
|
|
30
|
+
).toBe(META)
|
|
31
|
+
// Kill switch (SWITCHROOM_REPLY_TOPIC_AUTHORITY=0): explicit wins outright.
|
|
32
|
+
expect(
|
|
33
|
+
resolveAnswerThreadId({
|
|
34
|
+
frameworkTopicAuthority: false,
|
|
22
35
|
explicitThreadId: BREVO,
|
|
23
36
|
originResolved: true,
|
|
24
|
-
originThreadId: META,
|
|
37
|
+
originThreadId: META,
|
|
25
38
|
liveThreadId: META,
|
|
26
39
|
}),
|
|
27
40
|
).toBe(BREVO)
|
|
@@ -86,18 +99,20 @@ describe('resolveAnswerThreadId', () => {
|
|
|
86
99
|
).toBeUndefined()
|
|
87
100
|
})
|
|
88
101
|
|
|
89
|
-
it('precedence
|
|
102
|
+
it('precedence (framework authority, default): origin > live > explicit (never chatThreadMap — not an input)', () => {
|
|
90
103
|
// The chat last-seen thread is deliberately NOT a parameter: answer
|
|
91
104
|
// paths can never reach it, which is what closes the wrong-topic bug.
|
|
92
|
-
// explicit beats
|
|
105
|
+
// The model's explicit no longer beats a framework anchor — origin wins
|
|
106
|
+
// even when the model asserted a different topic (the General→CRM fix):
|
|
93
107
|
expect(
|
|
94
108
|
resolveAnswerThreadId({
|
|
95
109
|
explicitThreadId: 9,
|
|
96
110
|
originResolved: true,
|
|
97
111
|
originThreadId: BREVO,
|
|
112
|
+
liveTurnPresent: true,
|
|
98
113
|
liveThreadId: META,
|
|
99
114
|
}),
|
|
100
|
-
).toBe(
|
|
115
|
+
).toBe(BREVO)
|
|
101
116
|
// no explicit, origin resolved → origin (not live):
|
|
102
117
|
expect(
|
|
103
118
|
resolveAnswerThreadId({
|
|
@@ -107,5 +122,15 @@ describe('resolveAnswerThreadId', () => {
|
|
|
107
122
|
liveThreadId: META,
|
|
108
123
|
}),
|
|
109
124
|
).toBe(BREVO)
|
|
125
|
+
// kill switch restores the legacy explicit-first precedence:
|
|
126
|
+
expect(
|
|
127
|
+
resolveAnswerThreadId({
|
|
128
|
+
frameworkTopicAuthority: false,
|
|
129
|
+
explicitThreadId: 9,
|
|
130
|
+
originResolved: true,
|
|
131
|
+
originThreadId: BREVO,
|
|
132
|
+
liveThreadId: META,
|
|
133
|
+
}),
|
|
134
|
+
).toBe(9)
|
|
110
135
|
})
|
|
111
136
|
})
|