@librechat/agents 3.3.3 → 3.3.5
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/cjs/agents/AgentContext.cjs +2 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/constants.cjs +21 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/events.cjs +10 -1
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +793 -111
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +25 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +8 -0
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +19 -7
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +331 -5
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +41 -5
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +25 -4
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +4 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +132 -0
- package/dist/cjs/llm/preempt.cjs.map +1 -0
- package/dist/cjs/main.cjs +36 -5
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +54 -33
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +17 -43
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +315 -23
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +290 -60
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/handoffCue.cjs +63 -0
- package/dist/cjs/messages/handoffCue.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +4 -1
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +503 -127
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +35 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +28 -14
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +56 -63
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +9 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -1
- package/dist/cjs/utils/llm.cjs +1 -1
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +307 -20
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/toolContent.cjs +1514 -0
- package/dist/cjs/utils/toolContent.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +2 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/constants.mjs +19 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/events.mjs +10 -1
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +798 -116
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +25 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +9 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +19 -7
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +332 -8
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +41 -5
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +25 -4
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +4 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +131 -0
- package/dist/esm/llm/preempt.mjs.map +1 -0
- package/dist/esm/main.mjs +14 -11
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +55 -34
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +17 -43
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +305 -24
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +290 -61
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/handoffCue.mjs +61 -0
- package/dist/esm/messages/handoffCue.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +4 -1
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +500 -128
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +34 -11
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +28 -14
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +56 -63
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +9 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +6 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -1
- package/dist/esm/utils/llm.mjs +1 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +307 -21
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/toolContent.mjs +1503 -0
- package/dist/esm/utils/toolContent.mjs.map +1 -0
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +139 -2
- package/dist/types/hooks/HookRegistry.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/types.d.ts +45 -6
- package/dist/types/index.d.ts +1 -1
- package/dist/types/llm/invoke.d.ts +34 -1
- package/dist/types/llm/openai/index.d.ts +4 -0
- package/dist/types/llm/openai/utils/index.d.ts +2 -0
- package/dist/types/llm/openrouter/index.d.ts +5 -0
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/contextPruning.d.ts +2 -1
- package/dist/types/messages/core.d.ts +36 -0
- package/dist/types/messages/format.d.ts +6 -0
- package/dist/types/messages/handoffCue.d.ts +40 -0
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/injected.d.ts +3 -0
- package/dist/types/messages/prune.d.ts +16 -12
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +0 -7
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/run.d.ts +65 -0
- package/dist/types/types/stream.d.ts +1 -26
- package/dist/types/utils/tokens.d.ts +7 -0
- package/dist/types/utils/toolContent.d.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
- package/src/agents/AgentContext.ts +1 -0
- package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
- package/src/common/constants.ts +21 -0
- package/src/events.ts +15 -1
- package/src/graphs/Graph.ts +1261 -198
- package/src/graphs/MultiAgentGraph.ts +6 -1
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
- package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
- package/src/hooks/HookRegistry.ts +40 -0
- package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
- package/src/hooks/index.ts +16 -2
- package/src/hooks/types.ts +47 -3
- package/src/index.ts +1 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/anthropic/utils/message_inputs.ts +158 -12
- package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
- package/src/llm/bedrock/utils/message_inputs.ts +100 -24
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/google/utils/common.test.ts +75 -9
- package/src/llm/google/utils/common.ts +34 -3
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.test.ts +488 -0
- package/src/llm/invoke.ts +611 -8
- package/src/llm/openai/index.ts +106 -6
- package/src/llm/openai/utils/index.ts +109 -53
- package/src/llm/openai/utils/messages.test.ts +330 -1
- package/src/llm/openrouter/index.ts +12 -2
- package/src/llm/openrouter/reasoning.test.ts +314 -0
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/__tests__/observationMasking.test.ts +93 -2
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/cache.tail.test.ts +193 -0
- package/src/messages/cache.test.ts +113 -0
- package/src/messages/cache.ts +92 -60
- package/src/messages/contextPruning.test.ts +184 -0
- package/src/messages/contextPruning.ts +49 -42
- package/src/messages/core.ts +653 -40
- package/src/messages/ensureThinkingBlock.test.ts +49 -1
- package/src/messages/foldToollessToolBlocks.test.ts +163 -5
- package/src/messages/format.ts +454 -91
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/formatAgentMessages.test.ts +604 -14
- package/src/messages/formatAgentMessages.tools.test.ts +103 -0
- package/src/messages/handoffCue.test.ts +96 -0
- package/src/messages/handoffCue.ts +78 -0
- package/src/messages/index.ts +3 -0
- package/src/messages/injected.test.ts +90 -0
- package/src/messages/injected.ts +74 -0
- package/src/messages/prune.ts +996 -183
- package/src/run.ts +91 -6
- package/src/scripts/preempt-probe.ts +330 -0
- package/src/scripts/preempt-scenarios.ts +388 -0
- package/src/session/handlers.ts +32 -12
- package/src/specs/handoffCue.test.ts +165 -0
- package/src/specs/langfuse-callbacks.test.ts +352 -2
- package/src/specs/preemptSeal.test.ts +309 -0
- package/src/specs/prune.test.ts +1083 -6
- package/src/specs/summarization-unit.test.ts +105 -0
- package/src/specs/tokens.test.ts +609 -32
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +59 -23
- package/src/summarization/__tests__/node.test.ts +77 -0
- package/src/summarization/node.ts +46 -18
- package/src/tools/ToolNode.ts +150 -99
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
- package/src/tools/__tests__/directToolHooks.test.ts +255 -7
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/tool.ts +13 -2
- package/src/tools/search/types.ts +50 -3
- package/src/tools/toolOutputReferences.ts +6 -0
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +1 -42
- package/src/utils/llm.test.ts +18 -0
- package/src/utils/llm.ts +4 -1
- package/src/utils/tokens.ts +586 -31
- package/src/utils/toolContent.test.ts +1432 -0
- package/src/utils/toolContent.ts +2707 -0
- package/dist/cjs/splitStream.cjs +0 -151
- package/dist/cjs/splitStream.cjs.map +0 -1
- package/dist/esm/splitStream.mjs +0 -150
- package/dist/esm/splitStream.mjs.map +0 -1
- package/dist/types/mockStream.d.ts +0 -32
- package/dist/types/splitStream.d.ts +0 -37
- package/src/mockStream.ts +0 -99
- package/src/splitStream.ts +0 -234
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// src/scripts/preempt-scenarios.ts
|
|
2
|
+
//
|
|
3
|
+
// Live scenario matrix for the seal paths the basic probe does not exercise:
|
|
4
|
+
//
|
|
5
|
+
// npx tsx --env-file=/path/to/.env src/scripts/preempt-scenarios.ts \
|
|
6
|
+
// --scenario multiseal|toolturn|budget|cache|ma-halt|ma-continue|websearch \
|
|
7
|
+
// --provider anthropic|bedrock|...
|
|
8
|
+
//
|
|
9
|
+
// One scenario per invocation; prints SCENARIO_VERDICT as the last line.
|
|
10
|
+
import { config } from 'dotenv';
|
|
11
|
+
config();
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
14
|
+
import { tool } from '@langchain/core/tools';
|
|
15
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
16
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
17
|
+
import type * as t from '@/types';
|
|
18
|
+
import { GraphEvents } from '@/common';
|
|
19
|
+
import { createTokenCounter } from '@/utils/tokens';
|
|
20
|
+
import { getLLMConfig } from '@/utils/llmConfig';
|
|
21
|
+
import { HookRegistry } from '@/hooks';
|
|
22
|
+
import { Run } from '@/run';
|
|
23
|
+
|
|
24
|
+
const LONG_PROMPT =
|
|
25
|
+
'Write a detailed history of the Byzantine Empire from Constantine to ' +
|
|
26
|
+
'1453 in at least 800 words of flowing prose. No headings or bullets.';
|
|
27
|
+
|
|
28
|
+
function argOf(flag: string, fallback: string): string {
|
|
29
|
+
const index = process.argv.indexOf(flag);
|
|
30
|
+
return index !== -1 && process.argv[index + 1] != null
|
|
31
|
+
? process.argv[index + 1]
|
|
32
|
+
: fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textOf(message: BaseMessage | undefined): string {
|
|
36
|
+
if (message == null) return '';
|
|
37
|
+
if (typeof message.content === 'string') return message.content;
|
|
38
|
+
let text = '';
|
|
39
|
+
for (const block of message.content) {
|
|
40
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
41
|
+
text += block.text;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function kindOf(message: BaseMessage): string {
|
|
48
|
+
if (message.getType() === 'human') {
|
|
49
|
+
const source = message.additional_kwargs.source;
|
|
50
|
+
return typeof source === 'string' ? `human(${source})` : 'human';
|
|
51
|
+
}
|
|
52
|
+
if (message.getType() === 'tool') return 'tool';
|
|
53
|
+
return message.getType();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Outcome = {
|
|
57
|
+
scenario: string;
|
|
58
|
+
provider: string;
|
|
59
|
+
ok: boolean;
|
|
60
|
+
detail: Record<string, unknown>;
|
|
61
|
+
error: string | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
async function main(): Promise<Outcome> {
|
|
65
|
+
const scenario = argOf('--scenario', 'multiseal');
|
|
66
|
+
const providerKey = argOf('--provider', 'anthropic');
|
|
67
|
+
const llmConfig = getLLMConfig(providerKey);
|
|
68
|
+
if (llmConfig == null) {
|
|
69
|
+
throw new Error(`No llmConfig entry for provider "${providerKey}"`);
|
|
70
|
+
}
|
|
71
|
+
const tokenCounter = await createTokenCounter();
|
|
72
|
+
|
|
73
|
+
let armed = false;
|
|
74
|
+
let deltas = 0;
|
|
75
|
+
let armAt = 15;
|
|
76
|
+
let injections = 0;
|
|
77
|
+
let maxInjections = 1;
|
|
78
|
+
let chatModelStarts = 0;
|
|
79
|
+
let llmEnds = 0;
|
|
80
|
+
let toolExecutions = 0;
|
|
81
|
+
let haltOnBoundary = false;
|
|
82
|
+
|
|
83
|
+
const hooks = new HookRegistry();
|
|
84
|
+
hooks.register('PreemptBoundary', {
|
|
85
|
+
hooks: [
|
|
86
|
+
async () => {
|
|
87
|
+
armed = false;
|
|
88
|
+
if (haltOnBoundary) {
|
|
89
|
+
return { preventContinuation: true, stopReason: 'scenario_halt' };
|
|
90
|
+
}
|
|
91
|
+
if (injections >= maxInjections) return {};
|
|
92
|
+
injections += 1;
|
|
93
|
+
if (scenario === 'multiseal' && injections < maxInjections + 1) {
|
|
94
|
+
/** Re-arm after the resumed stream produces more deltas. */
|
|
95
|
+
armAt = deltas + 12;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
injectedMessages: [
|
|
99
|
+
{
|
|
100
|
+
role: 'user' as const,
|
|
101
|
+
content:
|
|
102
|
+
injections === 1 && maxInjections > 1
|
|
103
|
+
? 'Change of plan: write 300 words about the fall of Constantinople specifically.'
|
|
104
|
+
: 'Stop. In one short sentence: what year did Constantinople fall?',
|
|
105
|
+
source: 'steer',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
114
|
+
[GraphEvents.ON_MESSAGE_DELTA]: {
|
|
115
|
+
handle: (): void => {
|
|
116
|
+
deltas += 1;
|
|
117
|
+
/** Never re-arm past the scenario's injection budget: a real host
|
|
118
|
+
* disarms when its queue drains; this models that. */
|
|
119
|
+
if (deltas >= armAt && injections < maxInjections) armed = true;
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const calculator = tool(
|
|
125
|
+
async () => {
|
|
126
|
+
toolExecutions += 1;
|
|
127
|
+
return '345';
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'calculator',
|
|
131
|
+
description: 'Evaluates a basic arithmetic expression.',
|
|
132
|
+
schema: z.object({ expression: z.string() }),
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const streamConfig = {
|
|
137
|
+
runId: uuidv4(),
|
|
138
|
+
configurable: { user_id: 'probe-user', thread_id: `scenario-${scenario}` },
|
|
139
|
+
streamMode: 'values',
|
|
140
|
+
version: 'v2' as const,
|
|
141
|
+
callbacks: [
|
|
142
|
+
{
|
|
143
|
+
handleChatModelStart: (): void => {
|
|
144
|
+
chatModelStarts += 1;
|
|
145
|
+
},
|
|
146
|
+
handleLLMEnd: (): void => {
|
|
147
|
+
llmEnds += 1;
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const base = {
|
|
154
|
+
runId: uuidv4(),
|
|
155
|
+
tokenCounter,
|
|
156
|
+
customHandlers,
|
|
157
|
+
hooks,
|
|
158
|
+
returnContent: true,
|
|
159
|
+
skipCleanup: true,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let run: Run<t.IState>;
|
|
163
|
+
let prompt = LONG_PROMPT;
|
|
164
|
+
|
|
165
|
+
switch (scenario) {
|
|
166
|
+
case 'multiseal':
|
|
167
|
+
maxInjections = 2;
|
|
168
|
+
run = await Run.create<t.IState>({
|
|
169
|
+
...base,
|
|
170
|
+
graphConfig: { type: 'standard', llmConfig, instructions: 'You are a history assistant.' },
|
|
171
|
+
preemption: { shouldPreempt: () => armed, maxSeals: 3 },
|
|
172
|
+
});
|
|
173
|
+
break;
|
|
174
|
+
case 'toolturn':
|
|
175
|
+
armAt = 0;
|
|
176
|
+
armed = true;
|
|
177
|
+
prompt =
|
|
178
|
+
'Use the calculator tool to compute 15 * 23, then explain the historical significance of the number 345 in Roman history in at least 300 words.';
|
|
179
|
+
run = await Run.create<t.IState>({
|
|
180
|
+
...base,
|
|
181
|
+
graphConfig: {
|
|
182
|
+
type: 'standard',
|
|
183
|
+
llmConfig,
|
|
184
|
+
tools: [calculator],
|
|
185
|
+
instructions:
|
|
186
|
+
'Call the calculator immediately, before writing ANY text. After the tool result, write the explanation.',
|
|
187
|
+
},
|
|
188
|
+
preemption: { shouldPreempt: () => armed, maxSeals: 2 },
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
case 'budget':
|
|
192
|
+
run = await Run.create<t.IState>({
|
|
193
|
+
...base,
|
|
194
|
+
graphConfig: { type: 'standard', llmConfig, instructions: 'You are a history assistant.' },
|
|
195
|
+
preemption: {
|
|
196
|
+
shouldPreempt: () => {
|
|
197
|
+
/** Deliberately NEVER disarmed by the host: level-triggered forever. */
|
|
198
|
+
return deltas >= 15;
|
|
199
|
+
},
|
|
200
|
+
maxSeals: 1,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
break;
|
|
204
|
+
case 'cache':
|
|
205
|
+
run = await Run.create<t.IState>({
|
|
206
|
+
...base,
|
|
207
|
+
graphConfig: {
|
|
208
|
+
type: 'standard',
|
|
209
|
+
llmConfig: { ...llmConfig, promptCache: true } as typeof llmConfig,
|
|
210
|
+
instructions:
|
|
211
|
+
'You are a history assistant. '.repeat(80) +
|
|
212
|
+
'Answer at length in flowing prose.',
|
|
213
|
+
},
|
|
214
|
+
preemption: { shouldPreempt: () => armed, maxSeals: 2 },
|
|
215
|
+
});
|
|
216
|
+
break;
|
|
217
|
+
case 'ma-halt':
|
|
218
|
+
case 'ma-continue':
|
|
219
|
+
case 'ma-baseline':
|
|
220
|
+
haltOnBoundary = scenario === 'ma-halt';
|
|
221
|
+
run = await Run.create<t.IState>({
|
|
222
|
+
...base,
|
|
223
|
+
graphConfig: {
|
|
224
|
+
type: 'multi-agent',
|
|
225
|
+
agents: (() => {
|
|
226
|
+
const { provider, ...clientOptions } = llmConfig as {
|
|
227
|
+
provider: t.AgentInputs['provider'];
|
|
228
|
+
} & Record<string, unknown>;
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
agentId: 'writer',
|
|
232
|
+
provider,
|
|
233
|
+
clientOptions,
|
|
234
|
+
instructions: 'You are the writer. Write the requested essay.',
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
agentId: 'critic',
|
|
238
|
+
provider,
|
|
239
|
+
clientOptions,
|
|
240
|
+
instructions:
|
|
241
|
+
'You are the critic. In two sentences, critique what the writer produced.',
|
|
242
|
+
},
|
|
243
|
+
] as t.AgentInputs[];
|
|
244
|
+
})(),
|
|
245
|
+
edges: [{ from: 'writer', to: 'critic', edgeType: 'direct' }],
|
|
246
|
+
},
|
|
247
|
+
...(scenario === 'ma-baseline'
|
|
248
|
+
? {}
|
|
249
|
+
: { preemption: { shouldPreempt: () => armed, maxSeals: 1 } }),
|
|
250
|
+
});
|
|
251
|
+
break;
|
|
252
|
+
case 'websearch':
|
|
253
|
+
armAt = Number(argOf('--arm-at', '1'));
|
|
254
|
+
prompt =
|
|
255
|
+
'Search the web for the current population of Istanbul, then write a 300-word reflection on the city\'s growth.';
|
|
256
|
+
run = await Run.create<t.IState>({
|
|
257
|
+
...base,
|
|
258
|
+
graphConfig: {
|
|
259
|
+
type: 'standard',
|
|
260
|
+
llmConfig,
|
|
261
|
+
tools: [
|
|
262
|
+
{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 },
|
|
263
|
+
] as unknown as t.GraphTools,
|
|
264
|
+
instructions: 'Use web search before answering.',
|
|
265
|
+
},
|
|
266
|
+
preemption: { shouldPreempt: () => armed, maxSeals: 2 },
|
|
267
|
+
});
|
|
268
|
+
break;
|
|
269
|
+
default:
|
|
270
|
+
throw new Error(`Unknown scenario: ${scenario}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await run.processStream({ messages: [new HumanMessage(prompt)] }, streamConfig);
|
|
274
|
+
|
|
275
|
+
const messages = run.getRunMessages() ?? [];
|
|
276
|
+
const sequence = messages.map(kindOf);
|
|
277
|
+
const stats = run.getPreemptStats();
|
|
278
|
+
const steerCount = sequence.filter((k) => k === 'human(steer)').length;
|
|
279
|
+
const finalText = textOf(messages[messages.length - 1]);
|
|
280
|
+
const detail: Record<string, unknown> = {
|
|
281
|
+
sequence,
|
|
282
|
+
seals: stats.seals,
|
|
283
|
+
emptyBoundaries: stats.emptyBoundaries,
|
|
284
|
+
steerCount,
|
|
285
|
+
chatModelStarts,
|
|
286
|
+
llmEnds,
|
|
287
|
+
runsLeftOpen: chatModelStarts - llmEnds,
|
|
288
|
+
toolExecutions,
|
|
289
|
+
haltReason: run.getHaltReason() ?? null,
|
|
290
|
+
finalChars: finalText.length,
|
|
291
|
+
sealedBlockTypes: Array.isArray(messages[0]?.content)
|
|
292
|
+
? (messages[0].content as Array<{ type?: string }>).map((b) => b.type)
|
|
293
|
+
: ['string'],
|
|
294
|
+
lastTexts: messages.slice(-3).map((m) => ({
|
|
295
|
+
kind: kindOf(m),
|
|
296
|
+
agent: (m as { name?: string }).name ?? m.additional_kwargs.agentId ?? null,
|
|
297
|
+
chars: textOf(m).length,
|
|
298
|
+
head: textOf(m).slice(0, 80),
|
|
299
|
+
})),
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
let ok = false;
|
|
303
|
+
switch (scenario) {
|
|
304
|
+
case 'multiseal':
|
|
305
|
+
ok =
|
|
306
|
+
stats.seals === 2 &&
|
|
307
|
+
steerCount === 2 &&
|
|
308
|
+
chatModelStarts === 3 &&
|
|
309
|
+
llmEnds === 3 &&
|
|
310
|
+
/1453/.test(finalText);
|
|
311
|
+
break;
|
|
312
|
+
case 'toolturn': {
|
|
313
|
+
const toolIdx = sequence.indexOf('tool');
|
|
314
|
+
const steerIdx = sequence.indexOf('human(steer)');
|
|
315
|
+
ok =
|
|
316
|
+
stats.seals === 1 &&
|
|
317
|
+
toolExecutions === 1 &&
|
|
318
|
+
toolIdx !== -1 &&
|
|
319
|
+
steerIdx > toolIdx &&
|
|
320
|
+
chatModelStarts === llmEnds &&
|
|
321
|
+
finalText.trim().length > 0;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case 'budget':
|
|
325
|
+
ok =
|
|
326
|
+
stats.seals === 1 &&
|
|
327
|
+
steerCount === 1 &&
|
|
328
|
+
chatModelStarts === 2 &&
|
|
329
|
+
llmEnds === 2 &&
|
|
330
|
+
/1453/.test(finalText);
|
|
331
|
+
break;
|
|
332
|
+
case 'cache':
|
|
333
|
+
ok =
|
|
334
|
+
stats.seals === 1 &&
|
|
335
|
+
steerCount === 1 &&
|
|
336
|
+
chatModelStarts === llmEnds &&
|
|
337
|
+
finalText.trim().length > 0;
|
|
338
|
+
break;
|
|
339
|
+
case 'ma-halt':
|
|
340
|
+
ok =
|
|
341
|
+
stats.seals === 1 &&
|
|
342
|
+
run.getHaltReason() === 'scenario_halt' &&
|
|
343
|
+
chatModelStarts === 1 &&
|
|
344
|
+
llmEnds === 1;
|
|
345
|
+
break;
|
|
346
|
+
case 'ma-baseline':
|
|
347
|
+
ok = chatModelStarts === 2 && llmEnds === 2;
|
|
348
|
+
break;
|
|
349
|
+
case 'ma-continue':
|
|
350
|
+
ok =
|
|
351
|
+
stats.seals === 1 &&
|
|
352
|
+
steerCount === 1 &&
|
|
353
|
+
chatModelStarts === 3 &&
|
|
354
|
+
llmEnds === 3 &&
|
|
355
|
+
finalText.trim().length > 0;
|
|
356
|
+
break;
|
|
357
|
+
case 'websearch':
|
|
358
|
+
/**
|
|
359
|
+
* Best-effort: the load-bearing claims are (a) no seal orphans an
|
|
360
|
+
* unanswered server-tool call — the provider would 400 the resume —
|
|
361
|
+
* and (b) the run completes with balanced lifecycle counters.
|
|
362
|
+
*/
|
|
363
|
+
ok =
|
|
364
|
+
chatModelStarts === llmEnds &&
|
|
365
|
+
finalText.trim().length > 0 &&
|
|
366
|
+
stats.seals <= 2;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { scenario, provider: providerKey, ok, detail, error: null };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
main()
|
|
374
|
+
.then((outcome) => {
|
|
375
|
+
console.log(`SCENARIO_VERDICT ${JSON.stringify(outcome)}`);
|
|
376
|
+
process.exit(outcome.ok ? 0 : 1);
|
|
377
|
+
})
|
|
378
|
+
.catch((error: unknown) => {
|
|
379
|
+
console.log(
|
|
380
|
+
`SCENARIO_VERDICT ${JSON.stringify({
|
|
381
|
+
scenario: argOf('--scenario', '?'),
|
|
382
|
+
provider: argOf('--provider', '?'),
|
|
383
|
+
ok: false,
|
|
384
|
+
error: error instanceof Error ? error.message.slice(0, 400) : String(error),
|
|
385
|
+
})}`
|
|
386
|
+
);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
});
|
package/src/session/handlers.ts
CHANGED
|
@@ -7,7 +7,11 @@ import type {
|
|
|
7
7
|
import type * as t from '@/types';
|
|
8
8
|
import { ModelEndHandler, ToolEndHandler } from '@/events';
|
|
9
9
|
import { toJsonValue } from './messageSerialization';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createContentAggregator,
|
|
12
|
+
dispatchesChatModelStream,
|
|
13
|
+
SDK_STREAM_DISPATCH,
|
|
14
|
+
} from '@/stream';
|
|
11
15
|
import { createTimestamp } from './ids';
|
|
12
16
|
import { GraphEvents } from '@/common';
|
|
13
17
|
|
|
@@ -107,18 +111,34 @@ export function createRunHandlers(params: {
|
|
|
107
111
|
|
|
108
112
|
emitEvent(createEvent('run.started'));
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Forwards to the host's own handler, so it inherits that handler's
|
|
116
|
+
* capability. Branding matters because this wrapper is installed on EVERY
|
|
117
|
+
* session run and merged last: without it, a host that registered
|
|
118
|
+
* `new ChatModelStreamHandler()` would silently lose the sealing opt-out
|
|
119
|
+
* that registration is supposed to buy.
|
|
120
|
+
*/
|
|
121
|
+
const streamWrapper: t.EventHandler = {
|
|
122
|
+
handle: async (event, data, metadata, graph): Promise<void> => {
|
|
123
|
+
await callUserHandler({
|
|
124
|
+
userHandlers: params.userHandlers,
|
|
125
|
+
event,
|
|
126
|
+
data,
|
|
127
|
+
metadata,
|
|
128
|
+
graph,
|
|
129
|
+
});
|
|
121
130
|
},
|
|
131
|
+
};
|
|
132
|
+
if (
|
|
133
|
+
dispatchesChatModelStream(
|
|
134
|
+
params.userHandlers?.[GraphEvents.CHAT_MODEL_STREAM]
|
|
135
|
+
)
|
|
136
|
+
) {
|
|
137
|
+
Object.defineProperty(streamWrapper, SDK_STREAM_DISPATCH, { value: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handlers: Record<string, t.EventHandler> = {
|
|
141
|
+
[GraphEvents.CHAT_MODEL_STREAM]: streamWrapper,
|
|
122
142
|
[GraphEvents.CHAT_MODEL_END]: {
|
|
123
143
|
handle: async (event, data, metadata, graph): Promise<void> => {
|
|
124
144
|
await modelEndHandler.handle(
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/specs/handoffCue.test.ts
|
|
2
|
+
/**
|
|
3
|
+
* End-to-end #345: a bare direct-edge successor on a prefill-semantics
|
|
4
|
+
* provider must receive a user-turn handoff cue after the predecessor's
|
|
5
|
+
* trailing assistant output — and tolerant providers must not.
|
|
6
|
+
*/
|
|
7
|
+
import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
|
|
8
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
9
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
10
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
11
|
+
import type * as t from '@/types';
|
|
12
|
+
import { Providers } from '@/common';
|
|
13
|
+
import { PREDECESSOR_HANDOFF_CUE } from '@/messages/handoffCue';
|
|
14
|
+
import { FakeChatModel } from '@/llm/fake';
|
|
15
|
+
import { Run } from '@/run';
|
|
16
|
+
|
|
17
|
+
class CapturingChatModel extends FakeChatModel {
|
|
18
|
+
readonly invocations: BaseMessage[][] = [];
|
|
19
|
+
|
|
20
|
+
override async *_streamResponseChunks(
|
|
21
|
+
messages: BaseMessage[],
|
|
22
|
+
options: this['ParsedCallOptions'],
|
|
23
|
+
runManager?: CallbackManagerForLLMRun
|
|
24
|
+
): AsyncGenerator<ChatGenerationChunk> {
|
|
25
|
+
this.invocations.push(messages);
|
|
26
|
+
yield* super._streamResponseChunks(messages, options, runManager);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A ChatModel whose streamed chunks never carry ids. Overrides
|
|
32
|
+
* `_streamIterator` — the seam BELOW LangChain's run-id stamping (which
|
|
33
|
+
* happens in `_generateUncached`, downstream of `_streamResponseChunks`, so
|
|
34
|
+
* a subclass override there cannot produce this shape) but still a Runnable,
|
|
35
|
+
* so `systemRunnable.pipe(model)` works. The public ChatModel contract
|
|
36
|
+
* requires no ids; custom implementations really do ship this.
|
|
37
|
+
*/
|
|
38
|
+
class IdlessChatModel extends FakeChatModel {
|
|
39
|
+
readonly invocations: BaseMessage[][] = [];
|
|
40
|
+
private turn = 0;
|
|
41
|
+
|
|
42
|
+
constructor(private texts: string[]) {
|
|
43
|
+
super({ responses: texts });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override async *_streamIterator(
|
|
47
|
+
input: BaseMessage[]
|
|
48
|
+
): AsyncGenerator<AIMessageChunk> {
|
|
49
|
+
this.invocations.push(input);
|
|
50
|
+
const text = this.texts[Math.min(this.turn, this.texts.length - 1)];
|
|
51
|
+
this.turn += 1;
|
|
52
|
+
yield new AIMessageChunk({ content: text });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const streamConfig = {
|
|
57
|
+
configurable: { thread_id: 'handoff-cue-e2e' },
|
|
58
|
+
streamMode: 'values' as const,
|
|
59
|
+
version: 'v2' as const,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function runTwoAgents(
|
|
63
|
+
provider: Providers,
|
|
64
|
+
modelId = 'test-model',
|
|
65
|
+
idlessModel = false
|
|
66
|
+
): Promise<{ criticPayload: BaseMessage[]; messages: BaseMessage[] }> {
|
|
67
|
+
const run = await Run.create<t.IState>({
|
|
68
|
+
runId: `handoff-cue-${provider}`,
|
|
69
|
+
graphConfig: {
|
|
70
|
+
type: 'multi-agent',
|
|
71
|
+
agents: [
|
|
72
|
+
{
|
|
73
|
+
agentId: 'writer',
|
|
74
|
+
provider,
|
|
75
|
+
clientOptions: { model: modelId, apiKey: 'test-key' },
|
|
76
|
+
instructions: 'You are the writer.',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
agentId: 'critic',
|
|
80
|
+
provider,
|
|
81
|
+
clientOptions: { model: modelId, apiKey: 'test-key' },
|
|
82
|
+
instructions: 'You are the critic.',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
edges: [{ from: 'writer', to: 'critic', edgeType: 'direct' }],
|
|
86
|
+
},
|
|
87
|
+
returnContent: true,
|
|
88
|
+
skipCleanup: true,
|
|
89
|
+
});
|
|
90
|
+
if (!run.Graph) {
|
|
91
|
+
throw new Error('Expected graph to be initialized');
|
|
92
|
+
}
|
|
93
|
+
const model = idlessModel
|
|
94
|
+
? new IdlessChatModel(['The essay text.', 'The critique text.'])
|
|
95
|
+
: new CapturingChatModel({
|
|
96
|
+
responses: ['The essay text.', 'The critique text.'],
|
|
97
|
+
});
|
|
98
|
+
run.Graph.overrideModel = model as never;
|
|
99
|
+
|
|
100
|
+
await run.processStream(
|
|
101
|
+
{ messages: [new HumanMessage('write the essay')] },
|
|
102
|
+
streamConfig
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(model.invocations).toHaveLength(2);
|
|
106
|
+
return {
|
|
107
|
+
criticPayload: model.invocations[1],
|
|
108
|
+
messages: run.getRunMessages() ?? [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('bare direct-edge handoff cue (#345)', () => {
|
|
113
|
+
jest.setTimeout(15000);
|
|
114
|
+
|
|
115
|
+
it('anthropic successor payload ends with the cue, not the predecessor prefill', async () => {
|
|
116
|
+
const { criticPayload, messages } = await runTwoAgents(
|
|
117
|
+
Providers.ANTHROPIC
|
|
118
|
+
);
|
|
119
|
+
const last = criticPayload[criticPayload.length - 1];
|
|
120
|
+
expect(last.getType()).toBe('human');
|
|
121
|
+
expect(last.content).toBe(PREDECESSOR_HANDOFF_CUE);
|
|
122
|
+
expect(criticPayload[criticPayload.length - 2].getType()).toBe('ai');
|
|
123
|
+
/**
|
|
124
|
+
* Wire-only: the cue exists in the provider payload, never in run
|
|
125
|
+
* state or host-visible messages.
|
|
126
|
+
*/
|
|
127
|
+
expect(
|
|
128
|
+
messages.some((m) => m.content === PREDECESSOR_HANDOFF_CUE)
|
|
129
|
+
).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('bedrock successor payload also gets the cue', async () => {
|
|
133
|
+
/** Bedrock anthropic-likeness is model-dependent — needs a Claude id. */
|
|
134
|
+
const { criticPayload } = await runTwoAgents(
|
|
135
|
+
Providers.BEDROCK,
|
|
136
|
+
'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
|
137
|
+
);
|
|
138
|
+
const last = criticPayload[criticPayload.length - 1];
|
|
139
|
+
expect(last.content).toBe(PREDECESSOR_HANDOFF_CUE);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The public ChatModel contract does not require ids. Provenance must not
|
|
144
|
+
* depend on the model setting one — the node assigns a reducer-style id
|
|
145
|
+
* before recording, so the successor still gets the cue.
|
|
146
|
+
*/
|
|
147
|
+
it('applies the cue even when the model never sets message ids', async () => {
|
|
148
|
+
const { criticPayload } = await runTwoAgents(
|
|
149
|
+
Providers.ANTHROPIC,
|
|
150
|
+
'test-model',
|
|
151
|
+
true
|
|
152
|
+
);
|
|
153
|
+
const last = criticPayload[criticPayload.length - 1];
|
|
154
|
+
expect(last.content).toBe(PREDECESSOR_HANDOFF_CUE);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('openAI successor payload is untouched', async () => {
|
|
158
|
+
const { criticPayload } = await runTwoAgents(Providers.OPENAI);
|
|
159
|
+
const last = criticPayload[criticPayload.length - 1];
|
|
160
|
+
expect(last.getType()).toBe('ai');
|
|
161
|
+
expect(
|
|
162
|
+
criticPayload.some((m) => m.content === PREDECESSOR_HANDOFF_CUE)
|
|
163
|
+
).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|