@genesislcap/ai-assistant 15.14.2 → 15.15.0

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.
@@ -497,8 +497,6 @@ suite('multi-trigger first-wins: the earliest-listed firing trigger is the reaso
497
497
  );
498
498
  });
499
499
 
500
- suite.run();
501
-
502
500
  // ── batched collapse (GENC-1476) ───────────────────────────────────────────
503
501
  // Condensation rewrites history in place, so collapsing the instant a trigger fires
504
502
  // breaks the prompt cache on nearly every call of a re-read loop. Batching holds the
@@ -662,3 +660,87 @@ batch('a mixed policy batches superseded but still honours agentEnd immediately'
662
660
  });
663
661
 
664
662
  batch.run();
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // image-bearing tool results (GENC-1508)
666
+ //
667
+ // A render tool returns a few words of text plus ~15KB of base64. Sizing the payload
668
+ // on `content` alone leaves it permanently under the floor, so the image rides every
669
+ // later turn — the exact bytes condensation exists to shed. And a stub that leaves the
670
+ // attachments in place claims a collapse that did not happen.
671
+ // ---------------------------------------------------------------------------
672
+
673
+ /** A tool result shaped like a mockup render: short text, one large image. */
674
+ const imageToolMsg = (toolCallId: string, dataLen: number): ChatMessage => ({
675
+ role: 'tool',
676
+ content: 'rendered',
677
+ toolResult: {
678
+ toolCallId,
679
+ content: 'rendered',
680
+ attachments: [
681
+ { kind: 'image', name: 'mockup.png', mimeType: 'image/png', data: 'a'.repeat(dataLen) },
682
+ ],
683
+ },
684
+ });
685
+
686
+ suite('image bytes count toward the condense floor, so a short-text render condenses', () => {
687
+ const history: ChatMessage[] = [
688
+ asstCall('m1', 'render_mockup', { html: '<p/>' }),
689
+ imageToolMsg('m1', CONDENSE_MIN_CHARS + 500),
690
+ asstCall('m2', 'render_mockup', { html: '<p/>' }),
691
+ imageToolMsg('m2', CONDENSE_MIN_CHARS + 500),
692
+ ];
693
+ const policies = new Map<string, RegisteredCondensePolicy>([
694
+ ['m1', reg({ on: { kind: 'superseded', by: 'mockup' }, response: 'pointer' })],
695
+ ['m2', reg({ on: { kind: 'superseded', by: 'mockup' }, response: 'pointer' })],
696
+ ]);
697
+ const { on } = sink();
698
+ const out = applyCondensation(history, policies, ctx(), on);
699
+ assert.ok(
700
+ out[1].toolResult!.content.startsWith('['),
701
+ 'the superseded render is collapsed to a stub — sizing on content alone would skip it',
702
+ );
703
+ });
704
+
705
+ suite('a condensed image result drops its attachments, not just its text', () => {
706
+ const history: ChatMessage[] = [
707
+ asstCall('m1', 'render_mockup', { html: '<p/>' }),
708
+ imageToolMsg('m1', CONDENSE_MIN_CHARS + 500),
709
+ asstCall('m2', 'render_mockup', { html: '<p/>' }),
710
+ imageToolMsg('m2', CONDENSE_MIN_CHARS + 500),
711
+ ];
712
+ const policies = new Map<string, RegisteredCondensePolicy>([
713
+ ['m1', reg({ on: { kind: 'superseded', by: 'mockup' }, response: 'pointer' })],
714
+ ['m2', reg({ on: { kind: 'superseded', by: 'mockup' }, response: 'pointer' })],
715
+ ]);
716
+ const { on } = sink();
717
+ const out = applyCondensation(history, policies, ctx(), on);
718
+ assert.is(
719
+ out[1].toolResult!.attachments,
720
+ undefined,
721
+ 'leaving the image attached would shed none of the bytes the stub claims were elided',
722
+ );
723
+ assert.equal(
724
+ out[3].toolResult!.attachments?.length,
725
+ 1,
726
+ 'the surviving latest render keeps its image',
727
+ );
728
+ });
729
+
730
+ suite('a short text-only result is still below the floor with no attachments', () => {
731
+ const history: ChatMessage[] = [
732
+ asstCall('s1', 'render_mockup', {}),
733
+ toolMsg('s1', 'rendered'),
734
+ asstCall('s2', 'render_mockup', {}),
735
+ toolMsg('s2', 'rendered'),
736
+ ];
737
+ const policies = new Map<string, RegisteredCondensePolicy>([
738
+ ['s1', reg({ on: { kind: 'superseded', by: 'mockup' }, response: 'pointer' })],
739
+ ['s2', reg({ on: { kind: 'superseded', by: 'mockup' }, response: 'pointer' })],
740
+ ]);
741
+ const { on } = sink();
742
+ const out = applyCondensation(history, policies, ctx(), on);
743
+ assert.is(out[1].toolResult!.content, 'rendered', 'the floor still protects small payloads');
744
+ });
745
+
746
+ suite.run();
@@ -169,11 +169,19 @@ function condenseStub(
169
169
  trigger: CondenseTrigger,
170
170
  origLen: number,
171
171
  restorable: boolean,
172
+ /**
173
+ * Images elided alongside the text. Reported separately because the model reads this stub as
174
+ * fact: folding base64 bytes into the char count would tell it a 15KB render was "~15447
175
+ * chars" of prose. One variable cannot be both the size FLOOR (bytes, correctly) and a
176
+ * model-facing description (characters).
177
+ */
178
+ imageCount = 0,
172
179
  ): string {
173
180
  const what = target === 'args' ? 'args' : 'result';
174
181
  const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
175
182
  const restore = restorable ? '; re-call to restore' : '';
176
- return `[${tool}${key} ${what} elided, ~${origLen} chars (${triggerReason(trigger)})${restore}]`;
183
+ const images = imageCount > 0 ? ` + ${imageCount} image${imageCount === 1 ? '' : 's'}` : '';
184
+ return `[${tool}${key} — ${what} elided, ~${origLen} chars${images} (${triggerReason(trigger)})${restore}]`;
177
185
  }
178
186
 
179
187
  /** Stable label for the `context.condensed` meta-event's `trigger` field. */
@@ -392,7 +400,18 @@ export function applyCondensation(
392
400
  const entry = policies.get(msg.toolResult.toolCallId);
393
401
  // Defensive `?? 0` for restored/malformed history — `content` is typed
394
402
  // `string`, but a 0-length payload is simply below the floor and skipped.
395
- const origLen = msg.toolResult.content?.length ?? 0;
403
+ //
404
+ // Attachment bytes COUNT toward the floor (GENC-1508). An image-bearing result is
405
+ // typically a few words of text — "rendered the mockup" — plus ~15KB of base64, so
406
+ // sizing on `content` alone leaves it permanently under CONDENSE_MIN_CHARS and the
407
+ // image rides every subsequent turn. That is the exact payload condensation exists
408
+ // to shed, so it must be what is measured.
409
+ const textLen = msg.toolResult.content?.length ?? 0;
410
+ const images = msg.toolResult.attachments ?? [];
411
+ // Bytes gate the FLOOR (an image result is a few words of text plus ~15KB of base64, so
412
+ // sizing on text alone would never condense it); `textLen` and the image count are what
413
+ // the model is told. See condenseStub.
414
+ const origLen = textLen + images.reduce((n, att) => n + (att.data?.length ?? 0), 0);
396
415
  const fired = entry?.policy.response
397
416
  ? firstFired(msg.toolResult.toolCallId, entry)
398
417
  : undefined;
@@ -402,7 +421,7 @@ export function applyCondensation(
402
421
  // Kept non-empty — Anthropic rejects empty tool_result content.
403
422
  const content =
404
423
  result === 'drop' || result === 'pointer'
405
- ? condenseStub('response', tool, fired, origLen, result === 'pointer')
424
+ ? condenseStub('response', tool, fired, textLen, result === 'pointer', images.length)
406
425
  : result.replaceWith;
407
426
  if (!entry.reportedResponse) {
408
427
  entry.reportedResponse = true;
@@ -416,7 +435,11 @@ export function applyCondensation(
416
435
  tokensSaved: estimateTokensSaved(origLen, content.length),
417
436
  });
418
437
  }
419
- return { ...msg, toolResult: { ...msg.toolResult, content } };
438
+ // `attachments` is dropped, not spread through: the stub says the payload was
439
+ // elided, and leaving the images attached would make that a lie AND shed none of
440
+ // the bytes the condensation was performed to shed.
441
+ const { attachments: _elided, ...restOfResult } = msg.toolResult;
442
+ return { ...msg, toolResult: { ...restOfResult, content } };
420
443
  }
421
444
  }
422
445
 
@@ -7,6 +7,7 @@ import {
7
7
  COMPACT_MIN_MESSAGES_TO_COMPACT,
8
8
  findCompactionCut,
9
9
  normalizeForProvider,
10
+ transformHistoryForAgent,
10
11
  } from './history-transform';
11
12
 
12
13
  const asMsg = (o: Record<string, unknown>): ChatMessage => o as unknown as ChatMessage;
@@ -194,4 +195,34 @@ Suite('compactionDividerLabel pluralizes and falls back to 0', () => {
194
195
  );
195
196
  });
196
197
 
198
+ // ── masking another agent's tool payload (GENC-1508) ─────────────────────────
199
+
200
+ Suite('transformHistoryForAgent drops tool-result IMAGES, not just the text', () => {
201
+ const history: ChatMessage[] = [
202
+ asMsg({
203
+ role: 'tool',
204
+ content: '',
205
+ agentName: 'other-agent',
206
+ toolResult: {
207
+ toolCallId: 'r1',
208
+ content: 'rendered the mockup',
209
+ attachments: [
210
+ { kind: 'image', name: 'mockup.png', mimeType: 'image/png', data: 'a'.repeat(2000) },
211
+ ],
212
+ },
213
+ }),
214
+ ];
215
+ const out = transformHistoryForAgent(history, 'this-agent');
216
+ assert.ok(
217
+ out[0].toolResult!.content.includes('omitted'),
218
+ 'the text is stubbed, as it always was',
219
+ );
220
+ assert.is(
221
+ out[0].toolResult!.attachments,
222
+ undefined,
223
+ "masking exists to keep another agent's payload out of context — an image IS the payload, " +
224
+ 'and stubbing the prose while shipping the render defeats the mask where it matters most',
225
+ );
226
+ });
227
+
197
228
  Suite.run();
@@ -10,9 +10,15 @@ function maskToolPayload(msg: ChatMessage): ChatMessage {
10
10
  return { ...msg, toolCalls: msg.toolCalls.map((tc) => ({ ...tc, args: {} })) };
11
11
  }
12
12
  if (msg.toolResult) {
13
+ // `attachments` is dropped, not spread through. Masking exists to keep another agent's
14
+ // payload out of this one's context, and an image is the largest payload there is — spreading
15
+ // it would stub the prose while still shipping the render, defeating the mask for exactly the
16
+ // case it matters most. `applyHistoryCap` masks everything past the cap for sub-agent context,
17
+ // so without this every older image rides every sub-agent turn under an "omitted" label.
18
+ const { attachments: _masked, ...restOfResult } = msg.toolResult;
13
19
  return {
14
20
  ...msg,
15
- toolResult: { ...msg.toolResult, content: "[other agent's tool result omitted]" },
21
+ toolResult: { ...restOfResult, content: "[other agent's tool result omitted]" },
16
22
  };
17
23
  }
18
24
  return msg;