@genesislcap/ai-assistant 14.467.2 → 14.468.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.
@@ -0,0 +1,297 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { applyCondensation, CONDENSE_MIN_CHARS, } from './condense-history';
3
+ const suite = createLogicSuite('applyCondensation');
4
+ // Payloads must clear the driver-level floor to be condensed.
5
+ const big = (n = CONDENSE_MIN_CHARS + 500) => 'x'.repeat(n);
6
+ const asstCall = (id, name, args = {}, extra = {}) => ({ role: 'assistant', content: '', toolCalls: [Object.assign({ id, name, args }, extra)] });
7
+ const toolMsg = (toolCallId, content) => ({
8
+ role: 'tool',
9
+ content: '',
10
+ toolResult: { toolCallId, content },
11
+ });
12
+ const reg = (policy, clocks = {}) => {
13
+ var _a, _b, _c;
14
+ return ({
15
+ policy,
16
+ iteration: (_a = clocks.iteration) !== null && _a !== void 0 ? _a : 1,
17
+ turn: (_b = clocks.turn) !== null && _b !== void 0 ? _b : 1,
18
+ activation: (_c = clocks.activation) !== null && _c !== void 0 ? _c : 1,
19
+ });
20
+ };
21
+ /** A CondenseContext for the pure transform. Defaults fire nothing; override per test. */
22
+ const ctx = (o = {}) => {
23
+ var _a, _b, _c;
24
+ return ({
25
+ modelCall: (_a = o.modelCall) !== null && _a !== void 0 ? _a : 0,
26
+ turn: (_b = o.turn) !== null && _b !== void 0 ? _b : 0,
27
+ activationEnded: (_c = o.activationEnded) !== null && _c !== void 0 ? _c : (() => false),
28
+ });
29
+ };
30
+ const sink = () => {
31
+ const events = [];
32
+ return { events, on: (d) => events.push(d) };
33
+ };
34
+ // ---------------------------------------------------------------------------
35
+ // supersession
36
+ // ---------------------------------------------------------------------------
37
+ suite('superseded response: collapses the earlier call, keeps the latest full', () => {
38
+ const history = [
39
+ asstCall('r1', 'vfs_read', { path: 'A' }),
40
+ toolMsg('r1', big()),
41
+ asstCall('r2', 'vfs_read', { path: 'A' }),
42
+ toolMsg('r2', big()),
43
+ ];
44
+ const policies = new Map([
45
+ ['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
46
+ ['r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
47
+ ]);
48
+ const { events, on } = sink();
49
+ const out = applyCondensation(history, policies, ctx(), on);
50
+ assert.match(out[1].toolResult.content, /re-call to restore/);
51
+ assert.is(out[3].toolResult.content, big(), 'latest call for the key stays full');
52
+ // Exactly one transition reported, for the earlier call's response.
53
+ assert.is(events.length, 1);
54
+ assert.is(events[0].toolCallId, 'r1');
55
+ assert.is(events[0].target, 'response');
56
+ assert.is(events[0].trigger, 'superseded:A');
57
+ // Input is never mutated; unchanged messages keep their identity.
58
+ assert.is(history[1].toolResult.content, big(), 'stored history untouched');
59
+ assert.is(out[3], history[3], 'unchanged message reuses the same object');
60
+ assert.ok(out[1] !== history[1], 'collapsed message is a new object');
61
+ });
62
+ suite('superseded crosses tools: a read collapses the earlier write args of the same key', () => {
63
+ const history = [
64
+ asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
65
+ toolMsg('w1', 'written'),
66
+ asstCall('r1', 'vfs_read', { path: 'A' }),
67
+ toolMsg('r1', big()),
68
+ ];
69
+ const policies = new Map([
70
+ ['w1', reg({ on: { kind: 'superseded', by: 'A' }, args: 'pointer' })],
71
+ ['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
72
+ ]);
73
+ const { events, on } = sink();
74
+ const out = applyCondensation(history, policies, ctx(), on);
75
+ // Earlier write is superseded by the later read of the same key → its args collapse.
76
+ assert.match(String(out[0].toolCalls[0].args.condensed), /re-call to restore/);
77
+ // The read is the latest for key A → its response stays full.
78
+ assert.is(out[3].toolResult.content, big());
79
+ assert.is(events.length, 1);
80
+ assert.is(events[0].toolCallId, 'w1');
81
+ assert.is(events[0].target, 'args');
82
+ });
83
+ // ---------------------------------------------------------------------------
84
+ // age
85
+ // ---------------------------------------------------------------------------
86
+ suite('age: the model sees the result on `turns` turns, then it collapses', () => {
87
+ // Call made on iteration 1 → the result is first visible to the model on
88
+ // iteration 2. With turns:1 it may be read on that one turn, then collapses.
89
+ const history = [asstCall('g1', 'grep_source', {}), toolMsg('g1', big())];
90
+ const policies = new Map([
91
+ ['g1', reg({ on: { kind: 'age', turns: 1 }, response: 'pointer' }, { iteration: 1 })],
92
+ ]);
93
+ const { events, on } = sink();
94
+ // Iteration 2 — its one allowed look: still full, nothing reported.
95
+ const seen = applyCondensation(history, policies, ctx({ modelCall: 2 }), on);
96
+ assert.is(seen[1].toolResult.content, big(), 'full on the one turn it may be seen');
97
+ assert.is(events.length, 0, 'not collapsed while the model may still see it');
98
+ // Iteration 3 — one turn past the allowed view: collapses.
99
+ const collapsed = applyCondensation(history, policies, ctx({ modelCall: 3 }), on);
100
+ assert.match(collapsed[1].toolResult.content, /aged out/);
101
+ assert.match(collapsed[1].toolResult.content, /re-call to restore/);
102
+ assert.is(events.length, 1);
103
+ assert.is(events[0].trigger, 'age:1');
104
+ });
105
+ // ---------------------------------------------------------------------------
106
+ // min-size floor
107
+ // ---------------------------------------------------------------------------
108
+ suite('payloads below the size floor are never condensed', () => {
109
+ const history = [
110
+ asstCall('r1', 'vfs_read', { path: 'A' }),
111
+ toolMsg('r1', 'tiny'),
112
+ asstCall('r2', 'vfs_read', { path: 'A' }),
113
+ toolMsg('r2', big()),
114
+ ];
115
+ const policies = new Map([
116
+ ['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
117
+ ['r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
118
+ ]);
119
+ const { events, on } = sink();
120
+ const out = applyCondensation(history, policies, ctx(), on);
121
+ assert.is(out[1].toolResult.content, 'tiny', 'superseded but too small to bother');
122
+ assert.is(events.length, 0);
123
+ });
124
+ // ---------------------------------------------------------------------------
125
+ // result modes
126
+ // ---------------------------------------------------------------------------
127
+ suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
128
+ const mk = (id, result) => [
129
+ asstCall(id, 'vfs_read', { path: id }),
130
+ toolMsg(id, big()),
131
+ ];
132
+ // Each call is superseded by a later same-key call so it actually fires.
133
+ const history = [
134
+ ...mk('drop', 'drop'),
135
+ asstCall('drop2', 'vfs_read', { path: 'drop' }),
136
+ toolMsg('drop2', big()),
137
+ ...mk('ptr', 'pointer'),
138
+ asstCall('ptr2', 'vfs_read', { path: 'ptr' }),
139
+ toolMsg('ptr2', big()),
140
+ ...mk('rep', { replaceWith: 'SUMMARY' }),
141
+ asstCall('rep2', 'vfs_read', { path: 'rep' }),
142
+ toolMsg('rep2', big()),
143
+ ];
144
+ const policies = new Map([
145
+ ['drop', reg({ on: { kind: 'superseded', by: 'drop' }, response: 'drop' })],
146
+ ['drop2', reg({ on: { kind: 'superseded', by: 'drop' }, response: 'drop' })],
147
+ ['ptr', reg({ on: { kind: 'superseded', by: 'ptr' }, response: 'pointer' })],
148
+ ['ptr2', reg({ on: { kind: 'superseded', by: 'ptr' }, response: 'pointer' })],
149
+ ['rep', reg({ on: { kind: 'superseded', by: 'rep' }, response: { replaceWith: 'SUMMARY' } })],
150
+ ['rep2', reg({ on: { kind: 'superseded', by: 'rep' }, response: { replaceWith: 'SUMMARY' } })],
151
+ ]);
152
+ const { on } = sink();
153
+ const out = applyCondensation(history, policies, ctx(), on);
154
+ assert.is(out[1].toolResult.content, '[elided]', 'drop is a minimal non-empty placeholder');
155
+ assert.match(out[5].toolResult.content, /re-call to restore/, 'pointer carries the restore hint');
156
+ assert.is(out[9].toolResult.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
157
+ });
158
+ suite('args drop yields an empty object; pointer stashes a stub under one key', () => {
159
+ const history = [
160
+ asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
161
+ toolMsg('w1', 'ok'),
162
+ asstCall('w2', 'vfs_write', { path: 'A', content: big() }),
163
+ toolMsg('w2', 'ok'),
164
+ ];
165
+ const policies = new Map([
166
+ ['w1', reg({ on: { kind: 'superseded', by: 'A' }, args: 'drop' })],
167
+ ['w2', reg({ on: { kind: 'superseded', by: 'A' }, args: 'drop' })],
168
+ ]);
169
+ const { on } = sink();
170
+ const out = applyCondensation(history, policies, ctx(), on);
171
+ assert.equal(out[0].toolCalls[0].args, {}, 'dropped args is an empty object');
172
+ assert.equal(out[2].toolCalls[0].args, { path: 'A', content: big() }, 'latest write kept');
173
+ });
174
+ // ---------------------------------------------------------------------------
175
+ // envelope + metadata invariants
176
+ // ---------------------------------------------------------------------------
177
+ suite('collapsing args preserves the tool-call envelope and providerMetadata', () => {
178
+ const history = [
179
+ asstCall('w1', 'vfs_write', { path: 'A', content: big() }, { providerMetadata: { sig: 'keep' } }),
180
+ toolMsg('w1', 'ok'),
181
+ asstCall('w2', 'vfs_write', { path: 'A', content: big() }),
182
+ toolMsg('w2', 'ok'),
183
+ ];
184
+ const policies = new Map([
185
+ ['w1', reg({ on: { kind: 'superseded', by: 'A' }, args: 'pointer' })],
186
+ ['w2', reg({ on: { kind: 'superseded', by: 'A' }, args: 'pointer' })],
187
+ ]);
188
+ const { on } = sink();
189
+ const out = applyCondensation(history, policies, ctx(), on);
190
+ const tc = out[0].toolCalls[0];
191
+ assert.is(tc.id, 'w1', 'id preserved');
192
+ assert.is(tc.name, 'vfs_write', 'name preserved');
193
+ assert.equal(tc.providerMetadata, { sig: 'keep' }, 'reasoning signature round-trips');
194
+ });
195
+ // ---------------------------------------------------------------------------
196
+ // report-once + no-op
197
+ // ---------------------------------------------------------------------------
198
+ suite('the event fires once per payload across repeated applies', () => {
199
+ const history = [
200
+ asstCall('r1', 'vfs_read', { path: 'A' }),
201
+ toolMsg('r1', big()),
202
+ asstCall('r2', 'vfs_read', { path: 'A' }),
203
+ toolMsg('r2', big()),
204
+ ];
205
+ const policies = new Map([
206
+ ['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
207
+ ['r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
208
+ ]);
209
+ const { events, on } = sink();
210
+ applyCondensation(history, policies, ctx(), on);
211
+ applyCondensation(history, policies, ctx(), on);
212
+ applyCondensation(history, policies, ctx(), on);
213
+ assert.is(events.length, 1, 'collapse re-runs every call but the transition is reported once');
214
+ });
215
+ suite('an empty registry is a pure pass-through (same array reference)', () => {
216
+ const history = [asstCall('r1', 'vfs_read'), toolMsg('r1', big())];
217
+ const { events, on } = sink();
218
+ const out = applyCondensation(history, new Map(), ctx(), on);
219
+ assert.is(out, history);
220
+ assert.is(events.length, 0);
221
+ });
222
+ // ---------------------------------------------------------------------------
223
+ // turnEnd
224
+ // ---------------------------------------------------------------------------
225
+ suite('turnEnd: full during its own turn, collapsed on a later turn', () => {
226
+ const history = [asstCall('t1', 'read'), toolMsg('t1', big())];
227
+ const policies = new Map([
228
+ ['t1', reg({ on: { kind: 'turnEnd' }, response: 'pointer' }, { turn: 1 })],
229
+ ]);
230
+ const { events, on } = sink();
231
+ const sameTurn = applyCondensation(history, policies, ctx({ turn: 1 }), on);
232
+ assert.is(sameTurn[1].toolResult.content, big(), 'full for the rest of the request');
233
+ assert.is(events.length, 0);
234
+ const laterTurn = applyCondensation(history, policies, ctx({ turn: 2 }), on);
235
+ assert.match(laterTurn[1].toolResult.content, /turn ended/);
236
+ assert.is(events.length, 1);
237
+ assert.is(events[0].trigger, 'turnEnd');
238
+ });
239
+ // ---------------------------------------------------------------------------
240
+ // agentEnd
241
+ // ---------------------------------------------------------------------------
242
+ suite('agentEnd: kept while the agent is active, collapsed once its activation ends', () => {
243
+ const history = [asstCall('a1', 'load'), toolMsg('a1', big())];
244
+ const policies = new Map([
245
+ ['a1', reg({ on: { kind: 'agentEnd' }, response: 'pointer' }, { activation: 1 })],
246
+ ]);
247
+ const { events, on } = sink();
248
+ const active = applyCondensation(history, policies, ctx({ activationEnded: () => false }), on);
249
+ assert.is(active[1].toolResult.content, big(), 'full while the agent flow is active');
250
+ assert.is(events.length, 0);
251
+ const ended = applyCondensation(history, policies, ctx({ activationEnded: (a) => a === 1 }), on);
252
+ assert.match(ended[1].toolResult.content, /agent finished/);
253
+ assert.is(events.length, 1);
254
+ assert.is(events[0].trigger, 'agentEnd');
255
+ });
256
+ // ---------------------------------------------------------------------------
257
+ // multi-trigger (OR / first-wins)
258
+ // ---------------------------------------------------------------------------
259
+ suite('multi-trigger: collapses when ANY listed trigger fires', () => {
260
+ // superseded OR turnEnd — only one call for key A (never superseded), but the turn ends.
261
+ const history = [asstCall('m1', 'read', { path: 'A' }), toolMsg('m1', big())];
262
+ const policies = new Map([
263
+ [
264
+ 'm1',
265
+ reg({ on: [{ kind: 'superseded', by: 'A' }, { kind: 'turnEnd' }], response: 'pointer' }, { turn: 1 }),
266
+ ],
267
+ ]);
268
+ const { events, on } = sink();
269
+ assert.is(applyCondensation(history, policies, ctx({ turn: 1 }), on)[1].toolResult.content, big(), 'neither trigger has fired');
270
+ const later = applyCondensation(history, policies, ctx({ turn: 2 }), on);
271
+ assert.match(later[1].toolResult.content, /turn ended/, 'turnEnd fired via the OR');
272
+ assert.is(events[0].trigger, 'turnEnd');
273
+ });
274
+ suite('multi-trigger first-wins: the earliest-listed firing trigger is the reason', () => {
275
+ var _a, _b;
276
+ // m1 is superseded by m2 AND its turn has ended; m2 is the latest (not superseded)
277
+ // but its turn has ended too. `superseded` is listed first.
278
+ const history = [
279
+ asstCall('m1', 'read', { path: 'A' }),
280
+ toolMsg('m1', big()),
281
+ asstCall('m2', 'read', { path: 'A' }),
282
+ toolMsg('m2', big()),
283
+ ];
284
+ const on0 = {
285
+ on: [{ kind: 'superseded', by: 'A' }, { kind: 'turnEnd' }],
286
+ response: 'pointer',
287
+ };
288
+ const policies = new Map([
289
+ ['m1', reg(on0, { turn: 1 })],
290
+ ['m2', reg(on0, { turn: 1 })],
291
+ ]);
292
+ const { events, on } = sink();
293
+ applyCondensation(history, policies, ctx({ turn: 2 }), on);
294
+ assert.is((_a = events.find((e) => e.toolCallId === 'm1')) === null || _a === void 0 ? void 0 : _a.trigger, 'superseded:A', 'first-listed firing trigger wins as the reason');
295
+ assert.is((_b = events.find((e) => e.toolCallId === 'm2')) === null || _b === void 0 ? void 0 : _b.trigger, 'turnEnd', 'the supersession survivor still collapses via the next trigger');
296
+ });
297
+ suite.run();
@@ -1 +1 @@
1
- {"root":["../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/flowing-waves-indicator.ts","../src/components/halo-overlay.ts","../src/components/plasma-orb-indicator.ts","../src/components/waves-indicator.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/align-event-globals.ts","../src/components/chat-driver/chat-driver.test.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/config/config.ts","../src/config/define-stateful-agent.test.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/index.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/state/ai-assistant-slice.ts","../src/state/debug-event-log.test.ts","../src/state/debug-event-log.ts","../src/state/driver-registry.ts","../src/state/session-store.ts","../src/styles/ai-colours.ts","../src/styles/index.ts","../src/styles/styles.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/utils/animated-panel-toggle.ts","../src/utils/animation-exclusivity.test.ts","../src/utils/animation-exclusivity.ts","../src/utils/flatten-sub-agent-messages.test.ts","../src/utils/flatten-sub-agent-messages.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/message-partition.test.ts","../src/utils/message-partition.ts","../src/utils/strip-agent-handlers.test.ts","../src/utils/strip-agent-handlers.ts","../src/utils/sum-costs.test.ts","../src/utils/sum-costs.ts","../src/utils/tool-fold.ts"],"version":"5.9.2"}
1
+ {"root":["../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/flowing-waves-indicator.ts","../src/components/halo-overlay.ts","../src/components/plasma-orb-indicator.ts","../src/components/waves-indicator.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/align-event-globals.ts","../src/components/chat-driver/chat-driver.test.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/config/config.ts","../src/config/define-stateful-agent.test.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/index.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/state/ai-assistant-slice.ts","../src/state/debug-event-log.test.ts","../src/state/debug-event-log.ts","../src/state/driver-registry.ts","../src/state/session-store.ts","../src/styles/ai-colours.ts","../src/styles/index.ts","../src/styles/styles.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/utils/animated-panel-toggle.ts","../src/utils/animation-exclusivity.test.ts","../src/utils/animation-exclusivity.ts","../src/utils/condense-history.test.ts","../src/utils/condense-history.ts","../src/utils/flatten-sub-agent-messages.test.ts","../src/utils/flatten-sub-agent-messages.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/message-partition.test.ts","../src/utils/message-partition.ts","../src/utils/strip-agent-handlers.test.ts","../src/utils/strip-agent-handlers.ts","../src/utils/sum-costs.test.ts","../src/utils/sum-costs.ts","../src/utils/tool-fold.ts"],"version":"5.9.2"}
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": "14.467.2",
4
+ "version": "14.468.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,24 +64,24 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.467.2",
68
- "@genesislcap/genx": "14.467.2",
69
- "@genesislcap/rollup-builder": "14.467.2",
70
- "@genesislcap/ts-builder": "14.467.2",
71
- "@genesislcap/uvu-playwright-builder": "14.467.2",
72
- "@genesislcap/vite-builder": "14.467.2",
73
- "@genesislcap/webpack-builder": "14.467.2",
67
+ "@genesislcap/foundation-testing": "14.468.0",
68
+ "@genesislcap/genx": "14.468.0",
69
+ "@genesislcap/rollup-builder": "14.468.0",
70
+ "@genesislcap/ts-builder": "14.468.0",
71
+ "@genesislcap/uvu-playwright-builder": "14.468.0",
72
+ "@genesislcap/vite-builder": "14.468.0",
73
+ "@genesislcap/webpack-builder": "14.468.0",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.467.2",
79
- "@genesislcap/foundation-logger": "14.467.2",
80
- "@genesislcap/foundation-redux": "14.467.2",
81
- "@genesislcap/foundation-ui": "14.467.2",
82
- "@genesislcap/foundation-utils": "14.467.2",
83
- "@genesislcap/rapid-design-system": "14.467.2",
84
- "@genesislcap/web-core": "14.467.2",
78
+ "@genesislcap/foundation-ai": "14.468.0",
79
+ "@genesislcap/foundation-logger": "14.468.0",
80
+ "@genesislcap/foundation-redux": "14.468.0",
81
+ "@genesislcap/foundation-ui": "14.468.0",
82
+ "@genesislcap/foundation-utils": "14.468.0",
83
+ "@genesislcap/rapid-design-system": "14.468.0",
84
+ "@genesislcap/web-core": "14.468.0",
85
85
  "dompurify": "^3.3.1",
86
86
  "marked": "^17.0.3"
87
87
  },
@@ -93,5 +93,5 @@
93
93
  "publishConfig": {
94
94
  "access": "public"
95
95
  },
96
- "gitHead": "2edbf1311578ebd00440aacdd362b30411428923"
96
+ "gitHead": "0dfb3e9bf917a6de7e74e52d86995cfcd85f3110"
97
97
  }
@@ -38,6 +38,8 @@ interface ScriptedProvider extends AIProvider {
38
38
  toolChoicePerCall: Array<ChatToolChoice | undefined>;
39
39
  /** `temperature` seen on each `chat()` call, in order. */
40
40
  temperaturePerCall: Array<number | undefined>;
41
+ /** The model-bound history slice seen on each `chat()` call (post-transform). */
42
+ historyPerCall: ChatMessage[][];
41
43
  }
42
44
 
43
45
  const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
@@ -45,18 +47,23 @@ const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
45
47
  const advertisedPerCall: string[][] = [];
46
48
  const toolChoicePerCall: Array<ChatToolChoice | undefined> = [];
47
49
  const temperaturePerCall: Array<number | undefined> = [];
50
+ const historyPerCall: ChatMessage[][] = [];
48
51
  return {
49
52
  advertisedPerCall,
50
53
  toolChoicePerCall,
51
54
  temperaturePerCall,
55
+ historyPerCall,
52
56
  chat: async (
53
- _history: ChatMessage[],
57
+ history: ChatMessage[],
54
58
  _userMessage: string,
55
59
  options?: ChatRequestOptions,
56
60
  ): Promise<ChatMessage> => {
57
61
  advertisedPerCall.push((options?.tools ?? []).map((t) => t.name));
58
62
  toolChoicePerCall.push(options?.toolChoice);
59
63
  temperaturePerCall.push(options?.temperature);
64
+ // Snapshot the slice (condensation produces fresh objects per call and never
65
+ // mutates stored history, so a shallow array copy is a stable per-call view).
66
+ historyPerCall.push([...history]);
60
67
  // Once the script is exhausted, end the turn with a plain text reply.
61
68
  return queue.shift() ?? { role: 'assistant', content: 'done' };
62
69
  },
@@ -1648,3 +1655,228 @@ modelAttr('omits the model key entirely when the provider reports no status', as
1648
1655
  });
1649
1656
 
1650
1657
  modelAttr.run();
1658
+
1659
+ // ---------------------------------------------------------------------------
1660
+ // condenseWhen — tool-declared context condensation (wiring through the loop)
1661
+ // ---------------------------------------------------------------------------
1662
+
1663
+ const condense = createLogicSuite('ChatDriver condenseWhen');
1664
+ condense.after(() => {
1665
+ agenticActivityBus.close();
1666
+ });
1667
+
1668
+ const bigBody = 'A'.repeat(2000);
1669
+
1670
+ /** An assistant turn calling `read` with a path arg (so `by: args.path` resolves). */
1671
+ const readsPath = (id: string, path: string): ChatMessage => ({
1672
+ role: 'assistant',
1673
+ content: '',
1674
+ toolCalls: [{ id, name: 'read', args: { path } }],
1675
+ });
1676
+
1677
+ condense(
1678
+ 'collapses a superseded result in the model slice but keeps stored history full',
1679
+ async () => {
1680
+ const sessionKey = 'condense-supersede';
1681
+ clearMetaEventRegistry();
1682
+ const provider = scriptedProvider([
1683
+ readsPath('r1', 'A'),
1684
+ readsPath('r2', 'A'), // re-read of the same path supersedes r1
1685
+ ]);
1686
+ const config = agent({
1687
+ name: 'reader',
1688
+ systemPrompt: 'read files',
1689
+ toolDefinitions: [def('read')],
1690
+ toolHandlers: {
1691
+ read: async (args, ctx) => {
1692
+ ctx.condenseWhen({
1693
+ on: { kind: 'superseded', by: String(args.path) },
1694
+ response: 'pointer',
1695
+ });
1696
+ return bigBody;
1697
+ },
1698
+ },
1699
+ });
1700
+ const driver = makeDriver(config, provider, sessionKey);
1701
+
1702
+ const result = await driver.sendMessage('go');
1703
+ assert.is(result.reason, 'done');
1704
+
1705
+ // The 3rd provider call (index 2) ran after both reads: r1 is superseded, r2 is latest.
1706
+ const slice = provider.historyPerCall[2];
1707
+ const r1 = slice.find((m) => m.toolResult?.toolCallId === 'r1');
1708
+ const r2 = slice.find((m) => m.toolResult?.toolCallId === 'r2');
1709
+ assert.match(
1710
+ r1!.toolResult!.content,
1711
+ /re-call to restore/,
1712
+ 'earlier read collapsed for the model',
1713
+ );
1714
+ assert.is(r2!.toolResult!.content, bigBody, 'latest read still full');
1715
+
1716
+ // Stored history is untouched — both reads remain full for the UI / debug export.
1717
+ assert.is(
1718
+ toolResultContents(driver).filter((c) => c === bigBody).length,
1719
+ 2,
1720
+ 'stored history retains both full reads',
1721
+ );
1722
+
1723
+ // One context.condensed meta-event, for r1's response, recorded once.
1724
+ const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
1725
+ assert.is(events.length, 1, 'the full→stub transition is reported exactly once');
1726
+ assert.is(events[0].detail?.toolCallId, 'r1');
1727
+ assert.is(events[0].detail?.target, 'response');
1728
+ },
1729
+ );
1730
+
1731
+ condense('first-wins: a second condenseWhen for the same tool call is ignored', async () => {
1732
+ const sessionKey = 'condense-first-wins';
1733
+ clearMetaEventRegistry();
1734
+ const provider = scriptedProvider([readsPath('r1', 'A'), readsPath('r2', 'A')]);
1735
+ const config = agent({
1736
+ name: 'reader',
1737
+ systemPrompt: 'read files',
1738
+ toolDefinitions: [def('read')],
1739
+ toolHandlers: {
1740
+ read: async (args, ctx) => {
1741
+ // First declaration wins; the second (a different mode) is dropped.
1742
+ ctx.condenseWhen({
1743
+ on: { kind: 'superseded', by: String(args.path) },
1744
+ response: 'pointer',
1745
+ });
1746
+ ctx.condenseWhen({
1747
+ on: { kind: 'superseded', by: String(args.path) },
1748
+ response: { replaceWith: 'SECOND' },
1749
+ });
1750
+ return bigBody;
1751
+ },
1752
+ },
1753
+ });
1754
+ const driver = makeDriver(config, provider, sessionKey);
1755
+ await driver.sendMessage('go');
1756
+
1757
+ const collapsed = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'r1');
1758
+ assert.match(
1759
+ collapsed!.toolResult!.content,
1760
+ /re-call to restore/,
1761
+ 'the first (pointer) policy applied',
1762
+ );
1763
+ assert.is.not(collapsed!.toolResult!.content, 'SECOND', 'the later policy was ignored');
1764
+ });
1765
+
1766
+ condense('age clock is monotonic across turns — collapses on a later, separate turn', async () => {
1767
+ // Regression guard for the per-turn-reset bug: the age clock is the local loop
1768
+ // counter that resets every `sendMessage`, so an `age` payload from turn 1
1769
+ // would never collapse across short turns. The driver now uses a monotonic
1770
+ // model-call counter, so it does.
1771
+ const sessionKey = 'condense-age-cross-turn';
1772
+ clearMetaEventRegistry();
1773
+ const provider = scriptedProvider([
1774
+ { role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
1775
+ { role: 'assistant', content: 'noted' }, // turn 1, call 2 — model sees n1 once (full), turn ends
1776
+ { role: 'assistant', content: 'ok' }, // turn 2, call 1 — a short follow-up, no tools
1777
+ ]);
1778
+ const config = agent({
1779
+ name: 'note-taker',
1780
+ systemPrompt: 'take notes',
1781
+ toolDefinitions: [def('notes')],
1782
+ toolHandlers: {
1783
+ notes: async (_args, ctx) => {
1784
+ ctx.condenseWhen({ on: { kind: 'age', turns: 1 }, response: 'pointer' });
1785
+ return bigBody;
1786
+ },
1787
+ },
1788
+ });
1789
+ const driver = makeDriver(config, provider, sessionKey);
1790
+
1791
+ await driver.sendMessage('first'); // turn 1
1792
+ await driver.sendMessage('second'); // turn 2 — a separate, short sendMessage
1793
+
1794
+ // Turn 1's consume call (provider call #1) saw n1 in full — its one allowed look.
1795
+ const turn1Consume = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 'n1');
1796
+ assert.is(turn1Consume!.toolResult!.content, bigBody, 'full on its one allowed turn');
1797
+
1798
+ // Turn 2's first call (#2) is a SEPARATE sendMessage — the local loop counter has
1799
+ // reset to 0, but the monotonic clock kept counting, so n1 is now collapsed.
1800
+ // Under the old per-turn counter it would still be full here.
1801
+ const turn2 = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'n1');
1802
+ assert.match(turn2!.toolResult!.content, /re-call to restore/, 'collapsed on the next turn');
1803
+
1804
+ assert.ok(toolResultContents(driver).includes(bigBody), 'stored history retains the full note');
1805
+ const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
1806
+ assert.is(events.length, 1);
1807
+ assert.is(events[0].detail?.trigger, 'age:1');
1808
+ });
1809
+
1810
+ condense('turnEnd: full during its request, collapsed on the next user turn', async () => {
1811
+ const sessionKey = 'condense-turn-end';
1812
+ clearMetaEventRegistry();
1813
+ const provider = scriptedProvider([
1814
+ { role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
1815
+ { role: 'assistant', content: 'noted' }, // turn 1, call 2 — n1 still full this turn
1816
+ { role: 'assistant', content: 'ok' }, // turn 2 — a separate request
1817
+ ]);
1818
+ const config = agent({
1819
+ name: 'note-taker',
1820
+ systemPrompt: 'take notes',
1821
+ toolDefinitions: [def('notes')],
1822
+ toolHandlers: {
1823
+ notes: async (_args, ctx) => {
1824
+ ctx.condenseWhen({ on: { kind: 'turnEnd' }, response: 'pointer' });
1825
+ return bigBody;
1826
+ },
1827
+ },
1828
+ });
1829
+ const driver = makeDriver(config, provider, sessionKey);
1830
+
1831
+ await driver.sendMessage('first'); // turn 1
1832
+ await driver.sendMessage('second'); // turn 2
1833
+
1834
+ const inTurn1 = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 'n1');
1835
+ assert.is(inTurn1!.toolResult!.content, bigBody, 'full for the rest of the request it served');
1836
+ const inTurn2 = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'n1');
1837
+ assert.match(inTurn2!.toolResult!.content, /re-call to restore/, 'collapsed once the turn ended');
1838
+
1839
+ const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
1840
+ assert.is(events.length, 1);
1841
+ assert.is(events[0].detail?.trigger, 'turnEnd');
1842
+ });
1843
+
1844
+ condense('agentEnd: collapses once the agent releases (flow complete)', async () => {
1845
+ const sessionKey = 'condense-agent-end';
1846
+ clearMetaEventRegistry();
1847
+ const provider = scriptedProvider([
1848
+ { role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
1849
+ { role: 'assistant', content: '', toolCalls: [{ id: 'f1', name: 'finish', args: {} }] }, // call 2 — releases
1850
+ { role: 'assistant', content: 'wrapped up' }, // call 3 — flow done
1851
+ ]);
1852
+ const config = agent({
1853
+ name: 'wizard',
1854
+ systemPrompt: 'a flow',
1855
+ toolDefinitions: [def('load'), def('finish')],
1856
+ toolHandlers: {
1857
+ load: async (_args, ctx) => {
1858
+ ctx.condenseWhen({ on: { kind: 'agentEnd' }, response: 'pointer' });
1859
+ return bigBody;
1860
+ },
1861
+ finish: async (_args, ctx) => {
1862
+ ctx.releaseAgent();
1863
+ return 'flow complete';
1864
+ },
1865
+ },
1866
+ });
1867
+ const driver = makeDriver(config, provider, sessionKey);
1868
+ await driver.sendMessage('run the flow');
1869
+
1870
+ // call #2 (index 1) ran before `finish` released — schema still full.
1871
+ const beforeRelease = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 's1');
1872
+ assert.is(beforeRelease!.toolResult!.content, bigBody, 'full while the flow is active');
1873
+ // call #3 (index 2) ran after release — schema collapsed.
1874
+ const afterRelease = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 's1');
1875
+ assert.match(afterRelease!.toolResult!.content, /re-call to restore/, 'collapsed once released');
1876
+
1877
+ const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
1878
+ assert.is(events.length, 1);
1879
+ assert.is(events[0].detail?.trigger, 'agentEnd');
1880
+ });
1881
+
1882
+ condense.run();