@genesislcap/ai-assistant 15.10.3 → 15.10.4

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.
@@ -5334,7 +5334,7 @@
5334
5334
  "description": "Invoked once per payload at its full→stub transition."
5335
5335
  }
5336
5336
  ],
5337
- "description": "Collapse stale tool payloads (declared via `condenseWhen`) out of the\nmodel-bound history. Pure over the `history` array — it emits new message\nobjects and never mutates the input messages (mirroring the agent-masking\ntransform). It DOES, however, flip the `reported*` flag on the matching\n`policies` entry the first time a payload collapses the report-once gate that\nkeeps `onCondensed` firing once per (tool call, payload) even though the\ncollapse itself re-runs before every provider call.\n\nTriggers (a policy may list several via `on: Trigger[]` — the FIRST to fire\ncollapses the payload; all are monotonic, so the collapse never reverts):\n- `superseded` — among all registered calls sharing a `by` key, the LAST in\n history order survives; every earlier one's targeted payload collapses. A\n re-call with the same key becomes the new survivor and re-arms the rest.\n- `age` — the result is first visible one model-call after the call, so `turns`\n is how many model-calls may see the full result before it collapses\n (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,\n then collapses. The clock is monotonic across turns.\n- `turnEnd` — collapses once the turn (`sendMessage`) that made the call has\n ended (fires when `turn > callTurn`): full for the rest of that request,\n gone on every later one.\n- `agentEnd` — collapses once the agent activation that made the call has ended\n (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across\n all of the agent's turns, dropped when its flow finishes.\n- `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`\n after the call (a sub-`agentEnd` boundary the author declares), OR at\n `agentEnd` as a backstop if no boundary is ever declared.\n\nThe tool-call/result envelope is never removed (providers reject orphaned\ncalls/results) — only the args object or the result content is replaced."
5337
+ "description": "Collapse stale tool payloads (declared via `condenseWhen`) out of the\nmodel-bound history. Pure over the `history` array — it emits new message\nobjects and never mutates the input messages (mirroring the agent-masking\ntransform). It DOES, however, write two latches onto the matching `policies`\nentry the first time a payload collapses, both because the collapse itself\nre-runs before every provider call: the `reported*` flag (the report-once gate\nthat keeps `onCondensed` firing once per (tool call, payload)) and\n`firedTrigger` (which pins the reason so a reason-bearing stub is rendered\nonce and never rewritten).\n\nTriggers (a policy may list several via `on: Trigger[]` — the FIRST to fire\ncollapses the payload; all are monotonic, so the collapse never reverts):\n- `superseded` — among all registered calls sharing a `by` key, the LAST in\n history order survives; every earlier one's targeted payload collapses. A\n re-call with the same key becomes the new survivor and re-arms the rest.\n- `age` — the result is first visible one model-call after the call, so `turns`\n is how many model-calls may see the full result before it collapses\n (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,\n then collapses. The clock is monotonic across turns.\n- `turnEnd` — collapses once the turn (`sendMessage`) that made the call has\n ended (fires when `turn > callTurn`): full for the rest of that request,\n gone on every later one.\n- `agentEnd` — collapses once the agent activation that made the call has ended\n (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across\n all of the agent's turns, dropped when its flow finishes.\n- `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`\n after the call (a sub-`agentEnd` boundary the author declares), OR at\n `agentEnd` as a backstop if no boundary is ever declared.\n\nThe tool-call/result envelope is never removed (providers reject orphaned\ncalls/results) — only the args object or the result content is replaced."
5338
5338
  }
5339
5339
  ],
5340
5340
  "exports": [
@@ -1,4 +1,4 @@
1
- import type { ChatMessage, CondensePolicy } from '@genesislcap/foundation-ai';
1
+ import type { ChatMessage, CondensePolicy, CondenseTrigger } from '@genesislcap/foundation-ai';
2
2
  /**
3
3
  * Tool-context condensation: collapse stale tool payloads out of the history
4
4
  * sent to the model, while leaving stored history untouched.
@@ -46,6 +46,28 @@ export interface RegisteredCondensePolicy {
46
46
  reportedArgs?: boolean;
47
47
  /** `context.condensed` already emitted for the response payload (fire-once). */
48
48
  reportedResponse?: boolean;
49
+ /**
50
+ * The first trigger to fire for this call, latched on the pass it fired.
51
+ *
52
+ * "Fired", not "collapsed": it is written during trigger resolution, before the
53
+ * per-payload size floor is applied, so an entry whose payloads are all below the floor
54
+ * can latch without anything ever rendering. That is deliberate and safe — both payloads
55
+ * of a call resolve off the same clocks in the same pass, so they can never disagree
56
+ * about which trigger fired, and a payload's size is fixed for the life of the call
57
+ * (condensation never touches stored history), so a below-floor payload can never later
58
+ * become a large one under a stale reason.
59
+ *
60
+ * Trigger resolution picks the first LISTED trigger that is *currently* true, so without
61
+ * this it re-evaluates on every provider call and can change after the collapse: with the
62
+ * usual `on: [{superseded}, {agentEnd}]`, a payload collapsed at `agentEnd` silently
63
+ * re-resolves to `superseded` as soon as a newer call on the same key lands. Any stub that
64
+ * embeds the reason (`pointer` does) is then rewritten — an in-place history mutation that
65
+ * invalidates the prompt cache from that position a second time, for a payload that had
66
+ * already collapsed. Latching keeps the rendered stub stable, which is sound because a
67
+ * collapse is monotonic and never reverts, so the first trigger to fire is the correct
68
+ * one to keep.
69
+ */
70
+ firedTrigger?: CondenseTrigger;
49
71
  }
50
72
  /**
51
73
  * The driver's live state at the moment of a provider call — the clocks every
@@ -106,10 +128,12 @@ export interface CondensedEventDetail {
106
128
  * Collapse stale tool payloads (declared via `condenseWhen`) out of the
107
129
  * model-bound history. Pure over the `history` array — it emits new message
108
130
  * objects and never mutates the input messages (mirroring the agent-masking
109
- * transform). It DOES, however, flip the `reported*` flag on the matching
110
- * `policies` entry the first time a payload collapses the report-once gate that
111
- * keeps `onCondensed` firing once per (tool call, payload) even though the
112
- * collapse itself re-runs before every provider call.
131
+ * transform). It DOES, however, write two latches onto the matching `policies`
132
+ * entry the first time a payload collapses, both because the collapse itself
133
+ * re-runs before every provider call: the `reported*` flag (the report-once gate
134
+ * that keeps `onCondensed` firing once per (tool call, payload)) and
135
+ * `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
136
+ * once and never rewritten).
113
137
  *
114
138
  * Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
115
139
  * collapses the payload; all are monotonic, so the collapse never reverts):
@@ -1 +1 @@
1
- {"version":3,"file":"condense-history.d.ts","sourceRoot":"","sources":["../../../src/utils/condense-history.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAmB,MAAM,4BAA4B,CAAC;AAE/F;;;;;;;;;;;;;GAaG;AAEH;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,uFAAuF;AACvF,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAQ9C;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,eAAe,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD;;;OAGG;IACH,UAAU,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C;;;;;;;;;;;;;;;;OAgBG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,WAAW,EAAE,MAAM,CAAC;CACrB;AAgED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,EAAE,EACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,wBAAwB,CAAC,EAC/C,GAAG,EAAE,eAAe,EACpB,WAAW,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,GAClD,WAAW,EAAE,CA0Jf"}
1
+ {"version":3,"file":"condense-history.d.ts","sourceRoot":"","sources":["../../../src/utils/condense-history.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE/F;;;;;;;;;;;;;GAaG;AAEH;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,uFAAuF;AACvF,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAQ9C;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,YAAY,CAAC,EAAE,eAAe,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,eAAe,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD;;;OAGG;IACH,UAAU,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C;;;;;;;;;;;;;;;;OAgBG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,WAAW,EAAE,MAAM,CAAC;CACrB;AA2ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,EAAE,EACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,wBAAwB,CAAC,EAC/C,GAAG,EAAE,eAAe,EACpB,WAAW,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,GAClD,WAAW,EAAE,CAwKf"}
@@ -40,11 +40,21 @@ function triggerReason(trigger) {
40
40
  return 'superseded';
41
41
  }
42
42
  }
43
- /** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
44
- function condenseStub(target, tool, trigger, origLen) {
43
+ /**
44
+ * Framework-generated stub, shared by `pointer` and `drop`. Names the tool, the superseded key
45
+ * (the only surviving identifier once args are elided — the model cannot otherwise tell WHICH
46
+ * file a collapsed `vfs_write` wrote) and the original size.
47
+ *
48
+ * `restorable` is the ONLY difference between the two modes: `pointer` promises that re-calling
49
+ * reproduces the content and is valid solely for idempotent tools; `drop` makes no such promise
50
+ * and must not carry the clause. Identifying information is not what separates them — a stub too
51
+ * bare to say what the call did is how GENC-1494 started.
52
+ */
53
+ function condenseStub(target, tool, trigger, origLen, restorable) {
45
54
  const what = target === 'args' ? 'args' : 'result';
46
55
  const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
47
- return `[${tool}${key} ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
56
+ const restore = restorable ? '; re-call to restore' : '';
57
+ return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)})${restore}]`;
48
58
  }
49
59
  /** Stable label for the `context.condensed` meta-event's `trigger` field. */
50
60
  function triggerLabel(trigger) {
@@ -81,10 +91,12 @@ function marksDiscontinuity(kind) {
81
91
  * Collapse stale tool payloads (declared via `condenseWhen`) out of the
82
92
  * model-bound history. Pure over the `history` array — it emits new message
83
93
  * objects and never mutates the input messages (mirroring the agent-masking
84
- * transform). It DOES, however, flip the `reported*` flag on the matching
85
- * `policies` entry the first time a payload collapses the report-once gate that
86
- * keeps `onCondensed` firing once per (tool call, payload) even though the
87
- * collapse itself re-runs before every provider call.
94
+ * transform). It DOES, however, write two latches onto the matching `policies`
95
+ * entry the first time a payload collapses, both because the collapse itself
96
+ * re-runs before every provider call: the `reported*` flag (the report-once gate
97
+ * that keeps `onCondensed` firing once per (tool call, payload)) and
98
+ * `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
99
+ * once and never rewritten).
88
100
  *
89
101
  * Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
90
102
  * collapses the payload; all are monotonic, so the collapse never reverts):
@@ -172,7 +184,20 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
172
184
  };
173
185
  // The first listed trigger that fires — drives the collapse, the stub reason,
174
186
  // and the event label. `undefined` means the payload stays full.
175
- const firstFired = (toolCallId, entry) => triggersOf(entry).find((t) => (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t));
187
+ //
188
+ // Latched on first fire (see `firedTrigger`): re-resolving on every pass lets the reason
189
+ // change under a payload that has already collapsed, which rewrites any reason-bearing stub
190
+ // and costs a second cache break. Both payloads of a call share the latch — they are
191
+ // evaluated in the same pass off the same clocks, so they never disagree about which
192
+ // trigger fired; only the size floor decides whether each one renders.
193
+ const firstFired = (toolCallId, entry) => {
194
+ if (entry.firedTrigger)
195
+ return entry.firedTrigger;
196
+ const fired = triggersOf(entry).find((t) => (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t));
197
+ if (fired)
198
+ entry.firedTrigger = fired;
199
+ return fired;
200
+ };
176
201
  return history.map((msg) => {
177
202
  var _a, _b, _c, _d;
178
203
  // Tool-call ARGS live on the assistant message.
@@ -191,16 +216,23 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
191
216
  return tc;
192
217
  changed = true;
193
218
  const result = entry.policy.args;
194
- // Args must stay a Record; `drop` is an empty object, otherwise stash the
195
- // stub under a single key. Spread `tc` so providerMetadata (e.g. the
196
- // Gemini reasoning signature that must round-trip) and UI fields survive.
197
- const args = result === 'drop'
198
- ? {}
199
- : {
200
- [CONDENSED_ARGS_KEY]: result === 'pointer'
201
- ? condenseStub('args', tc.name, fired, origLen)
202
- : result.replaceWith,
203
- };
219
+ // Args must stay a Record, so every mode stashes its stub under a single key.
220
+ // Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
221
+ // round-trip) and UI fields survive.
222
+ //
223
+ // GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
224
+ // from a call the model made with NO arguments, and the model reads its own history
225
+ // as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
226
+ // a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
227
+ // without content, and burned two hours re-writing files that were already correct.
228
+ // A bare marker is not enough either — it still fails to say WHICH file the call
229
+ // wrote, since `path` went with the args. So `drop` renders the same identifying stub
230
+ // as `pointer`, minus only the restore promise it cannot honour.
231
+ const args = {
232
+ [CONDENSED_ARGS_KEY]: result === 'drop' || result === 'pointer'
233
+ ? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
234
+ : result.replaceWith,
235
+ };
204
236
  if (!entry.reportedArgs) {
205
237
  entry.reportedArgs = true;
206
238
  const stubLen = JSON.stringify(args).length;
@@ -231,11 +263,9 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
231
263
  const tool = (_d = nameById.get(msg.toolResult.toolCallId)) !== null && _d !== void 0 ? _d : msg.toolResult.toolCallId;
232
264
  const result = entry.policy.response; // `fired` is only set when response is declared
233
265
  // Kept non-empty — Anthropic rejects empty tool_result content.
234
- const content = result === 'drop'
235
- ? '[elided]'
236
- : result === 'pointer'
237
- ? condenseStub('response', tool, fired, origLen)
238
- : result.replaceWith;
266
+ const content = result === 'drop' || result === 'pointer'
267
+ ? condenseStub('response', tool, fired, origLen, result === 'pointer')
268
+ : result.replaceWith;
239
269
  if (!entry.reportedResponse) {
240
270
  entry.reportedResponse = true;
241
271
  onCondensed({
@@ -1,5 +1,5 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import { applyCondensation, CONDENSE_MIN_CHARS, } from './condense-history';
2
+ import { applyCondensation, CONDENSE_MIN_CHARS, CONDENSED_ARGS_KEY, } from './condense-history';
3
3
  const suite = createLogicSuite('applyCondensation');
4
4
  // Payloads must clear the driver-level floor to be condensed.
5
5
  const big = (n = CONDENSE_MIN_CHARS + 500) => 'x'.repeat(n);
@@ -154,11 +154,20 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
154
154
  ]);
155
155
  const { on } = sink();
156
156
  const out = applyCondensation(history, policies, ctx(), on);
157
- assert.is(out[1].toolResult.content, '[elided]', 'drop is a minimal non-empty placeholder');
157
+ // `drop` differs from `pointer` ONLY by the restore promise — it still identifies the call,
158
+ // because a stub too bare to say what the call did is how GENC-1494 started.
159
+ assert.match(out[1].toolResult.content, /vfs_read/, 'drop names the tool');
160
+ assert.match(out[1].toolResult.content, /result elided/, 'drop says what was elided');
161
+ assert.not.match(out[1].toolResult.content, /re-call to restore/, 'drop makes no restore promise');
158
162
  assert.match(out[5].toolResult.content, /re-call to restore/, 'pointer carries the restore hint');
159
163
  assert.is(out[9].toolResult.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
160
164
  });
161
- suite('args drop yields an empty object; pointer stashes a stub under one key', () => {
165
+ // GENC-1494: dropped args must NEVER collapse to `{}`. A bare empty object is
166
+ // indistinguishable from a call the model made with no arguments at all, and the model
167
+ // treats its own history as fact — a ui-builder run read its elided `vfs_write` calls as
168
+ // proof it was writing files with no content and re-wrote them for two hours. A bare marker
169
+ // is not enough either: `path` goes with the args, so the stub must still say WHICH file.
170
+ suite('args drop identifies the call; pointer stashes a stub under one key', () => {
162
171
  const history = [
163
172
  asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
164
173
  toolMsg('w1', 'ok'),
@@ -171,9 +180,45 @@ suite('args drop yields an empty object; pointer stashes a stub under one key',
171
180
  ]);
172
181
  const { on } = sink();
173
182
  const out = applyCondensation(history, policies, ctx(), on);
174
- assert.equal(out[0].toolCalls[0].args, {}, 'dropped args is an empty object');
183
+ const dropped = out[0].toolCalls[0].args;
184
+ const stub = String(dropped[CONDENSED_ARGS_KEY]);
185
+ assert.ok(Object.keys(dropped).length > 0, 'dropped args are never empty — that reads as a no-argument call');
186
+ assert.match(stub, /vfs_write/, 'stub names the tool');
187
+ assert.match(stub, /\bA\b/, 'stub names the superseded key — which file this call wrote');
188
+ assert.not.match(stub, /re-call to restore/, 'drop must not promise a restore it cannot honour');
175
189
  assert.equal(out[2].toolCalls[0].args, { path: 'A', content: big() }, 'latest write kept');
176
190
  });
191
+ // GENC-1494: trigger resolution picks the first LISTED trigger that is currently true, so it
192
+ // used to change after the collapse — with the usual `on: [{superseded}, {agentEnd}]`, a payload
193
+ // collapsed at `agentEnd` re-resolved to `superseded` the moment a newer same-key call landed,
194
+ // rewriting the stub and breaking the prompt cache a second time for an already-collapsed
195
+ // payload. In one reported session that was 22/22 args collapses and 97 response collapses.
196
+ suite('the fired trigger is latched, so a collapsed stub is never rewritten', () => {
197
+ const history = [asstCall('r1', 'vfs_read', { path: 'A' }), toolMsg('r1', big())];
198
+ const entry = reg({
199
+ on: [{ kind: 'superseded', by: 'A' }, { kind: 'agentEnd' }],
200
+ response: 'pointer', // pointer embeds the reason, so a re-resolve is visible in the text
201
+ });
202
+ const policies = new Map([['r1', entry]]);
203
+ const { events, on } = sink();
204
+ // Pass 1: the activation has ended but nothing supersedes r1 yet → collapses as agentEnd.
205
+ const first = applyCondensation(history, policies, ctx({ activationEnded: () => true }), on);
206
+ assert.match(first[1].toolResult.content, /agent finished/, 'collapses under agentEnd');
207
+ assert.is(events.length, 1);
208
+ assert.is(events[0].trigger, 'agentEnd');
209
+ // Pass 2: a newer read of the SAME key now exists, which would re-resolve the trigger to
210
+ // `superseded` (it is listed first). The already-collapsed stub must not change.
211
+ const grown = [
212
+ ...history,
213
+ asstCall('r2', 'vfs_read', { path: 'A' }),
214
+ toolMsg('r2', big()),
215
+ ];
216
+ policies.set('r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' }));
217
+ const second = applyCondensation(grown, policies, ctx({ activationEnded: () => true }), on);
218
+ assert.match(second[1].toolResult.content, /agent finished/, 'reason stays pinned to agentEnd');
219
+ assert.not.match(second[1].toolResult.content, /superseded/, 'must not re-resolve');
220
+ assert.is(second[1].toolResult.content, first[1].toolResult.content, 'stub text is byte-stable');
221
+ });
177
222
  // ---------------------------------------------------------------------------
178
223
  // envelope + metadata invariants
179
224
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.10.3",
4
+ "version": "15.10.4",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -73,26 +73,26 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@genesislcap/foundation-testing": "15.10.3",
77
- "@genesislcap/genx": "15.10.3",
78
- "@genesislcap/rollup-builder": "15.10.3",
79
- "@genesislcap/ts-builder": "15.10.3",
80
- "@genesislcap/uvu-playwright-builder": "15.10.3",
81
- "@genesislcap/vite-builder": "15.10.3",
82
- "@genesislcap/webpack-builder": "15.10.3",
76
+ "@genesislcap/foundation-testing": "15.10.4",
77
+ "@genesislcap/genx": "15.10.4",
78
+ "@genesislcap/rollup-builder": "15.10.4",
79
+ "@genesislcap/ts-builder": "15.10.4",
80
+ "@genesislcap/uvu-playwright-builder": "15.10.4",
81
+ "@genesislcap/vite-builder": "15.10.4",
82
+ "@genesislcap/webpack-builder": "15.10.4",
83
83
  "@types/dompurify": "^3.0.5",
84
84
  "@types/marked": "^5.0.2",
85
85
  "esbuild": "0.25.12"
86
86
  },
87
87
  "dependencies": {
88
- "@genesislcap/foundation-ai": "15.10.3",
89
- "@genesislcap/foundation-logger": "15.10.3",
90
- "@genesislcap/foundation-notifications": "15.10.3",
91
- "@genesislcap/foundation-redux": "15.10.3",
92
- "@genesislcap/foundation-ui": "15.10.3",
93
- "@genesislcap/foundation-utils": "15.10.3",
94
- "@genesislcap/rapid-design-system": "15.10.3",
95
- "@genesislcap/web-core": "15.10.3",
88
+ "@genesislcap/foundation-ai": "15.10.4",
89
+ "@genesislcap/foundation-logger": "15.10.4",
90
+ "@genesislcap/foundation-notifications": "15.10.4",
91
+ "@genesislcap/foundation-redux": "15.10.4",
92
+ "@genesislcap/foundation-ui": "15.10.4",
93
+ "@genesislcap/foundation-utils": "15.10.4",
94
+ "@genesislcap/rapid-design-system": "15.10.4",
95
+ "@genesislcap/web-core": "15.10.4",
96
96
  "dompurify": "^3.3.1",
97
97
  "marked": "^17.0.3"
98
98
  },
@@ -105,5 +105,5 @@
105
105
  "access": "public"
106
106
  },
107
107
  "customElements": "dist/custom-elements.json",
108
- "gitHead": "6e5c9bb135b3da6792c4c3a8e632b09b0bc3fbca"
108
+ "gitHead": "2747f342a1681832c041ac612b854039ef908ab7"
109
109
  }
@@ -3,6 +3,7 @@ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
3
  import {
4
4
  applyCondensation,
5
5
  CONDENSE_MIN_CHARS,
6
+ CONDENSED_ARGS_KEY,
6
7
  type CondenseContext,
7
8
  type CondensedEventDetail,
8
9
  type RegisteredCondensePolicy,
@@ -193,7 +194,15 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
193
194
  const { on } = sink();
194
195
  const out = applyCondensation(history, policies, ctx(), on);
195
196
 
196
- assert.is(out[1].toolResult!.content, '[elided]', 'drop is a minimal non-empty placeholder');
197
+ // `drop` differs from `pointer` ONLY by the restore promise — it still identifies the call,
198
+ // because a stub too bare to say what the call did is how GENC-1494 started.
199
+ assert.match(out[1].toolResult!.content, /vfs_read/, 'drop names the tool');
200
+ assert.match(out[1].toolResult!.content, /result elided/, 'drop says what was elided');
201
+ assert.not.match(
202
+ out[1].toolResult!.content,
203
+ /re-call to restore/,
204
+ 'drop makes no restore promise',
205
+ );
197
206
  assert.match(
198
207
  out[5].toolResult!.content,
199
208
  /re-call to restore/,
@@ -202,7 +211,12 @@ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
202
211
  assert.is(out[9].toolResult!.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
203
212
  });
204
213
 
205
- suite('args drop yields an empty object; pointer stashes a stub under one key', () => {
214
+ // GENC-1494: dropped args must NEVER collapse to `{}`. A bare empty object is
215
+ // indistinguishable from a call the model made with no arguments at all, and the model
216
+ // treats its own history as fact — a ui-builder run read its elided `vfs_write` calls as
217
+ // proof it was writing files with no content and re-wrote them for two hours. A bare marker
218
+ // is not enough either: `path` goes with the args, so the stub must still say WHICH file.
219
+ suite('args drop identifies the call; pointer stashes a stub under one key', () => {
206
220
  const history: ChatMessage[] = [
207
221
  asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
208
222
  toolMsg('w1', 'ok'),
@@ -215,10 +229,58 @@ suite('args drop yields an empty object; pointer stashes a stub under one key',
215
229
  ]);
216
230
  const { on } = sink();
217
231
  const out = applyCondensation(history, policies, ctx(), on);
218
- assert.equal(out[0].toolCalls![0].args, {}, 'dropped args is an empty object');
232
+ const dropped = out[0].toolCalls![0].args;
233
+ const stub = String(dropped[CONDENSED_ARGS_KEY]);
234
+
235
+ assert.ok(
236
+ Object.keys(dropped).length > 0,
237
+ 'dropped args are never empty — that reads as a no-argument call',
238
+ );
239
+ assert.match(stub, /vfs_write/, 'stub names the tool');
240
+ assert.match(stub, /\bA\b/, 'stub names the superseded key — which file this call wrote');
241
+ assert.not.match(stub, /re-call to restore/, 'drop must not promise a restore it cannot honour');
219
242
  assert.equal(out[2].toolCalls![0].args, { path: 'A', content: big() }, 'latest write kept');
220
243
  });
221
244
 
245
+ // GENC-1494: trigger resolution picks the first LISTED trigger that is currently true, so it
246
+ // used to change after the collapse — with the usual `on: [{superseded}, {agentEnd}]`, a payload
247
+ // collapsed at `agentEnd` re-resolved to `superseded` the moment a newer same-key call landed,
248
+ // rewriting the stub and breaking the prompt cache a second time for an already-collapsed
249
+ // payload. In one reported session that was 22/22 args collapses and 97 response collapses.
250
+ suite('the fired trigger is latched, so a collapsed stub is never rewritten', () => {
251
+ const history: ChatMessage[] = [asstCall('r1', 'vfs_read', { path: 'A' }), toolMsg('r1', big())];
252
+ const entry = reg({
253
+ on: [{ kind: 'superseded', by: 'A' }, { kind: 'agentEnd' }],
254
+ response: 'pointer', // pointer embeds the reason, so a re-resolve is visible in the text
255
+ });
256
+ const policies = new Map<string, RegisteredCondensePolicy>([['r1', entry]]);
257
+ const { events, on } = sink();
258
+
259
+ // Pass 1: the activation has ended but nothing supersedes r1 yet → collapses as agentEnd.
260
+ const first = applyCondensation(history, policies, ctx({ activationEnded: () => true }), on);
261
+ assert.match(first[1].toolResult!.content, /agent finished/, 'collapses under agentEnd');
262
+ assert.is(events.length, 1);
263
+ assert.is(events[0].trigger, 'agentEnd');
264
+
265
+ // Pass 2: a newer read of the SAME key now exists, which would re-resolve the trigger to
266
+ // `superseded` (it is listed first). The already-collapsed stub must not change.
267
+ const grown: ChatMessage[] = [
268
+ ...history,
269
+ asstCall('r2', 'vfs_read', { path: 'A' }),
270
+ toolMsg('r2', big()),
271
+ ];
272
+ policies.set('r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' }));
273
+ const second = applyCondensation(grown, policies, ctx({ activationEnded: () => true }), on);
274
+
275
+ assert.match(second[1].toolResult!.content, /agent finished/, 'reason stays pinned to agentEnd');
276
+ assert.not.match(second[1].toolResult!.content, /superseded/, 'must not re-resolve');
277
+ assert.is(
278
+ second[1].toolResult!.content,
279
+ first[1].toolResult!.content,
280
+ 'stub text is byte-stable',
281
+ );
282
+ });
283
+
222
284
  // ---------------------------------------------------------------------------
223
285
  // envelope + metadata invariants
224
286
  // ---------------------------------------------------------------------------
@@ -56,6 +56,28 @@ export interface RegisteredCondensePolicy {
56
56
  reportedArgs?: boolean;
57
57
  /** `context.condensed` already emitted for the response payload (fire-once). */
58
58
  reportedResponse?: boolean;
59
+ /**
60
+ * The first trigger to fire for this call, latched on the pass it fired.
61
+ *
62
+ * "Fired", not "collapsed": it is written during trigger resolution, before the
63
+ * per-payload size floor is applied, so an entry whose payloads are all below the floor
64
+ * can latch without anything ever rendering. That is deliberate and safe — both payloads
65
+ * of a call resolve off the same clocks in the same pass, so they can never disagree
66
+ * about which trigger fired, and a payload's size is fixed for the life of the call
67
+ * (condensation never touches stored history), so a below-floor payload can never later
68
+ * become a large one under a stale reason.
69
+ *
70
+ * Trigger resolution picks the first LISTED trigger that is *currently* true, so without
71
+ * this it re-evaluates on every provider call and can change after the collapse: with the
72
+ * usual `on: [{superseded}, {agentEnd}]`, a payload collapsed at `agentEnd` silently
73
+ * re-resolves to `superseded` as soon as a newer call on the same key lands. Any stub that
74
+ * embeds the reason (`pointer` does) is then rewritten — an in-place history mutation that
75
+ * invalidates the prompt cache from that position a second time, for a payload that had
76
+ * already collapsed. Latching keeps the rendered stub stable, which is sound because a
77
+ * collapse is monotonic and never reverts, so the first trigger to fire is the correct
78
+ * one to keep.
79
+ */
80
+ firedTrigger?: CondenseTrigger;
59
81
  }
60
82
 
61
83
  /**
@@ -131,16 +153,27 @@ function triggerReason(trigger: CondenseTrigger): string {
131
153
  }
132
154
  }
133
155
 
134
- /** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
156
+ /**
157
+ * Framework-generated stub, shared by `pointer` and `drop`. Names the tool, the superseded key
158
+ * (the only surviving identifier once args are elided — the model cannot otherwise tell WHICH
159
+ * file a collapsed `vfs_write` wrote) and the original size.
160
+ *
161
+ * `restorable` is the ONLY difference between the two modes: `pointer` promises that re-calling
162
+ * reproduces the content and is valid solely for idempotent tools; `drop` makes no such promise
163
+ * and must not carry the clause. Identifying information is not what separates them — a stub too
164
+ * bare to say what the call did is how GENC-1494 started.
165
+ */
135
166
  function condenseStub(
136
167
  target: 'args' | 'response',
137
168
  tool: string,
138
169
  trigger: CondenseTrigger,
139
170
  origLen: number,
171
+ restorable: boolean,
140
172
  ): string {
141
173
  const what = target === 'args' ? 'args' : 'result';
142
174
  const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
143
- return `[${tool}${key} ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
175
+ const restore = restorable ? '; re-call to restore' : '';
176
+ return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)})${restore}]`;
144
177
  }
145
178
 
146
179
  /** Stable label for the `context.condensed` meta-event's `trigger` field. */
@@ -181,10 +214,12 @@ function marksDiscontinuity(kind: CondenseTrigger['kind']): boolean {
181
214
  * Collapse stale tool payloads (declared via `condenseWhen`) out of the
182
215
  * model-bound history. Pure over the `history` array — it emits new message
183
216
  * objects and never mutates the input messages (mirroring the agent-masking
184
- * transform). It DOES, however, flip the `reported*` flag on the matching
185
- * `policies` entry the first time a payload collapses the report-once gate that
186
- * keeps `onCondensed` firing once per (tool call, payload) even though the
187
- * collapse itself re-runs before every provider call.
217
+ * transform). It DOES, however, write two latches onto the matching `policies`
218
+ * entry the first time a payload collapses, both because the collapse itself
219
+ * re-runs before every provider call: the `reported*` flag (the report-once gate
220
+ * that keeps `onCondensed` firing once per (tool call, payload)) and
221
+ * `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
222
+ * once and never rewritten).
188
223
  *
189
224
  * Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
190
225
  * collapses the payload; all are monotonic, so the collapse never reverts):
@@ -284,14 +319,24 @@ export function applyCondensation(
284
319
 
285
320
  // The first listed trigger that fires — drives the collapse, the stub reason,
286
321
  // and the event label. `undefined` means the payload stays full.
322
+ //
323
+ // Latched on first fire (see `firedTrigger`): re-resolving on every pass lets the reason
324
+ // change under a payload that has already collapsed, which rewrites any reason-bearing stub
325
+ // and costs a second cache break. Both payloads of a call share the latch — they are
326
+ // evaluated in the same pass off the same clocks, so they never disagree about which
327
+ // trigger fired; only the size floor decides whether each one renders.
287
328
  const firstFired = (
288
329
  toolCallId: string,
289
330
  entry: RegisteredCondensePolicy,
290
- ): CondenseTrigger | undefined =>
291
- triggersOf(entry).find(
331
+ ): CondenseTrigger | undefined => {
332
+ if (entry.firedTrigger) return entry.firedTrigger;
333
+ const fired = triggersOf(entry).find(
292
334
  (t) =>
293
335
  (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t),
294
336
  );
337
+ if (fired) entry.firedTrigger = fired;
338
+ return fired;
339
+ };
295
340
 
296
341
  return history.map((msg) => {
297
342
  // Tool-call ARGS live on the assistant message.
@@ -306,18 +351,24 @@ export function applyCondensation(
306
351
  if (origLen < CONDENSE_MIN_CHARS) return tc;
307
352
  changed = true;
308
353
  const result = entry.policy.args;
309
- // Args must stay a Record; `drop` is an empty object, otherwise stash the
310
- // stub under a single key. Spread `tc` so providerMetadata (e.g. the
311
- // Gemini reasoning signature that must round-trip) and UI fields survive.
312
- const args: Record<string, unknown> =
313
- result === 'drop'
314
- ? {}
315
- : {
316
- [CONDENSED_ARGS_KEY]:
317
- result === 'pointer'
318
- ? condenseStub('args', tc.name, fired, origLen)
319
- : result.replaceWith,
320
- };
354
+ // Args must stay a Record, so every mode stashes its stub under a single key.
355
+ // Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
356
+ // round-trip) and UI fields survive.
357
+ //
358
+ // GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
359
+ // from a call the model made with NO arguments, and the model reads its own history
360
+ // as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
361
+ // a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
362
+ // without content, and burned two hours re-writing files that were already correct.
363
+ // A bare marker is not enough either — it still fails to say WHICH file the call
364
+ // wrote, since `path` went with the args. So `drop` renders the same identifying stub
365
+ // as `pointer`, minus only the restore promise it cannot honour.
366
+ const args: Record<string, unknown> = {
367
+ [CONDENSED_ARGS_KEY]:
368
+ result === 'drop' || result === 'pointer'
369
+ ? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
370
+ : result.replaceWith,
371
+ };
321
372
  if (!entry.reportedArgs) {
322
373
  entry.reportedArgs = true;
323
374
  const stubLen = JSON.stringify(args).length;
@@ -350,11 +401,9 @@ export function applyCondensation(
350
401
  const result = entry.policy.response!; // `fired` is only set when response is declared
351
402
  // Kept non-empty — Anthropic rejects empty tool_result content.
352
403
  const content =
353
- result === 'drop'
354
- ? '[elided]'
355
- : result === 'pointer'
356
- ? condenseStub('response', tool, fired, origLen)
357
- : result.replaceWith;
404
+ result === 'drop' || result === 'pointer'
405
+ ? condenseStub('response', tool, fired, origLen, result === 'pointer')
406
+ : result.replaceWith;
358
407
  if (!entry.reportedResponse) {
359
408
  entry.reportedResponse = true;
360
409
  onCondensed({