@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.
- package/dist/ai-assistant.d.ts +39 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +38 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/utils/condense-history.d.ts +115 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -0
- package/dist/dts/utils/condense-history.test.d.ts +2 -0
- package/dist/dts/utils/condense-history.test.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +108 -7
- package/dist/esm/components/chat-driver/chat-driver.test.js +196 -1
- package/dist/esm/state/debug-event-log.js +3 -2
- package/dist/esm/utils/condense-history.js +218 -0
- package/dist/esm/utils/condense-history.test.js +297 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -16
- package/src/components/chat-driver/chat-driver.test.ts +233 -1
- package/src/components/chat-driver/chat-driver.ts +120 -6
- package/src/state/debug-event-log.ts +4 -2
- package/src/utils/condense-history.test.ts +373 -0
- package/src/utils/condense-history.ts +306 -0
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
ChatToolChoice,
|
|
10
10
|
ChatToolDefinition,
|
|
11
11
|
ChatToolHandlers,
|
|
12
|
+
CondensePolicy,
|
|
13
|
+
CondenseTrigger,
|
|
12
14
|
InteractionRequestOptions,
|
|
13
15
|
InteractionResult,
|
|
14
16
|
SubAgentFailureReason,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
recordTurnError,
|
|
41
43
|
recordTurnRetry,
|
|
42
44
|
} from '../../state/debug-event-log';
|
|
45
|
+
import { applyCondensation, type RegisteredCondensePolicy } from '../../utils/condense-history';
|
|
43
46
|
import { applyHistoryCap } from '../../utils/history-transform';
|
|
44
47
|
import { logger } from '../../utils/logger';
|
|
45
48
|
import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
|
|
@@ -245,6 +248,40 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
245
248
|
* sent to the model — stored `history` stays unchanged for UI and logging.
|
|
246
249
|
*/
|
|
247
250
|
private providerHistoryTransform?: (history: ChatMessage[]) => ChatMessage[];
|
|
251
|
+
/**
|
|
252
|
+
* Tool-declared condensation policies, keyed by tool-call id. Populated by
|
|
253
|
+
* `condenseWhen` (first-wins per call); read by `applyCondensation` before each
|
|
254
|
+
* provider call to collapse stale payloads from the model-bound history only.
|
|
255
|
+
* Accumulates across agents on a shared driver (a superseded read collapses no
|
|
256
|
+
* matter which agent made it) and is never cleared — it dies with the driver.
|
|
257
|
+
*/
|
|
258
|
+
private readonly condensePolicies = new Map<string, RegisteredCondensePolicy>();
|
|
259
|
+
/**
|
|
260
|
+
* Monotonic model-call counter for the driver's whole lifetime — the age clock
|
|
261
|
+
* for `condenseWhen({ on: { kind: 'age' } })`. The per-`sendMessage` `iterations`
|
|
262
|
+
* loop counter resets to 0 every turn, so it can only measure age WITHIN a
|
|
263
|
+
* single turn; this never resets, so `age` counts model-calls since the result
|
|
264
|
+
* appeared across turn boundaries (each short turn still advances it ≥ 1).
|
|
265
|
+
* Bumped once per tool-loop iteration (provider call).
|
|
266
|
+
*/
|
|
267
|
+
private modelCallSeq = 0;
|
|
268
|
+
/**
|
|
269
|
+
* Monotonic turn counter — the `turnEnd` clock. Bumped once per `sendMessage`
|
|
270
|
+
* (a user turn; NOT per handoff continuation, which is the same request), never
|
|
271
|
+
* reset. `turnEnd` collapses a payload once `turnSeq` exceeds the turn it was
|
|
272
|
+
* created in.
|
|
273
|
+
*/
|
|
274
|
+
private turnSeq = 0;
|
|
275
|
+
/**
|
|
276
|
+
* Monotonic agent-activation counter — the `agentEnd` clock. Advances when the
|
|
277
|
+
* active flow ends: a swap to a different-named agent (`applyAgent`) OR an
|
|
278
|
+
* explicit `releaseAgent` / `completeSubAgent`. A stateful agent re-resolving
|
|
279
|
+
* the same name across turns keeps one activation. `agentEnd` fires for a
|
|
280
|
+
* payload once a LATER activation is current (`callActivation < currentActivation`)
|
|
281
|
+
* — so a re-run of a released agent gets a fresh activation and is NOT
|
|
282
|
+
* collapsed until IT ends.
|
|
283
|
+
*/
|
|
284
|
+
private currentActivation = 0;
|
|
248
285
|
|
|
249
286
|
/** Stack of fold frames — grows when a fold opens, shrinks when it closes. */
|
|
250
287
|
private foldStack: FoldStackFrame[] = [];
|
|
@@ -528,6 +565,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
528
565
|
* each specialist turn so the shared driver runs with the right tools and prompt.
|
|
529
566
|
*/
|
|
530
567
|
applyAgent(config: AgentConfig): void {
|
|
568
|
+
// A real swap to a different agent begins a NEW activation — the prior agent's
|
|
569
|
+
// flow is over, so its `agentEnd`-tagged payloads become collapsible. Guarded
|
|
570
|
+
// on a name change so re-applying the same agent (e.g. a per-turn re-resolve)
|
|
571
|
+
// does not spuriously advance the clock and prematurely drop its payloads.
|
|
572
|
+
if (config.name !== this.activeAgentName) this.currentActivation += 1;
|
|
531
573
|
this.systemPrompt = config.systemPrompt;
|
|
532
574
|
if (typeof config.toolDefinitions === 'function') {
|
|
533
575
|
this.toolDefinitionsFactory = config.toolDefinitions;
|
|
@@ -1175,6 +1217,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1175
1217
|
|
|
1176
1218
|
this.busy = true;
|
|
1177
1219
|
this.beginTurn();
|
|
1220
|
+
// A new user turn — advances the `turnEnd` clock (continuations after a
|
|
1221
|
+
// handoff stay on the same turn; they go through `continueFromHistory`).
|
|
1222
|
+
this.turnSeq += 1;
|
|
1178
1223
|
this.subAgentCompletion = undefined;
|
|
1179
1224
|
this.subAgentFailure = undefined;
|
|
1180
1225
|
this.agentReleaseRequested = false;
|
|
@@ -1219,11 +1264,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1219
1264
|
* Centralised here so fold shortcut dispatch and the main tool loop use the
|
|
1220
1265
|
* same context without duplication.
|
|
1221
1266
|
*
|
|
1267
|
+
* @param activeToolCallId - The id of the tool call this context belongs to, so
|
|
1268
|
+
* `condenseWhen` can register against the right call (it stamps the clocks
|
|
1269
|
+
* straight off the driver). Absent for dispatch paths with no addressable tool
|
|
1270
|
+
* call (e.g. the fold-close handler), where `condenseWhen` is a no-op.
|
|
1222
1271
|
* @param traceCapture - Optional per-invocation slot. When provided, the trace
|
|
1223
1272
|
* from any sub-agent call is written here rather than to shared instance state,
|
|
1224
1273
|
* so parallel tool calls each capture their own trace independently.
|
|
1225
1274
|
*/
|
|
1226
|
-
private buildHandlerContext(traceCapture?: { trace?: ChatMessage[] }) {
|
|
1275
|
+
private buildHandlerContext(activeToolCallId?: string, traceCapture?: { trace?: ChatMessage[] }) {
|
|
1227
1276
|
return {
|
|
1228
1277
|
requestInteraction: <T>(
|
|
1229
1278
|
componentName: string,
|
|
@@ -1251,6 +1300,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1251
1300
|
return;
|
|
1252
1301
|
}
|
|
1253
1302
|
this.subAgentCompletion = { result };
|
|
1303
|
+
// This activation's flow has ended — advance the clock so its
|
|
1304
|
+
// `agentEnd` payloads collapse and a re-run starts a fresh activation.
|
|
1305
|
+
this.currentActivation += 1;
|
|
1254
1306
|
},
|
|
1255
1307
|
releaseAgent: (): void => {
|
|
1256
1308
|
if (this.agentReleaseRequested) {
|
|
@@ -1260,6 +1312,48 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1260
1312
|
return;
|
|
1261
1313
|
}
|
|
1262
1314
|
this.agentReleaseRequested = true;
|
|
1315
|
+
// The stateful flow has wrapped up — advance the clock so its `agentEnd`
|
|
1316
|
+
// payloads collapse and a re-run starts a fresh activation.
|
|
1317
|
+
this.currentActivation += 1;
|
|
1318
|
+
},
|
|
1319
|
+
condenseWhen: (policy: CondensePolicy): void => {
|
|
1320
|
+
// No addressable tool call (e.g. fold-close handler) — nothing to attach to.
|
|
1321
|
+
if (!activeToolCallId) return;
|
|
1322
|
+
if (!policy?.args && !policy?.response) {
|
|
1323
|
+
logger.warn(
|
|
1324
|
+
`ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called with neither args nor response — ignoring`,
|
|
1325
|
+
);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const triggers = Array.isArray(policy.on) ? policy.on : [policy.on];
|
|
1329
|
+
const validTrigger = (t: CondenseTrigger | undefined): boolean =>
|
|
1330
|
+
t?.kind === 'superseded'
|
|
1331
|
+
? typeof t.by === 'string' && t.by.length > 0
|
|
1332
|
+
: t?.kind === 'age'
|
|
1333
|
+
? typeof t.turns === 'number' && t.turns >= 1
|
|
1334
|
+
: t?.kind === 'turnEnd' || t?.kind === 'agentEnd';
|
|
1335
|
+
if (triggers.length === 0 || !triggers.every(validTrigger)) {
|
|
1336
|
+
logger.warn(
|
|
1337
|
+
`ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called with an invalid trigger — ignoring`,
|
|
1338
|
+
);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
// First-wins: a handler declares its policy once per call. A second
|
|
1342
|
+
// declaration for the same tool call is a handler bug, not a refinement.
|
|
1343
|
+
if (this.condensePolicies.has(activeToolCallId)) {
|
|
1344
|
+
logger.error(
|
|
1345
|
+
`ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called more than once for the same tool call — keeping the first policy, ignoring this one`,
|
|
1346
|
+
);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
// Stamp the three clocks NOW (read straight off the driver — the handler
|
|
1350
|
+
// runs during the current model-call's tool batch).
|
|
1351
|
+
this.condensePolicies.set(activeToolCallId, {
|
|
1352
|
+
policy,
|
|
1353
|
+
iteration: this.modelCallSeq,
|
|
1354
|
+
turn: this.turnSeq,
|
|
1355
|
+
activation: this.currentActivation,
|
|
1356
|
+
});
|
|
1263
1357
|
},
|
|
1264
1358
|
};
|
|
1265
1359
|
}
|
|
@@ -1611,6 +1705,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1611
1705
|
foldName: string,
|
|
1612
1706
|
fold: ToolFold,
|
|
1613
1707
|
args: Record<string, unknown>,
|
|
1708
|
+
activeToolCallId?: string,
|
|
1614
1709
|
): Promise<string> {
|
|
1615
1710
|
// Shortcut dispatch: model passed inner tool args directly, e.g.
|
|
1616
1711
|
// trading_tools({ search_trades: { side: "BUY" } })
|
|
@@ -1626,7 +1721,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1626
1721
|
typeof args[key] === 'object' && args[key] !== null
|
|
1627
1722
|
? (args[key] as Record<string, unknown>)
|
|
1628
1723
|
: {};
|
|
1629
|
-
return innerHandler(innerArgs, this.buildHandlerContext()).then((r) =>
|
|
1724
|
+
return innerHandler(innerArgs, this.buildHandlerContext(activeToolCallId)).then((r) =>
|
|
1630
1725
|
typeof r === 'string' ? r : JSON.stringify(r),
|
|
1631
1726
|
);
|
|
1632
1727
|
}
|
|
@@ -1727,6 +1822,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1727
1822
|
|
|
1728
1823
|
while (iterations < this.maxToolIterations) {
|
|
1729
1824
|
iterations += 1;
|
|
1825
|
+
// Monotonic across the driver's life — the age clock. Unlike `iterations`,
|
|
1826
|
+
// it is NOT reset per turn and NOT decremented for folds, so `age` keeps
|
|
1827
|
+
// counting model-calls once a turn ends. (See the `modelCallSeq` field.)
|
|
1828
|
+
this.modelCallSeq += 1;
|
|
1730
1829
|
|
|
1731
1830
|
// A cancel (or dispose) that landed while the previous iteration's tool
|
|
1732
1831
|
// batch was running takes effect here — before issuing another LLM call.
|
|
@@ -1804,9 +1903,24 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1804
1903
|
const primer = [...(this.primerHistory ?? []), ...(transientPrimer ?? [])];
|
|
1805
1904
|
const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
|
|
1806
1905
|
firstLlmCall = false;
|
|
1906
|
+
// Collapse stale tool payloads this agent declared via `condenseWhen`, then
|
|
1907
|
+
// pipe through any externally-set transform (e.g. multi-agent masking).
|
|
1908
|
+
// Both run on a copy — stored history is untouched.
|
|
1909
|
+
const condensedHistory = applyCondensation(
|
|
1910
|
+
[...baseHistory],
|
|
1911
|
+
this.condensePolicies,
|
|
1912
|
+
{
|
|
1913
|
+
modelCall: this.modelCallSeq,
|
|
1914
|
+
turn: this.turnSeq,
|
|
1915
|
+
// A call's activation has ended once a later activation is current — a
|
|
1916
|
+
// swap or release/complete both advance the counter.
|
|
1917
|
+
activationEnded: (activation) => activation < this.currentActivation,
|
|
1918
|
+
},
|
|
1919
|
+
(detail) => recordMetaEvent(this.sessionKey, 'context.condensed', detail),
|
|
1920
|
+
);
|
|
1807
1921
|
const historyForProvider = this.providerHistoryTransform
|
|
1808
|
-
? this.providerHistoryTransform(
|
|
1809
|
-
:
|
|
1922
|
+
? this.providerHistoryTransform(condensedHistory)
|
|
1923
|
+
: condensedHistory;
|
|
1810
1924
|
const historyForCall = [...primer, ...historyForProvider];
|
|
1811
1925
|
|
|
1812
1926
|
const systemPrompt =
|
|
@@ -2059,7 +2173,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2059
2173
|
});
|
|
2060
2174
|
return;
|
|
2061
2175
|
}
|
|
2062
|
-
const content = await this.openFold(tc.name, fold, tc.args);
|
|
2176
|
+
const content = await this.openFold(tc.name, fold, tc.args, tc.id);
|
|
2063
2177
|
executedById.set(tc.id, { toolCallId: tc.id, content });
|
|
2064
2178
|
// Fold open/close does NOT count as a real iteration — decrement to compensate
|
|
2065
2179
|
iterations -= 1;
|
|
@@ -2186,7 +2300,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2186
2300
|
// Real tool execution
|
|
2187
2301
|
try {
|
|
2188
2302
|
const traceCapture: { trace?: ChatMessage[] } = {};
|
|
2189
|
-
const result = await handler(tc.args, this.buildHandlerContext(traceCapture));
|
|
2303
|
+
const result = await handler(tc.args, this.buildHandlerContext(tc.id, traceCapture));
|
|
2190
2304
|
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
|
2191
2305
|
executedById.set(tc.id, {
|
|
2192
2306
|
toolCallId: tc.id,
|
|
@@ -60,6 +60,7 @@ export type MetaEventType =
|
|
|
60
60
|
// Context / cost
|
|
61
61
|
| 'context.updated'
|
|
62
62
|
| 'context.threshold-crossed'
|
|
63
|
+
| 'context.condensed'
|
|
63
64
|
// UI + input
|
|
64
65
|
| 'panel.toggled'
|
|
65
66
|
| 'attachment.added'
|
|
@@ -113,6 +114,7 @@ export const META_EVENT_IMPORTANCE: Record<MetaEventType, MetaEventImportance> =
|
|
|
113
114
|
'driver.wired': 'low',
|
|
114
115
|
'driver.unwired': 'low',
|
|
115
116
|
'context.updated': 'low',
|
|
117
|
+
'context.condensed': 'low',
|
|
116
118
|
'panel.toggled': 'low',
|
|
117
119
|
'attachment.added': 'low',
|
|
118
120
|
};
|
|
@@ -298,8 +300,8 @@ export const DEBUG_LOG_README: readonly string[] = [
|
|
|
298
300
|
"kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
|
|
299
301
|
"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
|
|
300
302
|
"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
|
|
301
|
-
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
302
|
-
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
303
|
+
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
304
|
+
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
303
305
|
'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
|
|
304
306
|
"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, token usage, session cost), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
|
|
305
307
|
'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type { ChatMessage, CondensePolicy } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
+
import {
|
|
4
|
+
applyCondensation,
|
|
5
|
+
CONDENSE_MIN_CHARS,
|
|
6
|
+
type CondenseContext,
|
|
7
|
+
type CondensedEventDetail,
|
|
8
|
+
type RegisteredCondensePolicy,
|
|
9
|
+
} from './condense-history';
|
|
10
|
+
|
|
11
|
+
const suite = createLogicSuite('applyCondensation');
|
|
12
|
+
|
|
13
|
+
// Payloads must clear the driver-level floor to be condensed.
|
|
14
|
+
const big = (n: number = CONDENSE_MIN_CHARS + 500): string => 'x'.repeat(n);
|
|
15
|
+
|
|
16
|
+
const asstCall = (
|
|
17
|
+
id: string,
|
|
18
|
+
name: string,
|
|
19
|
+
args: Record<string, unknown> = {},
|
|
20
|
+
extra: Record<string, unknown> = {},
|
|
21
|
+
): ChatMessage => ({ role: 'assistant', content: '', toolCalls: [{ id, name, args, ...extra }] });
|
|
22
|
+
|
|
23
|
+
const toolMsg = (toolCallId: string, content: string): ChatMessage => ({
|
|
24
|
+
role: 'tool',
|
|
25
|
+
content: '',
|
|
26
|
+
toolResult: { toolCallId, content },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const reg = (
|
|
30
|
+
policy: CondensePolicy,
|
|
31
|
+
clocks: { iteration?: number; turn?: number; activation?: number } = {},
|
|
32
|
+
): RegisteredCondensePolicy => ({
|
|
33
|
+
policy,
|
|
34
|
+
iteration: clocks.iteration ?? 1,
|
|
35
|
+
turn: clocks.turn ?? 1,
|
|
36
|
+
activation: clocks.activation ?? 1,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** A CondenseContext for the pure transform. Defaults fire nothing; override per test. */
|
|
40
|
+
const ctx = (
|
|
41
|
+
o: { modelCall?: number; turn?: number; activationEnded?: (a: number) => boolean } = {},
|
|
42
|
+
): CondenseContext => ({
|
|
43
|
+
modelCall: o.modelCall ?? 0,
|
|
44
|
+
turn: o.turn ?? 0,
|
|
45
|
+
activationEnded: o.activationEnded ?? (() => false),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const sink = () => {
|
|
49
|
+
const events: CondensedEventDetail[] = [];
|
|
50
|
+
return { events, on: (d: CondensedEventDetail) => events.push(d) };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// supersession
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
suite('superseded response: collapses the earlier call, keeps the latest full', () => {
|
|
58
|
+
const history: ChatMessage[] = [
|
|
59
|
+
asstCall('r1', 'vfs_read', { path: 'A' }),
|
|
60
|
+
toolMsg('r1', big()),
|
|
61
|
+
asstCall('r2', 'vfs_read', { path: 'A' }),
|
|
62
|
+
toolMsg('r2', big()),
|
|
63
|
+
];
|
|
64
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
65
|
+
['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
66
|
+
['r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
67
|
+
]);
|
|
68
|
+
const { events, on } = sink();
|
|
69
|
+
const out = applyCondensation(history, policies, ctx(), on);
|
|
70
|
+
|
|
71
|
+
assert.match(out[1].toolResult!.content, /re-call to restore/);
|
|
72
|
+
assert.is(out[3].toolResult!.content, big(), 'latest call for the key stays full');
|
|
73
|
+
// Exactly one transition reported, for the earlier call's response.
|
|
74
|
+
assert.is(events.length, 1);
|
|
75
|
+
assert.is(events[0].toolCallId, 'r1');
|
|
76
|
+
assert.is(events[0].target, 'response');
|
|
77
|
+
assert.is(events[0].trigger, 'superseded:A');
|
|
78
|
+
// Input is never mutated; unchanged messages keep their identity.
|
|
79
|
+
assert.is(history[1].toolResult!.content, big(), 'stored history untouched');
|
|
80
|
+
assert.is(out[3], history[3], 'unchanged message reuses the same object');
|
|
81
|
+
assert.ok(out[1] !== history[1], 'collapsed message is a new object');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
suite('superseded crosses tools: a read collapses the earlier write args of the same key', () => {
|
|
85
|
+
const history: ChatMessage[] = [
|
|
86
|
+
asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
|
|
87
|
+
toolMsg('w1', 'written'),
|
|
88
|
+
asstCall('r1', 'vfs_read', { path: 'A' }),
|
|
89
|
+
toolMsg('r1', big()),
|
|
90
|
+
];
|
|
91
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
92
|
+
['w1', reg({ on: { kind: 'superseded', by: 'A' }, args: 'pointer' })],
|
|
93
|
+
['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
94
|
+
]);
|
|
95
|
+
const { events, on } = sink();
|
|
96
|
+
const out = applyCondensation(history, policies, ctx(), on);
|
|
97
|
+
|
|
98
|
+
// Earlier write is superseded by the later read of the same key → its args collapse.
|
|
99
|
+
assert.match(String(out[0].toolCalls![0].args.condensed), /re-call to restore/);
|
|
100
|
+
// The read is the latest for key A → its response stays full.
|
|
101
|
+
assert.is(out[3].toolResult!.content, big());
|
|
102
|
+
assert.is(events.length, 1);
|
|
103
|
+
assert.is(events[0].toolCallId, 'w1');
|
|
104
|
+
assert.is(events[0].target, 'args');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// age
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
suite('age: the model sees the result on `turns` turns, then it collapses', () => {
|
|
112
|
+
// Call made on iteration 1 → the result is first visible to the model on
|
|
113
|
+
// iteration 2. With turns:1 it may be read on that one turn, then collapses.
|
|
114
|
+
const history: ChatMessage[] = [asstCall('g1', 'grep_source', {}), toolMsg('g1', big())];
|
|
115
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
116
|
+
['g1', reg({ on: { kind: 'age', turns: 1 }, response: 'pointer' }, { iteration: 1 })],
|
|
117
|
+
]);
|
|
118
|
+
const { events, on } = sink();
|
|
119
|
+
|
|
120
|
+
// Iteration 2 — its one allowed look: still full, nothing reported.
|
|
121
|
+
const seen = applyCondensation(history, policies, ctx({ modelCall: 2 }), on);
|
|
122
|
+
assert.is(seen[1].toolResult!.content, big(), 'full on the one turn it may be seen');
|
|
123
|
+
assert.is(events.length, 0, 'not collapsed while the model may still see it');
|
|
124
|
+
|
|
125
|
+
// Iteration 3 — one turn past the allowed view: collapses.
|
|
126
|
+
const collapsed = applyCondensation(history, policies, ctx({ modelCall: 3 }), on);
|
|
127
|
+
assert.match(collapsed[1].toolResult!.content, /aged out/);
|
|
128
|
+
assert.match(collapsed[1].toolResult!.content, /re-call to restore/);
|
|
129
|
+
assert.is(events.length, 1);
|
|
130
|
+
assert.is(events[0].trigger, 'age:1');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// min-size floor
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
suite('payloads below the size floor are never condensed', () => {
|
|
138
|
+
const history: ChatMessage[] = [
|
|
139
|
+
asstCall('r1', 'vfs_read', { path: 'A' }),
|
|
140
|
+
toolMsg('r1', 'tiny'),
|
|
141
|
+
asstCall('r2', 'vfs_read', { path: 'A' }),
|
|
142
|
+
toolMsg('r2', big()),
|
|
143
|
+
];
|
|
144
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
145
|
+
['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
146
|
+
['r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
147
|
+
]);
|
|
148
|
+
const { events, on } = sink();
|
|
149
|
+
const out = applyCondensation(history, policies, ctx(), on);
|
|
150
|
+
|
|
151
|
+
assert.is(out[1].toolResult!.content, 'tiny', 'superseded but too small to bother');
|
|
152
|
+
assert.is(events.length, 0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// result modes
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
suite('drop / pointer / replaceWith produce distinct collapsed forms', () => {
|
|
160
|
+
const mk = (id: string, result: CondensePolicy['response']): [ChatMessage, ChatMessage] => [
|
|
161
|
+
asstCall(id, 'vfs_read', { path: id }),
|
|
162
|
+
toolMsg(id, big()),
|
|
163
|
+
];
|
|
164
|
+
// Each call is superseded by a later same-key call so it actually fires.
|
|
165
|
+
const history: ChatMessage[] = [
|
|
166
|
+
...mk('drop', 'drop'),
|
|
167
|
+
asstCall('drop2', 'vfs_read', { path: 'drop' }),
|
|
168
|
+
toolMsg('drop2', big()),
|
|
169
|
+
...mk('ptr', 'pointer'),
|
|
170
|
+
asstCall('ptr2', 'vfs_read', { path: 'ptr' }),
|
|
171
|
+
toolMsg('ptr2', big()),
|
|
172
|
+
...mk('rep', { replaceWith: 'SUMMARY' }),
|
|
173
|
+
asstCall('rep2', 'vfs_read', { path: 'rep' }),
|
|
174
|
+
toolMsg('rep2', big()),
|
|
175
|
+
];
|
|
176
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
177
|
+
['drop', reg({ on: { kind: 'superseded', by: 'drop' }, response: 'drop' })],
|
|
178
|
+
['drop2', reg({ on: { kind: 'superseded', by: 'drop' }, response: 'drop' })],
|
|
179
|
+
['ptr', reg({ on: { kind: 'superseded', by: 'ptr' }, response: 'pointer' })],
|
|
180
|
+
['ptr2', reg({ on: { kind: 'superseded', by: 'ptr' }, response: 'pointer' })],
|
|
181
|
+
['rep', reg({ on: { kind: 'superseded', by: 'rep' }, response: { replaceWith: 'SUMMARY' } })],
|
|
182
|
+
['rep2', reg({ on: { kind: 'superseded', by: 'rep' }, response: { replaceWith: 'SUMMARY' } })],
|
|
183
|
+
]);
|
|
184
|
+
const { on } = sink();
|
|
185
|
+
const out = applyCondensation(history, policies, ctx(), on);
|
|
186
|
+
|
|
187
|
+
assert.is(out[1].toolResult!.content, '[elided]', 'drop is a minimal non-empty placeholder');
|
|
188
|
+
assert.match(
|
|
189
|
+
out[5].toolResult!.content,
|
|
190
|
+
/re-call to restore/,
|
|
191
|
+
'pointer carries the restore hint',
|
|
192
|
+
);
|
|
193
|
+
assert.is(out[9].toolResult!.content, 'SUMMARY', 'replaceWith is verbatim — no hint appended');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
suite('args drop yields an empty object; pointer stashes a stub under one key', () => {
|
|
197
|
+
const history: ChatMessage[] = [
|
|
198
|
+
asstCall('w1', 'vfs_write', { path: 'A', content: big() }),
|
|
199
|
+
toolMsg('w1', 'ok'),
|
|
200
|
+
asstCall('w2', 'vfs_write', { path: 'A', content: big() }),
|
|
201
|
+
toolMsg('w2', 'ok'),
|
|
202
|
+
];
|
|
203
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
204
|
+
['w1', reg({ on: { kind: 'superseded', by: 'A' }, args: 'drop' })],
|
|
205
|
+
['w2', reg({ on: { kind: 'superseded', by: 'A' }, args: 'drop' })],
|
|
206
|
+
]);
|
|
207
|
+
const { on } = sink();
|
|
208
|
+
const out = applyCondensation(history, policies, ctx(), on);
|
|
209
|
+
assert.equal(out[0].toolCalls![0].args, {}, 'dropped args is an empty object');
|
|
210
|
+
assert.equal(out[2].toolCalls![0].args, { path: 'A', content: big() }, 'latest write kept');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// envelope + metadata invariants
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
suite('collapsing args preserves the tool-call envelope and providerMetadata', () => {
|
|
218
|
+
const history: ChatMessage[] = [
|
|
219
|
+
asstCall(
|
|
220
|
+
'w1',
|
|
221
|
+
'vfs_write',
|
|
222
|
+
{ path: 'A', content: big() },
|
|
223
|
+
{ providerMetadata: { sig: 'keep' } },
|
|
224
|
+
),
|
|
225
|
+
toolMsg('w1', 'ok'),
|
|
226
|
+
asstCall('w2', 'vfs_write', { path: 'A', content: big() }),
|
|
227
|
+
toolMsg('w2', 'ok'),
|
|
228
|
+
];
|
|
229
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
230
|
+
['w1', reg({ on: { kind: 'superseded', by: 'A' }, args: 'pointer' })],
|
|
231
|
+
['w2', reg({ on: { kind: 'superseded', by: 'A' }, args: 'pointer' })],
|
|
232
|
+
]);
|
|
233
|
+
const { on } = sink();
|
|
234
|
+
const out = applyCondensation(history, policies, ctx(), on);
|
|
235
|
+
const tc = out[0].toolCalls![0];
|
|
236
|
+
assert.is(tc.id, 'w1', 'id preserved');
|
|
237
|
+
assert.is(tc.name, 'vfs_write', 'name preserved');
|
|
238
|
+
assert.equal(tc.providerMetadata, { sig: 'keep' }, 'reasoning signature round-trips');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// report-once + no-op
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
suite('the event fires once per payload across repeated applies', () => {
|
|
246
|
+
const history: ChatMessage[] = [
|
|
247
|
+
asstCall('r1', 'vfs_read', { path: 'A' }),
|
|
248
|
+
toolMsg('r1', big()),
|
|
249
|
+
asstCall('r2', 'vfs_read', { path: 'A' }),
|
|
250
|
+
toolMsg('r2', big()),
|
|
251
|
+
];
|
|
252
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
253
|
+
['r1', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
254
|
+
['r2', reg({ on: { kind: 'superseded', by: 'A' }, response: 'pointer' })],
|
|
255
|
+
]);
|
|
256
|
+
const { events, on } = sink();
|
|
257
|
+
applyCondensation(history, policies, ctx(), on);
|
|
258
|
+
applyCondensation(history, policies, ctx(), on);
|
|
259
|
+
applyCondensation(history, policies, ctx(), on);
|
|
260
|
+
assert.is(events.length, 1, 'collapse re-runs every call but the transition is reported once');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
suite('an empty registry is a pure pass-through (same array reference)', () => {
|
|
264
|
+
const history: ChatMessage[] = [asstCall('r1', 'vfs_read'), toolMsg('r1', big())];
|
|
265
|
+
const { events, on } = sink();
|
|
266
|
+
const out = applyCondensation(history, new Map(), ctx(), on);
|
|
267
|
+
assert.is(out, history);
|
|
268
|
+
assert.is(events.length, 0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// turnEnd
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
suite('turnEnd: full during its own turn, collapsed on a later turn', () => {
|
|
276
|
+
const history: ChatMessage[] = [asstCall('t1', 'read'), toolMsg('t1', big())];
|
|
277
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
278
|
+
['t1', reg({ on: { kind: 'turnEnd' }, response: 'pointer' }, { turn: 1 })],
|
|
279
|
+
]);
|
|
280
|
+
const { events, on } = sink();
|
|
281
|
+
|
|
282
|
+
const sameTurn = applyCondensation(history, policies, ctx({ turn: 1 }), on);
|
|
283
|
+
assert.is(sameTurn[1].toolResult!.content, big(), 'full for the rest of the request');
|
|
284
|
+
assert.is(events.length, 0);
|
|
285
|
+
|
|
286
|
+
const laterTurn = applyCondensation(history, policies, ctx({ turn: 2 }), on);
|
|
287
|
+
assert.match(laterTurn[1].toolResult!.content, /turn ended/);
|
|
288
|
+
assert.is(events.length, 1);
|
|
289
|
+
assert.is(events[0].trigger, 'turnEnd');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// agentEnd
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
suite('agentEnd: kept while the agent is active, collapsed once its activation ends', () => {
|
|
297
|
+
const history: ChatMessage[] = [asstCall('a1', 'load'), toolMsg('a1', big())];
|
|
298
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
299
|
+
['a1', reg({ on: { kind: 'agentEnd' }, response: 'pointer' }, { activation: 1 })],
|
|
300
|
+
]);
|
|
301
|
+
const { events, on } = sink();
|
|
302
|
+
|
|
303
|
+
const active = applyCondensation(history, policies, ctx({ activationEnded: () => false }), on);
|
|
304
|
+
assert.is(active[1].toolResult!.content, big(), 'full while the agent flow is active');
|
|
305
|
+
assert.is(events.length, 0);
|
|
306
|
+
|
|
307
|
+
const ended = applyCondensation(history, policies, ctx({ activationEnded: (a) => a === 1 }), on);
|
|
308
|
+
assert.match(ended[1].toolResult!.content, /agent finished/);
|
|
309
|
+
assert.is(events.length, 1);
|
|
310
|
+
assert.is(events[0].trigger, 'agentEnd');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// multi-trigger (OR / first-wins)
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
suite('multi-trigger: collapses when ANY listed trigger fires', () => {
|
|
318
|
+
// superseded OR turnEnd — only one call for key A (never superseded), but the turn ends.
|
|
319
|
+
const history: ChatMessage[] = [asstCall('m1', 'read', { path: 'A' }), toolMsg('m1', big())];
|
|
320
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
321
|
+
[
|
|
322
|
+
'm1',
|
|
323
|
+
reg(
|
|
324
|
+
{ on: [{ kind: 'superseded', by: 'A' }, { kind: 'turnEnd' }], response: 'pointer' },
|
|
325
|
+
{ turn: 1 },
|
|
326
|
+
),
|
|
327
|
+
],
|
|
328
|
+
]);
|
|
329
|
+
const { events, on } = sink();
|
|
330
|
+
|
|
331
|
+
assert.is(
|
|
332
|
+
applyCondensation(history, policies, ctx({ turn: 1 }), on)[1].toolResult!.content,
|
|
333
|
+
big(),
|
|
334
|
+
'neither trigger has fired',
|
|
335
|
+
);
|
|
336
|
+
const later = applyCondensation(history, policies, ctx({ turn: 2 }), on);
|
|
337
|
+
assert.match(later[1].toolResult!.content, /turn ended/, 'turnEnd fired via the OR');
|
|
338
|
+
assert.is(events[0].trigger, 'turnEnd');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
suite('multi-trigger first-wins: the earliest-listed firing trigger is the reason', () => {
|
|
342
|
+
// m1 is superseded by m2 AND its turn has ended; m2 is the latest (not superseded)
|
|
343
|
+
// but its turn has ended too. `superseded` is listed first.
|
|
344
|
+
const history: ChatMessage[] = [
|
|
345
|
+
asstCall('m1', 'read', { path: 'A' }),
|
|
346
|
+
toolMsg('m1', big()),
|
|
347
|
+
asstCall('m2', 'read', { path: 'A' }),
|
|
348
|
+
toolMsg('m2', big()),
|
|
349
|
+
];
|
|
350
|
+
const on0: CondensePolicy = {
|
|
351
|
+
on: [{ kind: 'superseded', by: 'A' }, { kind: 'turnEnd' }],
|
|
352
|
+
response: 'pointer',
|
|
353
|
+
};
|
|
354
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
355
|
+
['m1', reg(on0, { turn: 1 })],
|
|
356
|
+
['m2', reg(on0, { turn: 1 })],
|
|
357
|
+
]);
|
|
358
|
+
const { events, on } = sink();
|
|
359
|
+
applyCondensation(history, policies, ctx({ turn: 2 }), on);
|
|
360
|
+
|
|
361
|
+
assert.is(
|
|
362
|
+
events.find((e) => e.toolCallId === 'm1')?.trigger,
|
|
363
|
+
'superseded:A',
|
|
364
|
+
'first-listed firing trigger wins as the reason',
|
|
365
|
+
);
|
|
366
|
+
assert.is(
|
|
367
|
+
events.find((e) => e.toolCallId === 'm2')?.trigger,
|
|
368
|
+
'turnEnd',
|
|
369
|
+
'the supersession survivor still collapses via the next trigger',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
suite.run();
|