@librechat/agents 3.2.31 → 3.2.32

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.
@@ -3199,3 +3199,39 @@ describe('Opus 4.6', () => {
3199
3199
  });
3200
3200
  });
3201
3201
  });
3202
+
3203
+ describe('Tool input survives message conversion', () => {
3204
+ // Adapted from @langchain/anthropic's "converting messages doesn't drop tool input".
3205
+ // Guards the core >= 1.1.46 streaming-aggregation regression where the tool_use
3206
+ // content block's input was emptied (and re-serialization dropped it).
3207
+ test('converting messages does not drop tool input (live)', async () => {
3208
+ const jokeTool = {
3209
+ name: 'generate_random_joke',
3210
+ description: 'Generate a random joke.',
3211
+ schema: z.object({
3212
+ prompt: z.string().describe('The prompt to generate the joke for.'),
3213
+ }),
3214
+ };
3215
+ const model = new ChatAnthropic({
3216
+ model: 'claude-sonnet-4-5-20250929',
3217
+ temperature: 0,
3218
+ }).bindTools([jokeTool]);
3219
+
3220
+ const result = await model.invoke([
3221
+ new HumanMessage(
3222
+ 'Generate three (3) random jokes. Use the generate_random_joke tool and call it three times before responding. This is very important.'
3223
+ ),
3224
+ ]);
3225
+ expect(result.tool_calls?.length ?? 0).toBeGreaterThan(0);
3226
+
3227
+ const converted = _convertMessagesToAnthropicPayload([result]);
3228
+ const toolUseBlocks = (
3229
+ converted.messages[0].content as unknown as Array<Record<string, unknown>>
3230
+ ).filter((block) => block.type === 'tool_use');
3231
+ expect(toolUseBlocks.length).toBeGreaterThan(0);
3232
+ for (const block of toolUseBlocks) {
3233
+ expect(block.input).toBeDefined();
3234
+ expect((block.input as Record<string, unknown>).prompt).toBeDefined();
3235
+ }
3236
+ });
3237
+ });
@@ -524,6 +524,17 @@ function _formatContent(message: BaseMessage) {
524
524
  return null;
525
525
  }
526
526
 
527
+ // Core's v1 streaming aggregation can leave a partial tool-input delta as a
528
+ // standalone block typed `text` carrying `input` but no `text`. The assembled
529
+ // input is restored on the tool_use block from `message.tool_calls`, so drop it.
530
+ if (
531
+ contentPart.type === 'text' &&
532
+ 'input' in contentPart &&
533
+ !('text' in contentPart)
534
+ ) {
535
+ return null;
536
+ }
537
+
527
538
  if (isDataContentBlock(contentPart)) {
528
539
  return convertToProviderContentBlock(
529
540
  contentPart,
@@ -617,9 +628,9 @@ function _formatContent(message: BaseMessage) {
617
628
  }
618
629
 
619
630
  if (contentPartCopy.type === 'input_json_delta') {
620
- // `input_json_delta` type only represents yielding partial tool inputs
621
- // and is not a valid type for Anthropic messages.
622
- contentPartCopy.type = 'tool_use';
631
+ // Orphaned partial tool-input delta with no id of its own. The assembled
632
+ // input is restored on the tool_use block from `message.tool_calls`; drop it.
633
+ return null;
623
634
  }
624
635
 
625
636
  if (
@@ -631,6 +642,37 @@ function _formatContent(message: BaseMessage) {
631
642
  contentPartCopy.type = 'server_tool_use';
632
643
  }
633
644
 
645
+ // Core's streaming aggregation can leave the inline tool_use input empty
646
+ // (the assembled arguments live in `message.tool_calls` or, for persisted
647
+ // messages, in sibling input_json_delta blocks). Restore it when missing.
648
+ if (
649
+ contentPartCopy.type === 'tool_use' &&
650
+ typeof contentPartCopy.id === 'string' &&
651
+ (contentPartCopy.input === '' || contentPartCopy.input == null)
652
+ ) {
653
+ const matchingToolCall = isAIMessage(message)
654
+ ? message.tool_calls?.find((toolCall) => toolCall.id === contentPartCopy.id)
655
+ : undefined;
656
+ if (matchingToolCall) {
657
+ contentPartCopy.input = matchingToolCall.args;
658
+ } else {
659
+ const blockIndex = (contentPart as Record<string, unknown>).index;
660
+ const merged = contentParts
661
+ .filter((part) => {
662
+ const p = part as Record<string, unknown>;
663
+ return (
664
+ p.type === 'input_json_delta' &&
665
+ p.index === blockIndex &&
666
+ typeof p.input === 'string'
667
+ );
668
+ })
669
+ .reduce((acc, part) => acc + (part as Record<string, unknown>).input, '');
670
+ if (merged !== '') {
671
+ contentPartCopy.input = merged;
672
+ }
673
+ }
674
+ }
675
+
634
676
  if ('input' in contentPartCopy) {
635
677
  // Anthropic tool use inputs should be valid objects, when applicable.
636
678
  if (typeof contentPartCopy.input === 'string') {
@@ -203,10 +203,14 @@ export function _makeMessageChunkFromAnthropicEvent(
203
203
  ) {
204
204
  const content = [
205
205
  {
206
+ // No `type`: core's streaming aggregation merges this partial input into the
207
+ // sibling tool_use/server_tool_use block at the same index, keeping its type.
208
+ // A typed delta block won't merge under core >= 1.1.46 ("keep different block
209
+ // types separate"), which would orphan the input and empty the tool_use input.
206
210
  index: data.index,
207
211
  input: data.delta.partial_json,
208
- type: data.delta.type,
209
- },
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ } as any,
210
214
  ];
211
215
  return {
212
216
  chunk: new AIMessageChunk({
@@ -0,0 +1,186 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+ import { _convertMessagesToAnthropicPayload } from './message_inputs';
5
+ import { _makeMessageChunkFromAnthropicEvent } from './message_outputs';
6
+
7
+ /**
8
+ * Regression for @langchain/core >= 1.1.46 streaming aggregation: a tool call's
9
+ * input_json_delta is kept as a separate content block and v1-cast to a `text`
10
+ * block carrying `input` but no `text`, leaving the sibling tool_use block with an
11
+ * empty inline input. The assembled arguments live on `message.tool_calls`.
12
+ * Re-serializing such a message previously threw "Unsupported message content format".
13
+ */
14
+ describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input', () => {
15
+ const buildHistory = (): BaseMessage[] => [
16
+ new HumanMessage('what is 12345 * 6789?'),
17
+ new AIMessage({
18
+ content: [
19
+ { type: 'text', text: 'Let me calculate that.' },
20
+ // tool_use block left with empty inline input by aggregation
21
+ {
22
+ type: 'tool_use',
23
+ id: 'toolu_calc',
24
+ name: 'calculator',
25
+ input: '',
26
+ index: 0,
27
+ } as any,
28
+ // orphaned input delta, v1-cast to `text` with `input` and no `text`
29
+ { type: 'text', index: 0, input: '{"input": "12345 * 6789"}' } as any,
30
+ ],
31
+ tool_calls: [
32
+ {
33
+ id: 'toolu_calc',
34
+ name: 'calculator',
35
+ args: { input: '12345 * 6789' },
36
+ type: 'tool_call',
37
+ },
38
+ ],
39
+ }),
40
+ ];
41
+
42
+ it('does not throw on the orphaned text-with-input block', () => {
43
+ expect(() => _convertMessagesToAnthropicPayload(buildHistory())).not.toThrow();
44
+ });
45
+
46
+ it('restores tool_use input from message.tool_calls and drops the orphan block', () => {
47
+ const payload = _convertMessagesToAnthropicPayload(buildHistory());
48
+ const assistant = payload.messages.find((m: any) => m.role === 'assistant');
49
+ expect(assistant).toBeDefined();
50
+ const blocks = assistant!.content as any[];
51
+
52
+ const toolUse = blocks.find((b) => b.type === 'tool_use');
53
+ expect(toolUse).toMatchObject({
54
+ type: 'tool_use',
55
+ id: 'toolu_calc',
56
+ name: 'calculator',
57
+ input: { input: '12345 * 6789' },
58
+ });
59
+
60
+ // No leftover delta: no text block carrying `input`, no input_json_delta.
61
+ expect(
62
+ blocks.find(
63
+ (b) => (b.type === 'text' && 'input' in b) || b.type === 'input_json_delta'
64
+ )
65
+ ).toBeUndefined();
66
+
67
+ // The real assistant text is preserved.
68
+ expect(
69
+ blocks.some((b) => b.type === 'text' && b.text === 'Let me calculate that.')
70
+ ).toBe(true);
71
+ });
72
+
73
+ it('does not overwrite a tool_use block that already has inline input', () => {
74
+ const history: BaseMessage[] = [
75
+ new HumanMessage('hi'),
76
+ new AIMessage({
77
+ content: [
78
+ {
79
+ type: 'tool_use',
80
+ id: 'toolu_x',
81
+ name: 'calculator',
82
+ input: { input: '2 + 2' },
83
+ } as any,
84
+ ],
85
+ tool_calls: [
86
+ {
87
+ id: 'toolu_x',
88
+ name: 'calculator',
89
+ args: { input: '999' },
90
+ type: 'tool_call',
91
+ },
92
+ ],
93
+ }),
94
+ ];
95
+ const payload = _convertMessagesToAnthropicPayload(history);
96
+ const assistant = payload.messages.find((m: any) => m.role === 'assistant');
97
+ const toolUse = (assistant!.content as any[]).find((b) => b.type === 'tool_use');
98
+ expect(toolUse.input).toEqual({ input: '2 + 2' });
99
+ });
100
+
101
+ // Adapted from @langchain/anthropic's
102
+ // "partial tool input is correctly merged before calling Anthropic API".
103
+ it('merges sibling input_json_delta blocks into tool_use input (persisted, no tool_calls)', () => {
104
+ const messages: BaseMessage[] = [
105
+ new HumanMessage('What\'s the weather in Seattle tomorrow?'),
106
+ new AIMessage({
107
+ content: [
108
+ { type: 'text', index: 1, text: 'I need to call the get_weather tool' },
109
+ { type: 'tool_use', index: 2, name: 'get_weather', id: 'tool_call_id', input: '' },
110
+ { type: 'input_json_delta', index: 2, input: '{"city": "' },
111
+ { type: 'input_json_delta', index: 2, input: 'Seattle", "da' },
112
+ { type: 'input_json_delta', index: 2, input: 'te": "to' },
113
+ { type: 'input_json_delta', index: 2, input: 'morrow"}' },
114
+ ] as any,
115
+ }),
116
+ ];
117
+
118
+ const payload = _convertMessagesToAnthropicPayload(messages);
119
+ const assistant = payload.messages.find((m: any) => m.role === 'assistant');
120
+ const blocks = assistant!.content as any[];
121
+ expect(blocks.filter((b) => b.type === 'input_json_delta')).toHaveLength(0);
122
+ const toolUse = blocks.find((b) => b.type === 'tool_use');
123
+ expect(toolUse).toMatchObject({
124
+ type: 'tool_use',
125
+ name: 'get_weather',
126
+ id: 'tool_call_id',
127
+ input: { city: 'Seattle', date: 'tomorrow' },
128
+ });
129
+ });
130
+ });
131
+
132
+ describe('_makeMessageChunkFromAnthropicEvent — streamed tool input merges into content', () => {
133
+ const fields = { streamUsage: true, coerceContentToString: false };
134
+
135
+ it('emits input deltas without a type so aggregation merges them into the tool_use block', () => {
136
+ const events: any[] = [
137
+ {
138
+ type: 'content_block_start',
139
+ index: 0,
140
+ content_block: { type: 'tool_use', id: 'toolu_1', name: 'calculator', input: {} },
141
+ },
142
+ {
143
+ type: 'content_block_delta',
144
+ index: 0,
145
+ delta: { type: 'input_json_delta', partial_json: '{"input"' },
146
+ },
147
+ {
148
+ type: 'content_block_delta',
149
+ index: 0,
150
+ delta: { type: 'input_json_delta', partial_json: ': "2 + 2"}' },
151
+ },
152
+ ];
153
+ const chunks = events
154
+ .map((e) => _makeMessageChunkFromAnthropicEvent(e, fields)?.chunk)
155
+ .filter((c): c is NonNullable<typeof c> => c != null);
156
+
157
+ // input-delta chunks must not carry a `type` (so core merges them by index
158
+ // into the sibling tool_use/server_tool_use block rather than orphaning them)
159
+ const deltaBlocks = chunks
160
+ .slice(1)
161
+ .flatMap((c) => (Array.isArray(c.content) ? (c.content as any[]) : []))
162
+ .filter((b) => 'input' in b);
163
+ expect(deltaBlocks.length).toBeGreaterThan(0);
164
+ deltaBlocks.forEach((b) => expect('type' in b).toBe(false));
165
+
166
+ // aggregate the chunks the way core does during streaming
167
+ const merged = chunks.reduce((acc, c) => acc.concat(c));
168
+ const blocks = merged.content as any[];
169
+
170
+ const toolUse = blocks.find((b) => b.type === 'tool_use');
171
+ expect(toolUse).toMatchObject({ type: 'tool_use', id: 'toolu_1', name: 'calculator' });
172
+ const parsed =
173
+ typeof toolUse.input === 'string' ? JSON.parse(toolUse.input) : toolUse.input;
174
+ expect(parsed).toEqual({ input: '2 + 2' });
175
+
176
+ // no orphaned delta block survives aggregation
177
+ expect(blocks.filter((b) => b.type !== 'tool_use' && 'input' in b)).toHaveLength(0);
178
+
179
+ // tool_calls remain correctly aggregated
180
+ expect(merged.tool_calls?.[0]).toMatchObject({
181
+ id: 'toolu_1',
182
+ name: 'calculator',
183
+ args: { input: '2 + 2' },
184
+ });
185
+ });
186
+ });
@@ -13,6 +13,7 @@ import {
13
13
  stripBedrockCacheControl,
14
14
  addBedrockCacheControl,
15
15
  addCacheControl,
16
+ addCacheControlToStablePrefixMessages,
16
17
  } from './cache';
17
18
  import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
18
19
  import { toLangChainContent } from './langchain';
@@ -767,6 +768,127 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
767
768
  });
768
769
  });
769
770
 
771
+ describe('synthetic skill/meta messages are not cache-anchored', () => {
772
+ const hasAnthropicMarker = (m: BaseMessage): boolean =>
773
+ Array.isArray(m.content) &&
774
+ m.content.some((block) => 'cache_control' in block);
775
+
776
+ const hasBedrockCachePoint = (m: BaseMessage): boolean =>
777
+ Array.isArray(m.content) &&
778
+ m.content.some((block) => 'cachePoint' in block);
779
+
780
+ const skillBody = (skillName: string, content = 'SKILL BODY'): HumanMessage =>
781
+ new HumanMessage({
782
+ content,
783
+ additional_kwargs: { isMeta: true, source: 'skill', skillName },
784
+ });
785
+
786
+ it('Anthropic: skips a trailing synthetic skill message; markers land on the real user messages', () => {
787
+ const messages: BaseMessage[] = [
788
+ new HumanMessage('First real question'),
789
+ new AIMessage('Answer'),
790
+ new HumanMessage('Second real question'),
791
+ skillBody('pdf-analyzer'),
792
+ ];
793
+
794
+ const result = addCacheControl<BaseMessage>(messages);
795
+
796
+ expect(hasAnthropicMarker(result[3])).toBe(false);
797
+ expect(hasAnthropicMarker(result[2])).toBe(true);
798
+ expect(hasAnthropicMarker(result[0])).toBe(true);
799
+ });
800
+
801
+ it('Anthropic: strips a stale marker from a synthetic skill message without re-adding one', () => {
802
+ const stale = new HumanMessage({
803
+ content: toLangChainContent([
804
+ {
805
+ type: 'text',
806
+ text: 'SKILL BODY',
807
+ cache_control: { type: 'ephemeral' },
808
+ } as MessageContentComplex,
809
+ ]),
810
+ additional_kwargs: {
811
+ isMeta: true,
812
+ source: 'skill',
813
+ skillName: 'pdf-analyzer',
814
+ },
815
+ });
816
+ const messages: BaseMessage[] = [
817
+ new HumanMessage('Real question'),
818
+ new AIMessage('Answer'),
819
+ stale,
820
+ ];
821
+
822
+ const result = addCacheControl<BaseMessage>(messages);
823
+
824
+ expect(hasAnthropicMarker(result[2])).toBe(false);
825
+ expect(hasAnthropicMarker(result[0])).toBe(true);
826
+ });
827
+
828
+ it('Anthropic: detects skill messages by additional_kwargs.source even without isMeta', () => {
829
+ const messages: BaseMessage[] = [
830
+ new HumanMessage('Real question'),
831
+ new AIMessage('Answer'),
832
+ new HumanMessage({
833
+ content: 'SKILL BODY',
834
+ additional_kwargs: { source: 'skill', skillName: 'pdf-analyzer' },
835
+ }),
836
+ ];
837
+
838
+ const result = addCacheControl<BaseMessage>(messages);
839
+
840
+ expect(hasAnthropicMarker(result[2])).toBe(false);
841
+ expect(hasAnthropicMarker(result[0])).toBe(true);
842
+ });
843
+
844
+ it('Bedrock: skips a trailing synthetic skill message; cachePoints land on the real user messages', () => {
845
+ const messages: BaseMessage[] = [
846
+ new HumanMessage('First real question'),
847
+ new AIMessage('Answer'),
848
+ new HumanMessage('Second real question'),
849
+ skillBody('pdf-analyzer'),
850
+ ];
851
+
852
+ const result = addBedrockCacheControl<BaseMessage>(messages);
853
+
854
+ expect(hasBedrockCachePoint(result[3])).toBe(false);
855
+ expect(hasBedrockCachePoint(result[2])).toBe(true);
856
+ expect(hasBedrockCachePoint(result[0])).toBe(true);
857
+ });
858
+
859
+ it('stable-prefix fallback: anchors the real user message, not a synthetic skill message', () => {
860
+ // Mirrors AgentContext's dynamic-tail path: the only assistant message is a
861
+ // skill-only tool call (no text), so the assistant-only pass adds no marker
862
+ // and the cacheable fallback runs. It must skip the reconstructed skill
863
+ // HumanMessage and anchor the real user message instead.
864
+ const messages: BaseMessage[] = [
865
+ new HumanMessage('Real stable question'),
866
+ new AIMessage({
867
+ content: toLangChainContent([
868
+ {
869
+ type: 'tool_use',
870
+ id: 'call_1',
871
+ name: 'skill',
872
+ input: { skillName: 'pdf-analyzer' },
873
+ } as MessageContentComplex,
874
+ ]),
875
+ tool_calls: [
876
+ { id: 'call_1', name: 'skill', args: { skillName: 'pdf-analyzer' } },
877
+ ],
878
+ }),
879
+ skillBody('pdf-analyzer'),
880
+ ];
881
+
882
+ const result = addCacheControlToStablePrefixMessages<BaseMessage>(
883
+ messages,
884
+ 2
885
+ );
886
+
887
+ expect(hasAnthropicMarker(result[2])).toBe(false);
888
+ expect(hasAnthropicMarker(result[0])).toBe(true);
889
+ });
890
+ });
891
+
770
892
  describe('stripAnthropicCacheControl', () => {
771
893
  it('removes cache_control fields from content blocks', () => {
772
894
  const messages: TestMsg[] = [
@@ -182,6 +182,7 @@ export function addCacheControl<T extends AnthropicMessage | BaseMessage>(
182
182
  const needsCacheAdd =
183
183
  userMessagesModified < 2 &&
184
184
  isUserMessage &&
185
+ !isSyntheticMetaMessage(originalMessage) &&
185
186
  (typeof content === 'string' || hasArrayContent);
186
187
 
187
188
  // Skip messages that don't need any work
@@ -263,6 +264,26 @@ function getMessageRole(message: MessageWithContent): string | undefined {
263
264
  return undefined;
264
265
  }
265
266
 
267
+ const SKILL_MESSAGE_SOURCE = 'skill';
268
+
269
+ /**
270
+ * Synthetic skill/meta messages (reconstructed skill bodies, primed SKILL.md
271
+ * instructions) are re-injected every turn and are not stable conversation
272
+ * turns. They must not anchor a fresh prompt-cache marker — doing so pins the
273
+ * cache to a volatile/duplicated prefix. Stale markers are still stripped from
274
+ * them; only the *adding* of new markers is suppressed. Detected via
275
+ * `additional_kwargs.isMeta === true` or `additional_kwargs.source === 'skill'`.
276
+ */
277
+ function isSyntheticMetaMessage(message: MessageWithContent): boolean {
278
+ const { additional_kwargs: kwargs } = message as {
279
+ additional_kwargs?: { isMeta?: unknown; source?: unknown };
280
+ };
281
+ if (kwargs == null) {
282
+ return false;
283
+ }
284
+ return kwargs.isMeta === true || kwargs.source === SKILL_MESSAGE_SOURCE;
285
+ }
286
+
266
287
  function isCacheableConversationMessage(message: MessageWithContent): boolean {
267
288
  const role = getMessageRole(message);
268
289
  return (
@@ -305,7 +326,9 @@ function addCacheControlToRecentMessages<
305
326
  const content = originalMessage.content;
306
327
  const hasArrayContent = Array.isArray(content);
307
328
  const canAddCache =
308
- cachePointsAdded < maxCachePoints && canUseMessage(originalMessage);
329
+ cachePointsAdded < maxCachePoints &&
330
+ canUseMessage(originalMessage) &&
331
+ !isSyntheticMetaMessage(originalMessage);
309
332
 
310
333
  if (!canAddCache && !hasArrayContent) {
311
334
  continue;
@@ -536,6 +559,7 @@ export function addBedrockCacheControl<
536
559
  isUserMessage &&
537
560
  !isToolMessage &&
538
561
  !isEmptyString &&
562
+ !isSyntheticMetaMessage(originalMessage) &&
539
563
  (typeof content === 'string' || hasArrayContent);
540
564
 
541
565
  if (!needsCacheAdd && !hasArrayContent && !hasSerializationProps) {
@@ -334,6 +334,10 @@ interface FormatAssistantMessageOptions {
334
334
 
335
335
  interface FormatAgentMessagesOptions {
336
336
  provider?: Providers;
337
+ /** Skill names already primed fresh this turn (manual/always-apply). Their
338
+ * historical `skill` tool_calls are not reconstructed into a HumanMessage,
339
+ * so the same SKILL.md body is not injected twice in one request. */
340
+ skipSkillBodyNames?: Set<string>;
337
341
  }
338
342
 
339
343
  function extractReasoningContent(
@@ -1159,6 +1163,7 @@ function extractSkillName(args: unknown): string | undefined {
1159
1163
  * @param indexTokenCountMap - Optional map of message indices to token counts.
1160
1164
  * @param tools - Optional set of tool names that are allowed in the request.
1161
1165
  * @param skills - Optional map of skill name to body for reconstructing skill HumanMessages.
1166
+ * @param options - Optional formatting options (provider, skipSkillBodyNames).
1162
1167
  * @returns - Object containing formatted messages and updated indexTokenCountMap if provided.
1163
1168
  */
1164
1169
  export const formatAgentMessages = (
@@ -1445,7 +1450,11 @@ export const formatAgentMessages = (
1445
1450
  const endMessageIndex = messages.length;
1446
1451
 
1447
1452
  if (pendingSkillNames?.size != null && pendingSkillNames.size > 0) {
1453
+ const skipSkillBodyNames = options?.skipSkillBodyNames;
1448
1454
  for (const skillName of pendingSkillNames) {
1455
+ if (skipSkillBodyNames != null && skipSkillBodyNames.has(skillName)) {
1456
+ continue;
1457
+ }
1449
1458
  const body = skills?.get(skillName) ?? '';
1450
1459
  if (body) {
1451
1460
  messages.push(
@@ -410,4 +410,104 @@ describe('formatAgentMessages skill body reconstruction', () => {
410
410
  expect(assistantTotal).toBe(500);
411
411
  });
412
412
  });
413
+
414
+ describe('skipSkillBodyNames (fresh-prime dedupe)', () => {
415
+ const tools = new Set([Constants.SKILL_TOOL]);
416
+
417
+ const injectedSkillBodies = (
418
+ messages: ReturnType<typeof formatAgentMessages>['messages']
419
+ ) =>
420
+ messages.filter(
421
+ (m) =>
422
+ m instanceof HumanMessage &&
423
+ (m as HumanMessage).additional_kwargs.source === 'skill'
424
+ );
425
+
426
+ it('does NOT reconstruct body when skill name is in skipSkillBodyNames', () => {
427
+ const payload: TPayload = [
428
+ { role: 'user', content: 'Review my code' },
429
+ {
430
+ role: 'assistant',
431
+ content: [skillToolCall('call_1', 'code-review')],
432
+ },
433
+ ];
434
+
435
+ const { messages } = formatAgentMessages(
436
+ payload,
437
+ undefined,
438
+ tools,
439
+ skillBodies,
440
+ { skipSkillBodyNames: new Set(['code-review']) }
441
+ );
442
+
443
+ expect(injectedSkillBodies(messages)).toHaveLength(0);
444
+ });
445
+
446
+ it('reconstructs only names NOT in skipSkillBodyNames', () => {
447
+ const payload: TPayload = [
448
+ { role: 'user', content: 'Go' },
449
+ {
450
+ role: 'assistant',
451
+ content: [
452
+ skillToolCall('call_1', 'pdf-analyzer'),
453
+ skillToolCall('call_2', 'code-review'),
454
+ ],
455
+ },
456
+ ];
457
+
458
+ const { messages } = formatAgentMessages(
459
+ payload,
460
+ undefined,
461
+ tools,
462
+ skillBodies,
463
+ { skipSkillBodyNames: new Set(['code-review']) }
464
+ );
465
+
466
+ const injected = injectedSkillBodies(messages);
467
+ expect(injected).toHaveLength(1);
468
+ expect((injected[0] as HumanMessage).additional_kwargs.skillName).toBe(
469
+ 'pdf-analyzer'
470
+ );
471
+ });
472
+
473
+ it('reconstructs normally when skipSkillBodyNames is empty', () => {
474
+ const payload: TPayload = [
475
+ { role: 'user', content: 'Review my code' },
476
+ {
477
+ role: 'assistant',
478
+ content: [skillToolCall('call_1', 'code-review')],
479
+ },
480
+ ];
481
+
482
+ const { messages } = formatAgentMessages(
483
+ payload,
484
+ undefined,
485
+ tools,
486
+ skillBodies,
487
+ { skipSkillBodyNames: new Set() }
488
+ );
489
+
490
+ expect(injectedSkillBodies(messages)).toHaveLength(1);
491
+ });
492
+
493
+ it('skips in the non-tools-filtering path too', () => {
494
+ const payload: TPayload = [
495
+ { role: 'user', content: 'Review my code' },
496
+ {
497
+ role: 'assistant',
498
+ content: [skillToolCall('call_1', 'code-review')],
499
+ },
500
+ ];
501
+
502
+ const { messages } = formatAgentMessages(
503
+ payload,
504
+ undefined,
505
+ undefined,
506
+ skillBodies,
507
+ { skipSkillBodyNames: new Set(['code-review']) }
508
+ );
509
+
510
+ expect(injectedSkillBodies(messages)).toHaveLength(0);
511
+ });
512
+ });
413
513
  });