@genesislcap/ai-assistant 15.19.0 → 15.19.1
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/package.json +17 -18
- package/api-extractor.json +0 -11
- package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +0 -1
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts +0 -2
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts.map +0 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +0 -2
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +0 -1
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +0 -2
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +0 -1
- package/dist/dts/config/define-stateful-agent.test.d.ts +0 -2
- package/dist/dts/config/define-stateful-agent.test.d.ts.map +0 -1
- package/dist/dts/config/validate-providers.test.d.ts +0 -2
- package/dist/dts/config/validate-providers.test.d.ts.map +0 -1
- package/dist/dts/main/blocked-state.test.d.ts +0 -2
- package/dist/dts/main/blocked-state.test.d.ts.map +0 -1
- package/dist/dts/main/budget-meter.test.d.ts +0 -2
- package/dist/dts/main/budget-meter.test.d.ts.map +0 -1
- package/dist/dts/main/cost-session-banking.test.d.ts +0 -2
- package/dist/dts/main/cost-session-banking.test.d.ts.map +0 -1
- package/dist/dts/main/file-attachments.test.d.ts +0 -2
- package/dist/dts/main/file-attachments.test.d.ts.map +0 -1
- package/dist/dts/main/interaction-cost.test.d.ts +0 -2
- package/dist/dts/main/interaction-cost.test.d.ts.map +0 -1
- package/dist/dts/main/main.styles.test.d.ts +0 -2
- package/dist/dts/main/main.styles.test.d.ts.map +0 -1
- package/dist/dts/main/persistence-broken-sources.test.d.ts +0 -2
- package/dist/dts/main/persistence-broken-sources.test.d.ts.map +0 -1
- package/dist/dts/main/popout-interaction-gate.test.d.ts +0 -2
- package/dist/dts/main/popout-interaction-gate.test.d.ts.map +0 -1
- package/dist/dts/state/ai-assistant-slice.test.d.ts +0 -2
- package/dist/dts/state/ai-assistant-slice.test.d.ts.map +0 -1
- package/dist/dts/state/debug-event-log.test.d.ts +0 -2
- package/dist/dts/state/debug-event-log.test.d.ts.map +0 -1
- package/dist/dts/state/driver-registry.test.d.ts +0 -2
- package/dist/dts/state/driver-registry.test.d.ts.map +0 -1
- package/dist/dts/state/interaction-context.test.d.ts +0 -2
- package/dist/dts/state/interaction-context.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts +0 -2
- package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/diagnostics.test.d.ts +0 -2
- package/dist/dts/state/persistence/diagnostics.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persistence-provider.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persistence-provider.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persistence.integration.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persistence.integration.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persister.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persister.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-snapshot.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-snapshot.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts +0 -2
- package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts.map +0 -1
- package/dist/dts/utils/animation-exclusivity.test.d.ts +0 -2
- package/dist/dts/utils/animation-exclusivity.test.d.ts.map +0 -1
- package/dist/dts/utils/collect-session-models.test.d.ts +0 -2
- package/dist/dts/utils/collect-session-models.test.d.ts.map +0 -1
- package/dist/dts/utils/condense-history.test.d.ts +0 -2
- package/dist/dts/utils/condense-history.test.d.ts.map +0 -1
- package/dist/dts/utils/cost-session-history.test.d.ts +0 -2
- package/dist/dts/utils/cost-session-history.test.d.ts.map +0 -1
- package/dist/dts/utils/derive-cost-session-title.test.d.ts +0 -2
- package/dist/dts/utils/derive-cost-session-title.test.d.ts.map +0 -1
- package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts +0 -2
- package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts.map +0 -1
- package/dist/dts/utils/history-transform.test.d.ts +0 -2
- package/dist/dts/utils/history-transform.test.d.ts.map +0 -1
- package/dist/dts/utils/message-partition.test.d.ts +0 -2
- package/dist/dts/utils/message-partition.test.d.ts.map +0 -1
- package/dist/dts/utils/resolve-cost-history-config.test.d.ts +0 -2
- package/dist/dts/utils/resolve-cost-history-config.test.d.ts.map +0 -1
- package/dist/dts/utils/resolve-preference-baseline.test.d.ts +0 -2
- package/dist/dts/utils/resolve-preference-baseline.test.d.ts.map +0 -1
- package/dist/dts/utils/strip-agent-handlers.test.d.ts +0 -2
- package/dist/dts/utils/strip-agent-handlers.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-costs.test.d.ts +0 -2
- package/dist/dts/utils/sum-costs.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-tokens.test.d.ts +0 -2
- package/dist/dts/utils/sum-tokens.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-usage.test.d.ts +0 -2
- package/dist/dts/utils/sum-usage.test.d.ts.map +0 -1
- package/dist/dts/utils/usage-rows.test.d.ts +0 -2
- package/dist/dts/utils/usage-rows.test.d.ts.map +0 -1
- package/dist/esm/components/chat-driver/chat-driver.compact.test.js +0 -93
- package/dist/esm/components/chat-driver/chat-driver.invocation-scope.test.js +0 -238
- package/dist/esm/components/chat-driver/chat-driver.test.js +0 -2770
- package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +0 -137
- package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +0 -200
- package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +0 -268
- package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +0 -104
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +0 -312
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +0 -76
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.pin.test.js +0 -61
- package/dist/esm/components/settings-modal/settings-modal.styles.test.js +0 -80
- package/dist/esm/components/settings-modal/settings-modal.template.test.js +0 -91
- package/dist/esm/config/define-stateful-agent.test.js +0 -53
- package/dist/esm/config/validate-providers.test.js +0 -100
- package/dist/esm/main/blocked-state.test.js +0 -1065
- package/dist/esm/main/budget-meter.test.js +0 -359
- package/dist/esm/main/cost-session-banking.test.js +0 -538
- package/dist/esm/main/file-attachments.test.js +0 -164
- package/dist/esm/main/interaction-cost.test.js +0 -107
- package/dist/esm/main/main.styles.test.js +0 -161
- package/dist/esm/main/persistence-broken-sources.test.js +0 -180
- package/dist/esm/main/popout-interaction-gate.test.js +0 -54
- package/dist/esm/state/ai-assistant-slice.test.js +0 -229
- package/dist/esm/state/debug-event-log.test.js +0 -117
- package/dist/esm/state/driver-registry.test.js +0 -55
- package/dist/esm/state/interaction-context.test.js +0 -78
- package/dist/esm/state/persistence/diagnostics-cursors.test.js +0 -30
- package/dist/esm/state/persistence/diagnostics.test.js +0 -280
- package/dist/esm/state/persistence/session-persistence-provider.test.js +0 -110
- package/dist/esm/state/persistence/session-persistence.integration.test.js +0 -113
- package/dist/esm/state/persistence/session-persister.test.js +0 -613
- package/dist/esm/state/persistence/session-snapshot.test.js +0 -256
- package/dist/esm/state/persistence/stateful-restore.e2e.test.js +0 -158
- package/dist/esm/utils/animation-exclusivity.test.js +0 -52
- package/dist/esm/utils/collect-session-models.test.js +0 -53
- package/dist/esm/utils/condense-history.test.js +0 -547
- package/dist/esm/utils/cost-session-history.test.js +0 -174
- package/dist/esm/utils/derive-cost-session-title.test.js +0 -37
- package/dist/esm/utils/flatten-sub-agent-messages.test.js +0 -139
- package/dist/esm/utils/history-transform.test.js +0 -158
- package/dist/esm/utils/message-partition.test.js +0 -90
- package/dist/esm/utils/resolve-cost-history-config.test.js +0 -32
- package/dist/esm/utils/resolve-preference-baseline.test.js +0 -69
- package/dist/esm/utils/strip-agent-handlers.test.js +0 -81
- package/dist/esm/utils/sum-costs.test.js +0 -114
- package/dist/esm/utils/sum-tokens.test.js +0 -51
- package/dist/esm/utils/sum-usage.test.js +0 -164
- package/dist/esm/utils/usage-rows.test.js +0 -189
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/license.txt +0 -46
- package/sandbox/tsconfig.json +0 -8
- package/src/channel/ai-activity-bus.ts +0 -209
- package/src/channel/ai-activity-channel.ts +0 -146
- package/src/chat-driver-node.ts +0 -159
- package/src/components/activity-halo/activity-halo.ts +0 -106
- package/src/components/agent-picker/agent-picker.constants.ts +0 -2
- package/src/components/agent-picker/agent-picker.styles.ts +0 -24
- package/src/components/agent-picker/agent-picker.template.ts +0 -54
- package/src/components/agent-picker/agent-picker.ts +0 -99
- package/src/components/agent-picker/index.ts +0 -1
- package/src/components/ai-driver/ai-driver.ts +0 -166
- package/src/components/ai-driver/index.ts +0 -1
- package/src/components/chat-bubble/chat-bubble.styles.ts +0 -194
- package/src/components/chat-bubble/chat-bubble.template.ts +0 -67
- package/src/components/chat-bubble/chat-bubble.ts +0 -436
- package/src/components/chat-bubble/index.ts +0 -1
- package/src/components/chat-driver/align-event-globals.ts +0 -23
- package/src/components/chat-driver/chat-driver.compact.test.ts +0 -131
- package/src/components/chat-driver/chat-driver.invocation-scope.test.ts +0 -285
- package/src/components/chat-driver/chat-driver.test.ts +0 -3679
- package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +0 -185
- package/src/components/chat-driver/chat-driver.trace-capture.test.ts +0 -251
- package/src/components/chat-driver/chat-driver.ts +0 -3925
- package/src/components/chat-driver/chat-driver.turn-usage.test.ts +0 -362
- package/src/components/chat-driver/index.ts +0 -1
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts +0 -7
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts +0 -6
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +0 -144
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +0 -243
- package/src/components/chat-interaction-wrapper/index.ts +0 -3
- package/src/components/chat-markdown/chat-markdown.ts +0 -89
- package/src/components/chat-markdown/index.ts +0 -1
- package/src/components/flowing-waves-indicator.ts +0 -286
- package/src/components/halo-overlay.ts +0 -184
- package/src/components/orchestrating-driver/index.ts +0 -1
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +0 -438
- package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +0 -107
- package/src/components/orchestrating-driver/orchestrating-driver.pin.test.ts +0 -96
- package/src/components/orchestrating-driver/orchestrating-driver.ts +0 -813
- package/src/components/plasma-orb-indicator.ts +0 -281
- package/src/components/popout-manager/index.ts +0 -1
- package/src/components/popout-manager/popout-manager.ts +0 -212
- package/src/components/settings-modal/index.ts +0 -2
- package/src/components/settings-modal/settings-modal.styles.test.ts +0 -94
- package/src/components/settings-modal/settings-modal.styles.ts +0 -853
- package/src/components/settings-modal/settings-modal.template.test.ts +0 -124
- package/src/components/settings-modal/settings-modal.template.ts +0 -943
- package/src/components/waves-indicator.ts +0 -241
- package/src/config/config.ts +0 -608
- package/src/config/define-stateful-agent.test.ts +0 -64
- package/src/config/define-stateful-agent.ts +0 -642
- package/src/config/fallback-agents.ts +0 -29
- package/src/config/index.ts +0 -2
- package/src/config/validate-providers.test.ts +0 -148
- package/src/config/validate-providers.ts +0 -58
- package/src/index.ts +0 -29
- package/src/main/blocked-state.test.ts +0 -1444
- package/src/main/budget-meter.test.ts +0 -477
- package/src/main/cost-session-banking.test.ts +0 -679
- package/src/main/file-attachments.test.ts +0 -215
- package/src/main/index.ts +0 -3
- package/src/main/interaction-cost.test.ts +0 -140
- package/src/main/main.styles.test.ts +0 -216
- package/src/main/main.styles.ts +0 -995
- package/src/main/main.template.ts +0 -1119
- package/src/main/main.ts +0 -5432
- package/src/main/main.types.ts +0 -225
- package/src/main/persistence-broken-sources.test.ts +0 -219
- package/src/main/popout-interaction-gate.test.ts +0 -87
- package/src/provider/ai-provider-switcher.ts +0 -40
- package/src/provider/assistant-app-settings.ts +0 -84
- package/src/state/ai-assistant-slice.test.ts +0 -336
- package/src/state/ai-assistant-slice.ts +0 -604
- package/src/state/debug-event-log.test.ts +0 -154
- package/src/state/debug-event-log.ts +0 -358
- package/src/state/driver-registry.test.ts +0 -70
- package/src/state/driver-registry.ts +0 -79
- package/src/state/interaction-context.test.ts +0 -90
- package/src/state/interaction-context.ts +0 -101
- package/src/state/persistence/build-timeline-entries.ts +0 -66
- package/src/state/persistence/diagnostics-cursors.test.ts +0 -53
- package/src/state/persistence/diagnostics-cursors.ts +0 -80
- package/src/state/persistence/diagnostics.test.ts +0 -346
- package/src/state/persistence/diagnostics.ts +0 -220
- package/src/state/persistence/index.ts +0 -10
- package/src/state/persistence/persister-registry.ts +0 -55
- package/src/state/persistence/session-persistence-provider.test.ts +0 -136
- package/src/state/persistence/session-persistence-provider.ts +0 -226
- package/src/state/persistence/session-persistence.integration.test.ts +0 -141
- package/src/state/persistence/session-persister.test.ts +0 -787
- package/src/state/persistence/session-persister.ts +0 -917
- package/src/state/persistence/session-snapshot.test.ts +0 -352
- package/src/state/persistence/session-snapshot.ts +0 -344
- package/src/state/persistence/stateful-restore.e2e.test.ts +0 -229
- package/src/state/session-store.ts +0 -56
- package/src/styles/ai-colours.ts +0 -30
- package/src/styles/menu.ts +0 -187
- package/src/styles/settings-section.ts +0 -40
- package/src/suggestions/chat-suggestions.ts +0 -186
- package/src/tags/index.ts +0 -1
- package/src/types/ai-chat-widget.ts +0 -67
- package/src/types/interaction-context.ts +0 -82
- package/src/utils/animated-panel-toggle.ts +0 -62
- package/src/utils/animation-exclusivity.test.ts +0 -72
- package/src/utils/animation-exclusivity.ts +0 -40
- package/src/utils/banked-usage-baselines.ts +0 -90
- package/src/utils/collect-session-models.test.ts +0 -82
- package/src/utils/collect-session-models.ts +0 -65
- package/src/utils/condense-history.test.ts +0 -746
- package/src/utils/condense-history.ts +0 -448
- package/src/utils/cost-session-history.test.ts +0 -226
- package/src/utils/cost-session-history.ts +0 -218
- package/src/utils/derive-cost-session-title.test.ts +0 -63
- package/src/utils/derive-cost-session-title.ts +0 -76
- package/src/utils/flatten-sub-agent-messages.test.ts +0 -163
- package/src/utils/flatten-sub-agent-messages.ts +0 -88
- package/src/utils/format-usd.ts +0 -26
- package/src/utils/history-transform.test.ts +0 -228
- package/src/utils/history-transform.ts +0 -163
- package/src/utils/index.ts +0 -3
- package/src/utils/logger.ts +0 -3
- package/src/utils/message-partition.test.ts +0 -129
- package/src/utils/message-partition.ts +0 -115
- package/src/utils/resolve-cost-history-config.test.ts +0 -38
- package/src/utils/resolve-cost-history-config.ts +0 -38
- package/src/utils/resolve-preference-baseline.test.ts +0 -84
- package/src/utils/resolve-preference-baseline.ts +0 -80
- package/src/utils/strip-agent-handlers.test.ts +0 -99
- package/src/utils/strip-agent-handlers.ts +0 -53
- package/src/utils/sum-costs.test.ts +0 -136
- package/src/utils/sum-costs.ts +0 -18
- package/src/utils/sum-tokens.test.ts +0 -93
- package/src/utils/sum-tokens.ts +0 -68
- package/src/utils/sum-usage.test.ts +0 -191
- package/src/utils/sum-usage.ts +0 -208
- package/src/utils/tool-fold.ts +0 -181
- package/src/utils/usage-rows.test.ts +0 -237
- package/src/utils/usage-rows.ts +0 -187
- package/src/utils/with-timeout.ts +0 -24
- package/tsconfig.json +0 -9
|
@@ -1,2770 +0,0 @@
|
|
|
1
|
-
import { __awaiter } from "tslib";
|
|
2
|
-
import { BudgetExhaustedError, DEFAULT_PROVIDER_REFUSED_MESSAGE, ProviderRefusedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
|
|
3
|
-
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
4
|
-
import { AgenticActivityBus } from '../../channel/ai-activity-bus';
|
|
5
|
-
import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
|
|
6
|
-
import { sumCosts } from '../../utils/sum-costs';
|
|
7
|
-
import { sumTokens } from '../../utils/sum-tokens';
|
|
8
|
-
import { createToolFold } from '../../utils/tool-fold';
|
|
9
|
-
// Side-effect import — MUST come before `./chat-driver` so the driver subclasses
|
|
10
|
-
// jsdom's EventTarget rather than Node's native one (see the file). None of the
|
|
11
|
-
// imports above pull in the driver, so its realm is still set before evaluation.
|
|
12
|
-
import './align-event-globals';
|
|
13
|
-
import { ChatDriver } from './chat-driver';
|
|
14
|
-
const scriptedProvider = (responses) => {
|
|
15
|
-
const queue = [...responses];
|
|
16
|
-
const advertisedPerCall = [];
|
|
17
|
-
const toolChoicePerCall = [];
|
|
18
|
-
const temperaturePerCall = [];
|
|
19
|
-
const cachePolicyPerCall = [];
|
|
20
|
-
const tailContextPerCall = [];
|
|
21
|
-
const systemPromptPerCall = [];
|
|
22
|
-
const historyPerCall = [];
|
|
23
|
-
return {
|
|
24
|
-
advertisedPerCall,
|
|
25
|
-
toolChoicePerCall,
|
|
26
|
-
temperaturePerCall,
|
|
27
|
-
cachePolicyPerCall,
|
|
28
|
-
tailContextPerCall,
|
|
29
|
-
systemPromptPerCall,
|
|
30
|
-
historyPerCall,
|
|
31
|
-
chat: (history, _userMessage, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
32
|
-
var _a, _b;
|
|
33
|
-
advertisedPerCall.push(((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name));
|
|
34
|
-
toolChoicePerCall.push(options === null || options === void 0 ? void 0 : options.toolChoice);
|
|
35
|
-
temperaturePerCall.push(options === null || options === void 0 ? void 0 : options.temperature);
|
|
36
|
-
cachePolicyPerCall.push(options === null || options === void 0 ? void 0 : options.cachePolicy);
|
|
37
|
-
tailContextPerCall.push(options === null || options === void 0 ? void 0 : options.tailContext);
|
|
38
|
-
systemPromptPerCall.push(options === null || options === void 0 ? void 0 : options.systemPrompt);
|
|
39
|
-
// Snapshot the slice (condensation produces fresh objects per call and never
|
|
40
|
-
// mutates stored history, so a shallow array copy is a stable per-call view).
|
|
41
|
-
historyPerCall.push([...history]);
|
|
42
|
-
// Once the script is exhausted, end the turn with a plain text reply.
|
|
43
|
-
return (_b = queue.shift()) !== null && _b !== void 0 ? _b : { role: 'assistant', content: 'done' };
|
|
44
|
-
}),
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
const makeRegistry = (provider) => ({
|
|
48
|
-
get: () => provider,
|
|
49
|
-
default: () => provider,
|
|
50
|
-
defaultName: () => 'test',
|
|
51
|
-
names: () => ['test'],
|
|
52
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
53
|
-
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
54
|
-
});
|
|
55
|
-
const def = (name) => ({
|
|
56
|
-
name,
|
|
57
|
-
description: `${name} tool`,
|
|
58
|
-
parameters: { type: 'object', properties: {} },
|
|
59
|
-
});
|
|
60
|
-
/** An assistant turn that calls a single tool. `content` is empty so the driver
|
|
61
|
-
* does not treat it as a thinking step (which would split it into two messages). */
|
|
62
|
-
const callsTool = (name, id) => ({
|
|
63
|
-
role: 'assistant',
|
|
64
|
-
content: '',
|
|
65
|
-
toolCalls: [{ id, name, args: {} }],
|
|
66
|
-
});
|
|
67
|
-
const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
|
|
68
|
-
const makeDriver = (config, provider, sessionKey = '', activityBus) => {
|
|
69
|
-
const driver = new ChatDriver(makeRegistry(provider), {
|
|
70
|
-
maxToolIterations: 50,
|
|
71
|
-
maxFoldOperations: 5,
|
|
72
|
-
sessionKey,
|
|
73
|
-
activityBus,
|
|
74
|
-
});
|
|
75
|
-
driver.applyAgent(config);
|
|
76
|
-
return driver;
|
|
77
|
-
};
|
|
78
|
-
/** All tool calls across the whole conversation, flattened. */
|
|
79
|
-
const allToolCalls = (driver) => driver.getHistory().flatMap((m) => { var _a; return (_a = m.toolCalls) !== null && _a !== void 0 ? _a : []; });
|
|
80
|
-
/** Tool-result message contents, in order. */
|
|
81
|
-
const toolResultContents = (driver) => driver
|
|
82
|
-
.getHistory()
|
|
83
|
-
.filter((m) => m.role === 'tool' && m.toolResult)
|
|
84
|
-
.map((m) => m.toolResult.content);
|
|
85
|
-
/** `tool.unresolved` meta-event details recorded for a session (download-log surface). */
|
|
86
|
-
const unresolvedEvents = (sessionKey) => getMetaEvents(sessionKey)
|
|
87
|
-
.filter((e) => e.type === 'tool.unresolved')
|
|
88
|
-
.map((e) => { var _a; return (_a = e.detail) !== null && _a !== void 0 ? _a : {}; });
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
// stale tool detection — stateful agent advances past a tool's state
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
const stale = createLogicSuite('ChatDriver stale-tool detection');
|
|
93
|
-
stale('guides the model when it calls a tool that an earlier state exposed', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
94
|
-
// State A exposes tool_a; calling it advances to state B, which exposes only
|
|
95
|
-
// tool_b. A factory-form agent narrows the tool set per turn, mirroring how
|
|
96
|
-
// `defineStatefulAgent` works.
|
|
97
|
-
let state = 'A';
|
|
98
|
-
const config = agent({
|
|
99
|
-
name: 'Stateful',
|
|
100
|
-
toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
|
|
101
|
-
toolHandlers: () => state === 'A'
|
|
102
|
-
? {
|
|
103
|
-
tool_a: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
104
|
-
state = 'B';
|
|
105
|
-
return 'advanced to B';
|
|
106
|
-
}),
|
|
107
|
-
}
|
|
108
|
-
: { tool_b: () => __awaiter(void 0, void 0, void 0, function* () { return 'b done'; }) },
|
|
109
|
-
});
|
|
110
|
-
const provider = scriptedProvider([
|
|
111
|
-
callsTool('tool_a', 't1'), // real — advances A -> B
|
|
112
|
-
callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
|
|
113
|
-
callsTool('tool_b', 't3'), // real — valid in state B
|
|
114
|
-
]);
|
|
115
|
-
const sessionKey = 'stale-meta-test';
|
|
116
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
117
|
-
const result = yield driver.sendMessage('go');
|
|
118
|
-
assert.is(result.reason, 'done');
|
|
119
|
-
// The per-state narrowing actually happened: tool_a advertised first, tool_b later.
|
|
120
|
-
assert.equal(provider.advertisedPerCall[0], ['tool_a']);
|
|
121
|
-
assert.ok(provider.advertisedPerCall.some((tools) => tools.includes('tool_b') && !tools.includes('tool_a')), 'a later turn should advertise tool_b without tool_a');
|
|
122
|
-
// The retried tool_a got stale guidance — not "Unknown tool".
|
|
123
|
-
const staleGuidance = toolResultContents(driver).find((c) => c.includes('was available earlier but is not part of the current step'));
|
|
124
|
-
assert.ok(staleGuidance, 'a previously-available tool should receive stale guidance');
|
|
125
|
-
assert.not.ok(toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')), 'a previously-available tool must not be reported as a hallucination');
|
|
126
|
-
// The retried call is flagged unknown + stale for the UI.
|
|
127
|
-
const retried = allToolCalls(driver).filter((tc) => tc.name === 'tool_a' && isChatToolCallUnknown(tc));
|
|
128
|
-
assert.is(retried.length, 1, 'exactly one tool_a call should be flagged unknown');
|
|
129
|
-
assert.ok(isChatToolCallUnknown(retried[0]) && retried[0].stale === true, 'and marked stale');
|
|
130
|
-
// The occurrence is recorded to the meta-event log for the download log.
|
|
131
|
-
assert.ok(unresolvedEvents(sessionKey).some((d) => d.kind === 'stale' && d.tool === 'tool_a'), 'a stale tool.unresolved meta event should be recorded');
|
|
132
|
-
}));
|
|
133
|
-
stale('reports a never-seen tool as a hallucinated unknown tool', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
134
|
-
const config = agent({
|
|
135
|
-
name: 'Static',
|
|
136
|
-
toolDefinitions: [def('real_tool')],
|
|
137
|
-
toolHandlers: { real_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
138
|
-
});
|
|
139
|
-
const provider = scriptedProvider([callsTool('made_up', 'm1')]);
|
|
140
|
-
const sessionKey = 'hallucination-meta-test';
|
|
141
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
142
|
-
yield driver.sendMessage('go');
|
|
143
|
-
assert.ok(toolResultContents(driver).includes('Unknown tool: made_up'), 'a tool never advertised should be reported as unknown');
|
|
144
|
-
const call = allToolCalls(driver).find((tc) => tc.name === 'made_up');
|
|
145
|
-
assert.ok(call && isChatToolCallUnknown(call), 'the call should be flagged unknown');
|
|
146
|
-
assert.not.ok(call.stale, 'a hallucinated tool must NOT be flagged stale');
|
|
147
|
-
assert.ok(unresolvedEvents(sessionKey).some((d) => d.kind === 'unknown' && d.tool === 'made_up'), 'an unknown tool.unresolved meta event should be recorded');
|
|
148
|
-
}));
|
|
149
|
-
stale('points the model at the close tool when an exclusive fold hides a base tool', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
150
|
-
var _a, _b;
|
|
151
|
-
const fold = createToolFold({
|
|
152
|
-
name: 'my_fold',
|
|
153
|
-
tools: [def('inner_tool')],
|
|
154
|
-
handlers: { inner_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'inner done'; }) },
|
|
155
|
-
// exclusive defaults to true — opening it removes base_tool from the set.
|
|
156
|
-
});
|
|
157
|
-
const config = agent({
|
|
158
|
-
name: 'Folded',
|
|
159
|
-
toolDefinitions: [def('base_tool'), fold.definition],
|
|
160
|
-
toolHandlers: Object.assign({ base_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'base done'; }) }, fold.handler),
|
|
161
|
-
});
|
|
162
|
-
const provider = scriptedProvider([
|
|
163
|
-
callsTool('my_fold', 'f1'), // open the exclusive fold — base_tool now hidden
|
|
164
|
-
callsTool('base_tool', 'b1'), // hidden behind the open fold
|
|
165
|
-
]);
|
|
166
|
-
const sessionKey = 'fold-meta-test';
|
|
167
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
168
|
-
yield driver.sendMessage('go');
|
|
169
|
-
// Target the base_tool result specifically — the fold-open result also
|
|
170
|
-
// mentions my_fold, so match on the tool call id rather than substring.
|
|
171
|
-
const guidance = (_b = (_a = driver
|
|
172
|
-
.getHistory()
|
|
173
|
-
.find((m) => { var _a; return m.role === 'tool' && ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'b1'; })) === null || _a === void 0 ? void 0 : _a.toolResult) === null || _b === void 0 ? void 0 : _b.content;
|
|
174
|
-
assert.ok(guidance, 'calling a fold-hidden tool should produce guidance');
|
|
175
|
-
assert.match(guidance, /not available while the "my_fold" fold is open/);
|
|
176
|
-
assert.match(guidance, /close_my_fold/);
|
|
177
|
-
const hidden = allToolCalls(driver).find((tc) => tc.name === 'base_tool' && isChatToolCallUnknown(tc));
|
|
178
|
-
assert.ok(hidden && isChatToolCallUnknown(hidden) && hidden.stale === true, 'the hidden call is stale');
|
|
179
|
-
assert.ok(unresolvedEvents(sessionKey).some((d) => d.kind === 'fold-hidden' && d.tool === 'base_tool' && d.fold === 'my_fold'), 'a fold-hidden tool.unresolved meta event should be recorded');
|
|
180
|
-
}));
|
|
181
|
-
stale('splits stale vs hallucinated tools on the unknown-tool-limit error', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
182
|
-
const sessionKey = 'stale-limit-test';
|
|
183
|
-
clearMetaEventRegistry();
|
|
184
|
-
let state = 'A';
|
|
185
|
-
const config = agent({
|
|
186
|
-
name: 'Stateful',
|
|
187
|
-
toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
|
|
188
|
-
toolHandlers: () => state === 'A'
|
|
189
|
-
? {
|
|
190
|
-
tool_a: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
191
|
-
state = 'B';
|
|
192
|
-
return 'advanced to B';
|
|
193
|
-
}),
|
|
194
|
-
}
|
|
195
|
-
: { tool_b: () => __awaiter(void 0, void 0, void 0, function* () { return 'b done'; }) },
|
|
196
|
-
});
|
|
197
|
-
// One real call to advance to B, then 10 consecutive stale calls — the 10th
|
|
198
|
-
// trips the stale ceiling (MAX_STALE_TOOL_CALLS, 2x the hallucination limit) and ends the turn.
|
|
199
|
-
const provider = scriptedProvider([
|
|
200
|
-
callsTool('tool_a', 'real'),
|
|
201
|
-
...Array.from({ length: 10 }, (_unused, i) => callsTool('tool_a', `stale-${i}`)),
|
|
202
|
-
]);
|
|
203
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
204
|
-
const result = yield driver.sendMessage('go');
|
|
205
|
-
assert.is(result.reason, 'done');
|
|
206
|
-
const limitError = getMetaEvents(sessionKey).find((e) => { var _a; return e.type === 'turn.error' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.reason) === 'unknown-tool-limit'; });
|
|
207
|
-
assert.ok(limitError, 'hitting the limit should record an unknown-tool-limit turn.error');
|
|
208
|
-
const detail = limitError.detail;
|
|
209
|
-
assert.equal(detail.staleTools, ['tool_a'], 'tool_a should be classified as stale');
|
|
210
|
-
assert.equal(detail.hallucinatedTools, [], 'nothing was hallucinated');
|
|
211
|
-
// Every stale attempt — not just the final limit error — is in the download log.
|
|
212
|
-
assert.is(unresolvedEvents(sessionKey).filter((d) => d.kind === 'stale').length, 10, 'each stale attempt should be recorded as its own tool.unresolved event');
|
|
213
|
-
// The user-facing turn ends with the apology, not a crash.
|
|
214
|
-
const last = driver.getHistory().at(-1);
|
|
215
|
-
assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant' && last.content.startsWith("I'm sorry"));
|
|
216
|
-
}));
|
|
217
|
-
stale.run();
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
// onUnresolvedTool hook — an agent can redirect an unresolved tool call
|
|
220
|
-
// ---------------------------------------------------------------------------
|
|
221
|
-
const hook = createLogicSuite('ChatDriver onUnresolvedTool hook');
|
|
222
|
-
hook('replaces the default for a hallucinated tool when the hook returns a string', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
223
|
-
const config = agent({
|
|
224
|
-
name: 'Hooked',
|
|
225
|
-
toolDefinitions: [def('real_tool')],
|
|
226
|
-
toolHandlers: { real_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
227
|
-
onUnresolvedTool: ({ toolName, kind, availableTools }) => `redirect: ${toolName} is ${kind}; use ${availableTools.join(', ')}`,
|
|
228
|
-
});
|
|
229
|
-
const provider = scriptedProvider([callsTool('made_up', 'm1')]);
|
|
230
|
-
const driver = makeDriver(config, provider);
|
|
231
|
-
yield driver.sendMessage('go');
|
|
232
|
-
assert.ok(toolResultContents(driver).includes('redirect: made_up is unknown; use real_tool'), 'the hook string should replace the default Unknown tool message');
|
|
233
|
-
assert.not.ok(toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')), 'the default unknown-tool message must not appear');
|
|
234
|
-
}));
|
|
235
|
-
hook('replaces the default for a stale tool when the hook returns a string', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
236
|
-
let state = 'A';
|
|
237
|
-
const config = agent({
|
|
238
|
-
name: 'HookedStateful',
|
|
239
|
-
toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
|
|
240
|
-
toolHandlers: () => state === 'A'
|
|
241
|
-
? {
|
|
242
|
-
tool_a: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
243
|
-
state = 'B';
|
|
244
|
-
return 'advanced to B';
|
|
245
|
-
}),
|
|
246
|
-
}
|
|
247
|
-
: { tool_b: () => __awaiter(void 0, void 0, void 0, function* () { return 'b done'; }) },
|
|
248
|
-
onUnresolvedTool: ({ toolName, kind }) => `redirect: ${toolName}/${kind}`,
|
|
249
|
-
});
|
|
250
|
-
const provider = scriptedProvider([
|
|
251
|
-
callsTool('tool_a', 't1'), // real — advances A -> B
|
|
252
|
-
callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
|
|
253
|
-
callsTool('tool_b', 't3'), // real — valid in state B
|
|
254
|
-
]);
|
|
255
|
-
const driver = makeDriver(config, provider);
|
|
256
|
-
yield driver.sendMessage('go');
|
|
257
|
-
assert.ok(toolResultContents(driver).includes('redirect: tool_a/stale'), 'the hook string should replace the default stale message');
|
|
258
|
-
assert.not.ok(toolResultContents(driver).some((c) => c.includes('was available earlier but is not part of the current step')), 'the default stale message must not appear');
|
|
259
|
-
}));
|
|
260
|
-
hook('falls back to the default when the hook returns undefined', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
261
|
-
const config = agent({
|
|
262
|
-
name: 'HookUndefined',
|
|
263
|
-
toolDefinitions: [def('real_tool')],
|
|
264
|
-
toolHandlers: { real_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
265
|
-
onUnresolvedTool: () => undefined,
|
|
266
|
-
});
|
|
267
|
-
const provider = scriptedProvider([callsTool('made_up', 'm1')]);
|
|
268
|
-
const driver = makeDriver(config, provider);
|
|
269
|
-
yield driver.sendMessage('go');
|
|
270
|
-
assert.ok(toolResultContents(driver).includes('Unknown tool: made_up'), 'an undefined hook result should fall back to the default message');
|
|
271
|
-
}));
|
|
272
|
-
hook('falls back to the default when the hook returns a whitespace-only string', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
273
|
-
const config = agent({
|
|
274
|
-
name: 'HookEmpty',
|
|
275
|
-
toolDefinitions: [def('real_tool')],
|
|
276
|
-
toolHandlers: { real_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
277
|
-
onUnresolvedTool: () => ' ',
|
|
278
|
-
});
|
|
279
|
-
const provider = scriptedProvider([callsTool('made_up', 'm1')]);
|
|
280
|
-
const driver = makeDriver(config, provider);
|
|
281
|
-
yield driver.sendMessage('go');
|
|
282
|
-
assert.ok(toolResultContents(driver).includes('Unknown tool: made_up'), 'a whitespace-only hook result should fall back to the default message');
|
|
283
|
-
}));
|
|
284
|
-
hook('falls back to the default when the hook throws', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
285
|
-
const config = agent({
|
|
286
|
-
name: 'HookThrows',
|
|
287
|
-
toolDefinitions: [def('real_tool')],
|
|
288
|
-
toolHandlers: { real_tool: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
289
|
-
onUnresolvedTool: () => {
|
|
290
|
-
throw new Error('boom');
|
|
291
|
-
},
|
|
292
|
-
});
|
|
293
|
-
const provider = scriptedProvider([callsTool('made_up', 'm1')]);
|
|
294
|
-
const driver = makeDriver(config, provider);
|
|
295
|
-
yield driver.sendMessage('go');
|
|
296
|
-
assert.ok(toolResultContents(driver).includes('Unknown tool: made_up'), 'a throwing hook should fall back to the default message');
|
|
297
|
-
}));
|
|
298
|
-
hook.run();
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
// timeout handling — a transport `TimeoutError` surfaces a distinct message
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
const timeout = createLogicSuite('ChatDriver timeout handling');
|
|
303
|
-
/** A provider whose `chat` rejects with the `TimeoutError` the transports tag
|
|
304
|
-
* a request timeout with (see gemini/anthropic `post`). */
|
|
305
|
-
const timesOutProvider = () => ({
|
|
306
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
307
|
-
throw new DOMException('Gemini request timed out', 'TimeoutError');
|
|
308
|
-
}),
|
|
309
|
-
});
|
|
310
|
-
timeout('surfaces a timeout-specific message instead of the generic failure', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
311
|
-
var _a, _b;
|
|
312
|
-
clearMetaEventRegistry();
|
|
313
|
-
const config = agent({
|
|
314
|
-
name: 'Static',
|
|
315
|
-
toolDefinitions: [def('noop')],
|
|
316
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
317
|
-
});
|
|
318
|
-
const sessionKey = 'timeout-test';
|
|
319
|
-
const driver = makeDriver(config, timesOutProvider(), sessionKey);
|
|
320
|
-
const result = yield driver.sendMessage('go');
|
|
321
|
-
assert.is(result.reason, 'done');
|
|
322
|
-
// The turn ends with the timeout message, NOT the generic "something went wrong".
|
|
323
|
-
const last = driver.getHistory().at(-1);
|
|
324
|
-
assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
|
|
325
|
-
assert.ok(last.content.includes('timed out'), 'message names the timeout');
|
|
326
|
-
assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic catch');
|
|
327
|
-
// Recorded distinctly in the debug log: a turn.error tagged TimeoutError,
|
|
328
|
-
// not swallowed silently.
|
|
329
|
-
const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
|
|
330
|
-
assert.ok(err, 'a turn.error should be recorded');
|
|
331
|
-
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.reason, 'exception');
|
|
332
|
-
assert.is((_b = err.detail) === null || _b === void 0 ? void 0 : _b.name, 'TimeoutError');
|
|
333
|
-
}));
|
|
334
|
-
timeout.run();
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
// cancellation — dispose() aborts the in-flight provider call (lifecycle signal)
|
|
337
|
-
// ---------------------------------------------------------------------------
|
|
338
|
-
const cancel = createLogicSuite('ChatDriver cancellation');
|
|
339
|
-
cancel('threads a non-aborted signal into the provider call by default', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
340
|
-
const config = agent({
|
|
341
|
-
name: 'Static',
|
|
342
|
-
toolDefinitions: [def('noop')],
|
|
343
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
344
|
-
});
|
|
345
|
-
const capture = {};
|
|
346
|
-
const provider = {
|
|
347
|
-
chat: (_h, _m, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
348
|
-
capture.signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
349
|
-
return { role: 'assistant', content: 'hi' };
|
|
350
|
-
}),
|
|
351
|
-
};
|
|
352
|
-
const driver = makeDriver(config, provider, 'signal-plumb');
|
|
353
|
-
yield driver.sendMessage('go');
|
|
354
|
-
assert.ok(capture.signal, 'provider received an options.signal');
|
|
355
|
-
assert.not.ok(capture.signal.aborted, 'and it is not aborted on a normal turn');
|
|
356
|
-
}));
|
|
357
|
-
cancel('dispose() aborts the in-flight request and stops quietly', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
358
|
-
var _a;
|
|
359
|
-
clearMetaEventRegistry();
|
|
360
|
-
const config = agent({
|
|
361
|
-
name: 'Static',
|
|
362
|
-
toolDefinitions: [def('noop')],
|
|
363
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
364
|
-
});
|
|
365
|
-
// A provider that hangs until its signal aborts, then rejects with the
|
|
366
|
-
// signal's reason — mirroring how the transport surfaces an aborted fetch.
|
|
367
|
-
const capture = {};
|
|
368
|
-
const provider = {
|
|
369
|
-
chat: (_h, _m, options) => {
|
|
370
|
-
const signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
371
|
-
capture.signal = signal;
|
|
372
|
-
return new Promise((_resolve, reject) => {
|
|
373
|
-
if (!signal)
|
|
374
|
-
return;
|
|
375
|
-
if (signal.aborted) {
|
|
376
|
-
reject(signal.reason);
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
380
|
-
});
|
|
381
|
-
},
|
|
382
|
-
};
|
|
383
|
-
const driver = makeDriver(config, provider, 'cancel-test');
|
|
384
|
-
const pending = driver.sendMessage('go');
|
|
385
|
-
driver.dispose();
|
|
386
|
-
const result = yield pending;
|
|
387
|
-
assert.is(result.reason, 'done');
|
|
388
|
-
assert.ok((_a = capture.signal) === null || _a === void 0 ? void 0 : _a.aborted, 'the provider signal aborted on dispose');
|
|
389
|
-
assert.is(capture.signal.reason.name, 'AbortError');
|
|
390
|
-
// Quiet: no assistant message appended (would otherwise pollute cached history).
|
|
391
|
-
const assistantMsgs = driver.getHistory().filter((m) => m.role === 'assistant');
|
|
392
|
-
assert.is(assistantMsgs.length, 0, 'no message appended on a disposal abort');
|
|
393
|
-
assert.not.ok(driver.getHistory().some((m) => { var _a; return (_a = m.content) === null || _a === void 0 ? void 0 : _a.includes('something went wrong'); }), 'must not surface the generic failure');
|
|
394
|
-
}));
|
|
395
|
-
cancel('cancel() stops the turn with a "Stopped." marker and leaves the driver usable', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
396
|
-
clearMetaEventRegistry();
|
|
397
|
-
const config = agent({
|
|
398
|
-
name: 'Static',
|
|
399
|
-
toolDefinitions: [def('noop')],
|
|
400
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
401
|
-
});
|
|
402
|
-
let call = 0;
|
|
403
|
-
const provider = {
|
|
404
|
-
chat: (_h, _m, options) => {
|
|
405
|
-
call += 1;
|
|
406
|
-
if (call === 1) {
|
|
407
|
-
// First turn hangs until its signal aborts (the user cancel). Must also
|
|
408
|
-
// handle the already-aborted case: cancel can land before chat runs
|
|
409
|
-
// (during the provider-resolution await), so the signal may arrive
|
|
410
|
-
// aborted — mirror how real fetch rejects immediately in that case.
|
|
411
|
-
const signal = options.signal;
|
|
412
|
-
return new Promise((_resolve, reject) => {
|
|
413
|
-
if (signal.aborted) {
|
|
414
|
-
reject(signal.reason);
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
// Second turn resolves normally — proves the driver wasn't bricked.
|
|
421
|
-
return Promise.resolve({ role: 'assistant', content: 'second response' });
|
|
422
|
-
},
|
|
423
|
-
};
|
|
424
|
-
const driver = makeDriver(config, provider, 'cancel-stop');
|
|
425
|
-
const pending = driver.sendMessage('go');
|
|
426
|
-
driver.cancel();
|
|
427
|
-
const result = yield pending;
|
|
428
|
-
assert.is(result.reason, 'done');
|
|
429
|
-
// Subtle 'Stopped.' marker (system-event, not a generic failure).
|
|
430
|
-
const last = driver.getHistory().at(-1);
|
|
431
|
-
assert.is(last === null || last === void 0 ? void 0 : last.role, 'system-event');
|
|
432
|
-
assert.is(last === null || last === void 0 ? void 0 : last.content, 'Stopped.');
|
|
433
|
-
// The driver is still usable for the next message.
|
|
434
|
-
const second = yield driver.sendMessage('again');
|
|
435
|
-
assert.is(second.reason, 'done');
|
|
436
|
-
assert.ok(driver.getHistory().some((m) => m.content === 'second response'), 'a fresh turn runs after a cancel');
|
|
437
|
-
}));
|
|
438
|
-
cancel('a tool already running finishes, then the loop stops at the boundary', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
439
|
-
let toolFinished = false;
|
|
440
|
-
let secondChatCalled = false;
|
|
441
|
-
const config = agent({
|
|
442
|
-
name: 'Static',
|
|
443
|
-
toolDefinitions: [def('slow')],
|
|
444
|
-
toolHandlers: {
|
|
445
|
-
slow: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
446
|
-
// Cancel mid-tool: the tool must still run to completion.
|
|
447
|
-
driver.cancel();
|
|
448
|
-
yield Promise.resolve();
|
|
449
|
-
toolFinished = true;
|
|
450
|
-
return 'tool done';
|
|
451
|
-
}),
|
|
452
|
-
},
|
|
453
|
-
});
|
|
454
|
-
let call = 0;
|
|
455
|
-
const provider = {
|
|
456
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
457
|
-
call += 1;
|
|
458
|
-
if (call === 1) {
|
|
459
|
-
return {
|
|
460
|
-
role: 'assistant',
|
|
461
|
-
content: '',
|
|
462
|
-
toolCalls: [{ id: 't1', name: 'slow', args: {} }],
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
secondChatCalled = true;
|
|
466
|
-
return { role: 'assistant', content: 'should not be reached' };
|
|
467
|
-
}),
|
|
468
|
-
};
|
|
469
|
-
const driver = makeDriver(config, provider, 'cancel-tool');
|
|
470
|
-
const result = yield driver.sendMessage('go');
|
|
471
|
-
assert.is(result.reason, 'done');
|
|
472
|
-
assert.ok(toolFinished, 'the in-flight tool ran to completion (tools are atomic)');
|
|
473
|
-
assert.not.ok(secondChatCalled, 'no further LLM call is made after cancel');
|
|
474
|
-
const last = driver.getHistory().at(-1);
|
|
475
|
-
assert.is(last === null || last === void 0 ? void 0 : last.role, 'system-event');
|
|
476
|
-
assert.is(last === null || last === void 0 ? void 0 : last.content, 'Stopped.');
|
|
477
|
-
}));
|
|
478
|
-
cancel('cancel() is a no-op when no turn is running', () => {
|
|
479
|
-
const config = agent({
|
|
480
|
-
name: 'Static',
|
|
481
|
-
toolDefinitions: [def('noop')],
|
|
482
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
483
|
-
});
|
|
484
|
-
const driver = makeDriver(config, scriptedProvider([]), 'cancel-idle');
|
|
485
|
-
driver.cancel(); // must not throw or append anything while idle
|
|
486
|
-
assert.is(driver.getHistory().length, 0);
|
|
487
|
-
});
|
|
488
|
-
cancel.run();
|
|
489
|
-
// ---------------------------------------------------------------------------
|
|
490
|
-
// sub-agents — forced tool use + typed completion/failure union (GENC-1312)
|
|
491
|
-
//
|
|
492
|
-
// A child sub-agent driver shares the parent's provider registry, so one
|
|
493
|
-
// scripted queue drives both: script the parent's delegating turn, then the
|
|
494
|
-
// worker's turn(s), in order.
|
|
495
|
-
// ---------------------------------------------------------------------------
|
|
496
|
-
const subagent = createLogicSuite('ChatDriver sub-agents');
|
|
497
|
-
/** A sub-agent named `worker` that finishes by calling `completeSubAgent`. */
|
|
498
|
-
const completingWorker = (result) => agent({
|
|
499
|
-
name: 'worker',
|
|
500
|
-
toolDefinitions: [def('finish')],
|
|
501
|
-
toolHandlers: {
|
|
502
|
-
finish: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
503
|
-
var _a;
|
|
504
|
-
(_a = ctx.completeSubAgent) === null || _a === void 0 ? void 0 : _a.call(ctx, result);
|
|
505
|
-
return 'finished';
|
|
506
|
-
}),
|
|
507
|
-
},
|
|
508
|
-
});
|
|
509
|
-
/** A parent that delegates to `worker` and reports the outcome via `capture`. */
|
|
510
|
-
const delegatingParent = (sub, capture) => agent({
|
|
511
|
-
name: 'boss',
|
|
512
|
-
subAgents: [sub],
|
|
513
|
-
toolDefinitions: [def('delegate')],
|
|
514
|
-
toolHandlers: {
|
|
515
|
-
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
516
|
-
const outcome = yield ctx.requestSubAgent('worker', { task: 'do it' });
|
|
517
|
-
capture(outcome);
|
|
518
|
-
return outcome.ok ? 'sub-agent completed' : `sub-agent failed: ${outcome.reason}`;
|
|
519
|
-
}),
|
|
520
|
-
},
|
|
521
|
-
});
|
|
522
|
-
subagent('resolves { ok: true, result } when the sub-agent calls completeSubAgent', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
523
|
-
var _a, _b, _c, _d, _e;
|
|
524
|
-
const sessionKey = 'subagent-success-events';
|
|
525
|
-
clearMetaEventRegistry();
|
|
526
|
-
let outcome;
|
|
527
|
-
const parent = delegatingParent(completingWorker({ value: 42 }), (o) => {
|
|
528
|
-
outcome = o;
|
|
529
|
-
});
|
|
530
|
-
const provider = scriptedProvider([
|
|
531
|
-
callsTool('delegate', 'd1'), // parent delegates to the worker
|
|
532
|
-
callsTool('finish', 'f1'), // worker completes
|
|
533
|
-
]);
|
|
534
|
-
const driver = makeDriver(parent, provider, sessionKey);
|
|
535
|
-
yield driver.sendMessage('go');
|
|
536
|
-
assert.equal(outcome, { ok: true, result: { value: 42 } });
|
|
537
|
-
// The delegation is bracketed on the parent timeline by started + completed
|
|
538
|
-
// (no subagent.failed), mirroring turn.start/turn.end.
|
|
539
|
-
const subEvents = getMetaEvents(sessionKey).filter((e) => e.type.startsWith('subagent.'));
|
|
540
|
-
assert.equal(subEvents.map((e) => e.type), ['subagent.started', 'subagent.completed']);
|
|
541
|
-
assert.is((_a = subEvents[0].detail) === null || _a === void 0 ? void 0 : _a.agent, 'worker');
|
|
542
|
-
assert.is((_b = subEvents[1].detail) === null || _b === void 0 ? void 0 : _b.agent, 'worker');
|
|
543
|
-
// The sub-agent trace lives on the tool CALL (read by the UI trace panel and
|
|
544
|
-
// sumCosts) and is NOT duplicated onto the tool RESULT.
|
|
545
|
-
const callMsg = driver.getHistory().find((m) => { var _a; return (_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.some((c) => c.name === 'delegate'); });
|
|
546
|
-
const delegateCall = (_c = callMsg === null || callMsg === void 0 ? void 0 : callMsg.toolCalls) === null || _c === void 0 ? void 0 : _c.find((c) => c.name === 'delegate');
|
|
547
|
-
assert.ok((_d = delegateCall === null || delegateCall === void 0 ? void 0 : delegateCall.subAgentTrace) === null || _d === void 0 ? void 0 : _d.length, 'the tool call carries the sub-agent trace');
|
|
548
|
-
const resultMsg = driver.getHistory().find((m) => m.role === 'tool' && !!m.toolResult);
|
|
549
|
-
assert.not.ok((_e = resultMsg === null || resultMsg === void 0 ? void 0 : resultMsg.toolResult) === null || _e === void 0 ? void 0 : _e.subAgentTrace, 'the tool result must not duplicate the trace');
|
|
550
|
-
}));
|
|
551
|
-
subagent('forces tool use on the sub-agent turn but not the parent turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
552
|
-
const parent = delegatingParent(completingWorker({ done: true }), () => { });
|
|
553
|
-
const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
|
|
554
|
-
yield makeDriver(parent, provider).sendMessage('go');
|
|
555
|
-
// Call 0 is the parent's turn (may-call); call 1 is the worker's turn (must-call).
|
|
556
|
-
assert.is(provider.toolChoicePerCall[0], undefined, 'parent turn is not forced');
|
|
557
|
-
assert.is(provider.toolChoicePerCall[1], 'required', 'sub-agent turn forces a tool call');
|
|
558
|
-
assert.ok(provider.advertisedPerCall[1].includes('finish'), 'the worker advertised its completion tool');
|
|
559
|
-
}));
|
|
560
|
-
subagent('resolves { ok: false, reason } and records telemetry when the sub-agent never completes', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
561
|
-
const sessionKey = 'subagent-unknown-tool-test';
|
|
562
|
-
clearMetaEventRegistry();
|
|
563
|
-
let outcome;
|
|
564
|
-
const worker = agent({
|
|
565
|
-
name: 'worker',
|
|
566
|
-
toolDefinitions: [def('real')],
|
|
567
|
-
toolHandlers: { real: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
568
|
-
});
|
|
569
|
-
const parent = delegatingParent(worker, (o) => {
|
|
570
|
-
outcome = o;
|
|
571
|
-
});
|
|
572
|
-
// The worker repeatedly calls a tool it was never given, tripping the
|
|
573
|
-
// unknown-tool limit (DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5) without completing.
|
|
574
|
-
const provider = scriptedProvider([
|
|
575
|
-
callsTool('delegate', 'd1'),
|
|
576
|
-
...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
|
|
577
|
-
]);
|
|
578
|
-
yield makeDriver(parent, provider, sessionKey).sendMessage('go');
|
|
579
|
-
assert.equal(outcome, { ok: false, reason: 'unknown_tool_limit' });
|
|
580
|
-
// The failure surfaces as a high-importance `subagent.failed` meta event,
|
|
581
|
-
// recorded under the PARENT driver's session so it lands on the user-visible
|
|
582
|
-
// debug-log timeline — not orphaned in the child's own session bucket.
|
|
583
|
-
assert.ok(getMetaEvents(sessionKey).some((e) => {
|
|
584
|
-
var _a, _b;
|
|
585
|
-
return e.type === 'subagent.failed' &&
|
|
586
|
-
((_a = e.detail) === null || _a === void 0 ? void 0 : _a.agent) === 'worker' &&
|
|
587
|
-
((_b = e.detail) === null || _b === void 0 ? void 0 : _b.reason) === 'unknown_tool_limit';
|
|
588
|
-
}), 'a subagent.failed meta event should be recorded under the parent session');
|
|
589
|
-
assert.not.ok(getMetaEvents('').some((e) => e.type === 'subagent.failed'), 'the failure must not be orphaned in the child default session bucket');
|
|
590
|
-
}));
|
|
591
|
-
subagent("folds the sub-agent's high-value meta events into the parent session, breadcrumbed", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
592
|
-
var _a;
|
|
593
|
-
const sessionKey = 'subagent-meta-harvest-test';
|
|
594
|
-
clearMetaEventRegistry();
|
|
595
|
-
const worker = agent({
|
|
596
|
-
name: 'worker',
|
|
597
|
-
toolDefinitions: [def('real')],
|
|
598
|
-
toolHandlers: { real: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
599
|
-
});
|
|
600
|
-
const parent = delegatingParent(worker, () => { });
|
|
601
|
-
// The worker trips the unknown-tool limit — emitting tool.unresolved + a
|
|
602
|
-
// turn.error under its own transient session, which the parent harvests.
|
|
603
|
-
const provider = scriptedProvider([
|
|
604
|
-
callsTool('delegate', 'd1'),
|
|
605
|
-
...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
|
|
606
|
-
]);
|
|
607
|
-
yield makeDriver(parent, provider, sessionKey).sendMessage('go');
|
|
608
|
-
const events = getMetaEvents(sessionKey);
|
|
609
|
-
// The sub-agent's turn.error is folded onto the parent timeline, breadcrumbed.
|
|
610
|
-
const harvestedError = events.find((e) => { var _a; return e.type === 'turn.error' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.subAgent) === 'boss › worker'; });
|
|
611
|
-
assert.ok(harvestedError, "the sub-agent's turn.error is folded in, breadcrumbed");
|
|
612
|
-
assert.is((_a = harvestedError.detail) === null || _a === void 0 ? void 0 : _a.reason, 'unknown-tool-limit');
|
|
613
|
-
// ... as are its per-attempt tool.unresolved signals.
|
|
614
|
-
assert.ok(events.some((e) => { var _a; return e.type === 'tool.unresolved' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.subAgent) === 'boss › worker'; }), "the sub-agent's tool.unresolved events are folded in too");
|
|
615
|
-
// High-volume, message-derivable events are NOT merged.
|
|
616
|
-
assert.not.ok(events.some((e) => { var _a; return e.type === 'turn.start' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.subAgent) === 'boss › worker'; }), 'turn.start is intentionally excluded from the harvest');
|
|
617
|
-
// The child's transient bucket is cleared, never polluting the empty-key sink.
|
|
618
|
-
assert.is(getMetaEvents('').length, 0);
|
|
619
|
-
}));
|
|
620
|
-
subagent('defaults to { ok: false, reason: "max_iterations" } when the sub-agent ends without completing', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
621
|
-
const sessionKey = 'subagent-default-fail-test';
|
|
622
|
-
clearMetaEventRegistry();
|
|
623
|
-
let outcome;
|
|
624
|
-
const worker = agent({
|
|
625
|
-
name: 'worker',
|
|
626
|
-
toolDefinitions: [def('noop')],
|
|
627
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
628
|
-
});
|
|
629
|
-
const parent = delegatingParent(worker, (o) => {
|
|
630
|
-
outcome = o;
|
|
631
|
-
});
|
|
632
|
-
// No script for the worker turn → it returns a plain-text reply and ends
|
|
633
|
-
// without ever calling a completion tool (the child records no explicit
|
|
634
|
-
// failure reason).
|
|
635
|
-
const provider = scriptedProvider([callsTool('delegate', 'd1')]);
|
|
636
|
-
yield makeDriver(parent, provider, sessionKey).sendMessage('go');
|
|
637
|
-
assert.equal(outcome, { ok: false, reason: 'max_iterations' });
|
|
638
|
-
// Even the defensive default is reported to the parent session — this is the
|
|
639
|
-
// only telemetry path when the child recorded no explicit failure.
|
|
640
|
-
assert.ok(getMetaEvents(sessionKey).some((e) => { var _a; return e.type === 'subagent.failed' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.reason) === 'max_iterations'; }), 'the default failure should still record a subagent.failed meta event');
|
|
641
|
-
}));
|
|
642
|
-
subagent("forwards the sub-agent's turns onto the parent timeline, numbered under the activating turn", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
643
|
-
const parent = delegatingParent(completingWorker({ done: true }), () => { });
|
|
644
|
-
const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
|
|
645
|
-
const driver = makeDriver(parent, provider);
|
|
646
|
-
yield driver.sendMessage('go');
|
|
647
|
-
const snaps = driver.getTurnSnapshots();
|
|
648
|
-
// Parent turn 0 activated the sub-agent, so the worker's single turn is "0-1".
|
|
649
|
-
const childSnap = snaps.find((s) => s.turnIndex === '0-1');
|
|
650
|
-
assert.ok(childSnap, 'the sub-agent\'s turn should be forwarded as "0-1"');
|
|
651
|
-
assert.is(childSnap.agentName, 'worker', 'the forwarded snapshot keeps the sub-agent name');
|
|
652
|
-
assert.ok(childSnap.toolNames.includes('finish'), 'and records the tools the sub-agent saw');
|
|
653
|
-
// The parent's own turns stay numeric.
|
|
654
|
-
assert.ok(snaps.some((s) => s.turnIndex === '0'), 'the activating parent turn is present as a bare string counter');
|
|
655
|
-
}));
|
|
656
|
-
subagent('resolves { ok: false, reason: "timeout" } and aborts the run when the sub-agent exceeds timeoutMs', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
657
|
-
const sessionKey = 'subagent-timeout-test';
|
|
658
|
-
clearMetaEventRegistry();
|
|
659
|
-
let outcome;
|
|
660
|
-
// The worker would complete if allowed to run — but its turn hangs (below),
|
|
661
|
-
// so the parent's timeout fires first.
|
|
662
|
-
const worker = agent({
|
|
663
|
-
name: 'worker',
|
|
664
|
-
toolDefinitions: [def('finish')],
|
|
665
|
-
toolHandlers: {
|
|
666
|
-
finish: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
667
|
-
var _a;
|
|
668
|
-
(_a = ctx.completeSubAgent) === null || _a === void 0 ? void 0 : _a.call(ctx, { ok: true });
|
|
669
|
-
return 'finished';
|
|
670
|
-
}),
|
|
671
|
-
},
|
|
672
|
-
});
|
|
673
|
-
const parent = agent({
|
|
674
|
-
name: 'boss',
|
|
675
|
-
subAgents: [worker],
|
|
676
|
-
toolDefinitions: [def('delegate')],
|
|
677
|
-
toolHandlers: {
|
|
678
|
-
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
679
|
-
const o = yield ctx.requestSubAgent('worker', { task: 'do it', timeoutMs: 20 });
|
|
680
|
-
outcome = o;
|
|
681
|
-
return o.ok ? 'sub-agent completed' : `sub-agent failed: ${o.reason}`;
|
|
682
|
-
}),
|
|
683
|
-
},
|
|
684
|
-
});
|
|
685
|
-
// Call 0: parent delegates. Call 1: the worker's turn hangs until its signal
|
|
686
|
-
// aborts (the timeout-driven dispose). Call 2: the parent wraps up.
|
|
687
|
-
let call = 0;
|
|
688
|
-
const provider = {
|
|
689
|
-
chat: (_h, _m, options) => {
|
|
690
|
-
const n = call;
|
|
691
|
-
call += 1;
|
|
692
|
-
if (n === 0)
|
|
693
|
-
return Promise.resolve(callsTool('delegate', 'd1'));
|
|
694
|
-
if (n === 1) {
|
|
695
|
-
return new Promise((_resolve, reject) => {
|
|
696
|
-
const signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
697
|
-
if (!signal)
|
|
698
|
-
return;
|
|
699
|
-
if (signal.aborted) {
|
|
700
|
-
reject(signal.reason);
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
return Promise.resolve({ role: 'assistant', content: 'done' });
|
|
707
|
-
},
|
|
708
|
-
};
|
|
709
|
-
yield makeDriver(parent, provider, sessionKey).sendMessage('go');
|
|
710
|
-
assert.equal(outcome, { ok: false, reason: 'timeout' });
|
|
711
|
-
// Recorded under the parent session, like every other sub-agent failure.
|
|
712
|
-
assert.ok(getMetaEvents(sessionKey).some((e) => { var _a; return e.type === 'subagent.failed' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.reason) === 'timeout'; }), 'a subagent.failed meta event with reason "timeout" should be recorded');
|
|
713
|
-
}));
|
|
714
|
-
subagent('times out (without hanging) when the sub-agent is parked on a user interaction', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
715
|
-
const sessionKey = 'subagent-interaction-timeout';
|
|
716
|
-
clearMetaEventRegistry();
|
|
717
|
-
let outcome;
|
|
718
|
-
// The worker asks a question and waits; the user never answers. The parent's
|
|
719
|
-
// timeout must cancel that interaction and unblock the worker — if it didn't,
|
|
720
|
-
// `await runPromise` inside invokeSubAgent would hang here forever (the worker
|
|
721
|
-
// is parked on a promise only the parent can settle), and this test would
|
|
722
|
-
// never resolve.
|
|
723
|
-
const worker = agent({
|
|
724
|
-
name: 'worker',
|
|
725
|
-
toolDefinitions: [def('ask')],
|
|
726
|
-
toolHandlers: {
|
|
727
|
-
ask: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
728
|
-
const r = yield ctx.requestInteraction('q', {}); // parks on the parent
|
|
729
|
-
return { answered: r };
|
|
730
|
-
}),
|
|
731
|
-
},
|
|
732
|
-
});
|
|
733
|
-
const parent = agent({
|
|
734
|
-
name: 'boss',
|
|
735
|
-
subAgents: [worker],
|
|
736
|
-
toolDefinitions: [def('delegate')],
|
|
737
|
-
toolHandlers: {
|
|
738
|
-
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
739
|
-
const o = yield ctx.requestSubAgent('worker', { task: 'ask', timeoutMs: 20 });
|
|
740
|
-
outcome = o;
|
|
741
|
-
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
742
|
-
}),
|
|
743
|
-
},
|
|
744
|
-
});
|
|
745
|
-
// call 0: parent delegates. call 1: worker asks (parks on the interaction).
|
|
746
|
-
// Later calls: the disposed worker's turn sees an aborted signal and rejects;
|
|
747
|
-
// the parent's resuming turn (not aborted) ends with text.
|
|
748
|
-
let call = 0;
|
|
749
|
-
const provider = {
|
|
750
|
-
chat: (_h, _m, options) => {
|
|
751
|
-
const n = call;
|
|
752
|
-
call += 1;
|
|
753
|
-
if (n === 0)
|
|
754
|
-
return Promise.resolve(callsTool('delegate', 'd1'));
|
|
755
|
-
if (n === 1)
|
|
756
|
-
return Promise.resolve(callsTool('ask', 'a1'));
|
|
757
|
-
const signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
758
|
-
if (signal === null || signal === void 0 ? void 0 : signal.aborted)
|
|
759
|
-
return Promise.reject(signal.reason);
|
|
760
|
-
return Promise.resolve({ role: 'assistant', content: 'done' });
|
|
761
|
-
},
|
|
762
|
-
};
|
|
763
|
-
yield makeDriver(parent, provider, sessionKey).sendMessage('go');
|
|
764
|
-
assert.equal(outcome, { ok: false, reason: 'timeout' });
|
|
765
|
-
assert.ok(getMetaEvents(sessionKey).some((e) => { var _a; return e.type === 'subagent.failed' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.reason) === 'timeout'; }), 'subagent.failed (timeout) should be recorded — proving it neither hung nor skipped logging');
|
|
766
|
-
}));
|
|
767
|
-
subagent('fails the sub-agent with reason "timeout" (no apology text) on a transport TimeoutError', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
768
|
-
const sessionKey = 'subagent-transport-timeout-test';
|
|
769
|
-
clearMetaEventRegistry();
|
|
770
|
-
let outcome;
|
|
771
|
-
const worker = agent({
|
|
772
|
-
name: 'worker',
|
|
773
|
-
toolDefinitions: [def('finish')],
|
|
774
|
-
toolHandlers: {
|
|
775
|
-
finish: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
776
|
-
var _a;
|
|
777
|
-
(_a = ctx.completeSubAgent) === null || _a === void 0 ? void 0 : _a.call(ctx, { ok: true });
|
|
778
|
-
return 'finished';
|
|
779
|
-
}),
|
|
780
|
-
},
|
|
781
|
-
});
|
|
782
|
-
const parent = delegatingParent(worker, (o) => {
|
|
783
|
-
outcome = o;
|
|
784
|
-
});
|
|
785
|
-
// Call 0: parent delegates. Call 1: the worker's request times out at the
|
|
786
|
-
// transport (tagged TimeoutError). Call 2: the parent wraps up.
|
|
787
|
-
let call = 0;
|
|
788
|
-
const provider = {
|
|
789
|
-
chat: () => {
|
|
790
|
-
const n = call;
|
|
791
|
-
call += 1;
|
|
792
|
-
if (n === 0)
|
|
793
|
-
return Promise.resolve(callsTool('delegate', 'd1'));
|
|
794
|
-
if (n === 1) {
|
|
795
|
-
return Promise.reject(new DOMException('Anthropic request timed out', 'TimeoutError'));
|
|
796
|
-
}
|
|
797
|
-
return Promise.resolve({ role: 'assistant', content: 'done' });
|
|
798
|
-
},
|
|
799
|
-
};
|
|
800
|
-
const driver = makeDriver(parent, provider, sessionKey);
|
|
801
|
-
yield driver.sendMessage('go');
|
|
802
|
-
// The parent gets a typed timeout — not the wrong max_iterations default it
|
|
803
|
-
// used to see when the child answered with the user-facing apology instead.
|
|
804
|
-
assert.equal(outcome, { ok: false, reason: 'timeout' });
|
|
805
|
-
assert.ok(getMetaEvents(sessionKey).some((e) => {
|
|
806
|
-
var _a, _b;
|
|
807
|
-
return e.type === 'subagent.failed' &&
|
|
808
|
-
((_a = e.detail) === null || _a === void 0 ? void 0 : _a.agent) === 'worker' &&
|
|
809
|
-
((_b = e.detail) === null || _b === void 0 ? void 0 : _b.reason) === 'timeout';
|
|
810
|
-
}), 'a subagent.failed meta event with reason "timeout" should be recorded');
|
|
811
|
-
// The child must not speak to the user.
|
|
812
|
-
assert.not.ok(driver.getHistory().some((m) => { var _a; return (_a = m.content) === null || _a === void 0 ? void 0 : _a.includes('The request timed out'); }), 'the sub-agent must not append the user-facing timeout apology');
|
|
813
|
-
}));
|
|
814
|
-
subagent.run();
|
|
815
|
-
// ---------------------------------------------------------------------------
|
|
816
|
-
// cost / token attribution on the thinking-step split (GENC-1410)
|
|
817
|
-
// ---------------------------------------------------------------------------
|
|
818
|
-
const costAttribution = createLogicSuite('ChatDriver cost attribution (thinking-step split)');
|
|
819
|
-
costAttribution('a text-plus-tool-call response contributes its cost and tokens exactly once', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
820
|
-
const config = agent({
|
|
821
|
-
name: 'Worker',
|
|
822
|
-
toolDefinitions: () => [def('do_thing')],
|
|
823
|
-
toolHandlers: () => ({ do_thing: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) }),
|
|
824
|
-
});
|
|
825
|
-
// A single response that emits reasoning + interstitial narration + a tool call, carrying that
|
|
826
|
-
// response's usage. The driver splits it into three messages — a `reasoning` bubble, a
|
|
827
|
-
// `narration` bubble, and the tool-call message — but only ONE may carry the cost/tokens or the
|
|
828
|
-
// session totals double-count this one response (GENC-1410).
|
|
829
|
-
const provider = scriptedProvider([
|
|
830
|
-
{
|
|
831
|
-
role: 'assistant',
|
|
832
|
-
content: 'Let me act.',
|
|
833
|
-
reasoning: 'Let me think.',
|
|
834
|
-
toolCalls: [{ id: 't1', name: 'do_thing', args: {} }],
|
|
835
|
-
cost: 0.05,
|
|
836
|
-
inputTokens: 1000,
|
|
837
|
-
outputTokens: 50,
|
|
838
|
-
},
|
|
839
|
-
// Turn-ending reply (no tool calls, no usage) so the loop terminates cleanly.
|
|
840
|
-
{ role: 'assistant', content: 'Done.' },
|
|
841
|
-
]);
|
|
842
|
-
const driver = makeDriver(config, provider);
|
|
843
|
-
yield driver.sendMessage('go');
|
|
844
|
-
const history = driver.getHistory();
|
|
845
|
-
const reasoning = history.find((m) => m.category === 'reasoning');
|
|
846
|
-
const narration = history.find((m) => m.category === 'narration');
|
|
847
|
-
const toolCallMsg = history.find((m) => { var _a; return (_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.some((c) => c.name === 'do_thing'); });
|
|
848
|
-
// The one response was split into reasoning + narration bubbles + the tool-call message.
|
|
849
|
-
assert.ok(reasoning, 'a reasoning bubble is emitted for the chain-of-thought summary');
|
|
850
|
-
assert.is(reasoning.content, 'Let me think.');
|
|
851
|
-
assert.ok(narration, 'a narration bubble is emitted for the interstitial prose');
|
|
852
|
-
assert.is(narration.content, 'Let me act.');
|
|
853
|
-
assert.ok(toolCallMsg, 'the tool call is on its own message');
|
|
854
|
-
assert.is(toolCallMsg.content, '');
|
|
855
|
-
// Only the tool-call message carries the turn's usage; the display bubbles carry none.
|
|
856
|
-
for (const m of [reasoning, narration]) {
|
|
857
|
-
assert.is(m.cost, undefined, 'a display bubble must not carry cost');
|
|
858
|
-
assert.is(m.inputTokens, undefined, 'nor inputTokens');
|
|
859
|
-
assert.is(m.outputTokens, undefined, 'nor outputTokens');
|
|
860
|
-
}
|
|
861
|
-
assert.is(toolCallMsg.cost, 0.05, 'the tool-call message carries the cost');
|
|
862
|
-
assert.is(toolCallMsg.inputTokens, 1000);
|
|
863
|
-
assert.is(toolCallMsg.outputTokens, 50);
|
|
864
|
-
// The reasoning/narration bubbles are UI-only; they must not carry the tool call either.
|
|
865
|
-
assert.is(reasoning.toolCalls, undefined, 'the reasoning bubble carries no tool call');
|
|
866
|
-
assert.is(narration.toolCalls, undefined, 'the narration bubble carries no tool call');
|
|
867
|
-
// The session totals count the one response once, not twice.
|
|
868
|
-
assert.is(sumCosts(history), 0.05, 'cost is summed once');
|
|
869
|
-
assert.is(sumTokens(history), 1050, 'tokens are summed once');
|
|
870
|
-
}));
|
|
871
|
-
costAttribution.run();
|
|
872
|
-
// ---------------------------------------------------------------------------
|
|
873
|
-
// per-agent / per-state temperature & tool-call mode (GENC-1321)
|
|
874
|
-
//
|
|
875
|
-
// The driver resolves `temperature` and `toolChoice` the same way it resolves
|
|
876
|
-
// `provider`: a static value, or a function of the turn context (which carries
|
|
877
|
-
// the live state for stateful agents). These tests assert both forms reach the
|
|
878
|
-
// provider call, and that the per-turn (function) form re-resolves each call.
|
|
879
|
-
// ---------------------------------------------------------------------------
|
|
880
|
-
const settings = createLogicSuite('ChatDriver temperature & toolChoice');
|
|
881
|
-
settings('passes a static agent temperature and tool-call mode to the provider', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
882
|
-
const provider = scriptedProvider([]);
|
|
883
|
-
const driver = makeDriver(agent({
|
|
884
|
-
name: 'a',
|
|
885
|
-
temperature: 0.3,
|
|
886
|
-
toolChoice: 'none',
|
|
887
|
-
cachePolicy: { scope: 'prompt' },
|
|
888
|
-
tailContext: 'STATE',
|
|
889
|
-
}), provider);
|
|
890
|
-
yield driver.sendMessage('hi');
|
|
891
|
-
assert.is(provider.temperaturePerCall[0], 0.3);
|
|
892
|
-
assert.equal(provider.toolChoicePerCall[0], 'none');
|
|
893
|
-
assert.equal(provider.cachePolicyPerCall[0], { scope: 'prompt' });
|
|
894
|
-
// The driver frames the raw tail content in a <system-reminder> marker.
|
|
895
|
-
assert.is(provider.tailContextPerCall[0], '<system-reminder>\nSTATE\n</system-reminder>');
|
|
896
|
-
// ...and surfaced in the per-turn debug snapshot (the effective values sent).
|
|
897
|
-
const snap = driver.getTurnSnapshots()[0];
|
|
898
|
-
assert.is(snap.temperature, 0.3);
|
|
899
|
-
assert.equal(snap.toolChoice, 'none');
|
|
900
|
-
// The framed tail is captured on the snapshot too — it is never stored in
|
|
901
|
-
// history, so the snapshot is the only record of what the model actually saw.
|
|
902
|
-
assert.is(snap.tailContext, '<system-reminder>\nSTATE\n</system-reminder>');
|
|
903
|
-
}));
|
|
904
|
-
settings('re-resolves the function form per turn (carrying turn context)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
905
|
-
const provider = scriptedProvider([callsTool('tool_a', 't1')]);
|
|
906
|
-
const config = agent({
|
|
907
|
-
name: 'b',
|
|
908
|
-
toolDefinitions: [def('tool_a')],
|
|
909
|
-
toolHandlers: { tool_a: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
910
|
-
// turnIndex: 0 on the first LLM call, > 0 on subsequent tool-loop iterations.
|
|
911
|
-
temperature: (ctx) => (ctx.turnIndex === 0 ? 0.1 : 0.9),
|
|
912
|
-
toolChoice: (ctx) => (ctx.turnIndex === 0 ? { tool: 'tool_a' } : 'auto'),
|
|
913
|
-
cachePolicy: (ctx) => (ctx.turnIndex === 0 ? { scope: 'history' } : { scope: 'tools' }),
|
|
914
|
-
});
|
|
915
|
-
yield makeDriver(config, provider).sendMessage('go');
|
|
916
|
-
// First call forces the named tool at a low temperature...
|
|
917
|
-
assert.is(provider.temperaturePerCall[0], 0.1);
|
|
918
|
-
assert.equal(provider.toolChoicePerCall[0], { tool: 'tool_a' });
|
|
919
|
-
assert.equal(provider.cachePolicyPerCall[0], { scope: 'history' });
|
|
920
|
-
// ...the follow-up call (after the tool result) sees the other branch.
|
|
921
|
-
assert.is(provider.temperaturePerCall[1], 0.9);
|
|
922
|
-
assert.equal(provider.toolChoicePerCall[1], 'auto');
|
|
923
|
-
assert.equal(provider.cachePolicyPerCall[1], { scope: 'tools' });
|
|
924
|
-
}));
|
|
925
|
-
settings('leaves temperature and toolChoice unset when the agent does not configure them', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
926
|
-
const provider = scriptedProvider([]);
|
|
927
|
-
yield makeDriver(agent({ name: 'c' }), provider).sendMessage('hi');
|
|
928
|
-
assert.is(provider.temperaturePerCall[0], undefined);
|
|
929
|
-
assert.is(provider.toolChoicePerCall[0], undefined);
|
|
930
|
-
assert.is(provider.cachePolicyPerCall[0], undefined);
|
|
931
|
-
assert.is(provider.tailContextPerCall[0], undefined);
|
|
932
|
-
}));
|
|
933
|
-
// GENC-1380 A3b: the framework's malformed/empty retry nudge is routed to the framed tail (so the
|
|
934
|
-
// system prompt stays byte-stable for caching), not appended to the system string.
|
|
935
|
-
settings('routes the empty-response retry nudge into the framed tail, not the system prompt', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
936
|
-
var _a, _b, _c;
|
|
937
|
-
// First call returns empty → the driver retries; the second exhausts the queue and ends the turn.
|
|
938
|
-
const provider = scriptedProvider([{ role: 'assistant', content: '' }]);
|
|
939
|
-
const driver = makeDriver(agent({ name: 'r', systemPrompt: 'BASE' }), provider);
|
|
940
|
-
yield driver.sendMessage('go');
|
|
941
|
-
// The retry call (index 1) carries the nudge in the framed tail...
|
|
942
|
-
assert.ok((_a = provider.tailContextPerCall[1]) === null || _a === void 0 ? void 0 : _a.startsWith('<system-reminder>'), 'tail is framed');
|
|
943
|
-
assert.ok((_b = provider.tailContextPerCall[1]) === null || _b === void 0 ? void 0 : _b.includes('You must respond'), 'retry nudge is in the tail');
|
|
944
|
-
// ...and the system prompt stays the bare agent prompt on both calls (nudge moved, not copied).
|
|
945
|
-
assert.is(provider.systemPromptPerCall[0], 'BASE');
|
|
946
|
-
assert.is(provider.systemPromptPerCall[1], 'BASE');
|
|
947
|
-
// Regression guard (debug-log reconstruction): the tail is never stored in
|
|
948
|
-
// history, so the per-turn snapshot must capture it — otherwise the retry
|
|
949
|
-
// nudge the model actually received would be invisible in the export. The
|
|
950
|
-
// snapshot's (systemPrompt + tailContext) must equal what the provider saw.
|
|
951
|
-
const retrySnap = driver.getTurnSnapshots()[1];
|
|
952
|
-
assert.is(retrySnap.systemPrompt, provider.systemPromptPerCall[1]);
|
|
953
|
-
assert.is(retrySnap.tailContext, provider.tailContextPerCall[1]);
|
|
954
|
-
assert.ok((_c = retrySnap.tailContext) === null || _c === void 0 ? void 0 : _c.includes('You must respond'), 'nudge reconstructable');
|
|
955
|
-
// The first (non-retry) call had no framework suffix and no agent tail → empty.
|
|
956
|
-
assert.is(driver.getTurnSnapshots()[0].tailContext, undefined);
|
|
957
|
-
}));
|
|
958
|
-
settings.run();
|
|
959
|
-
// ---------------------------------------------------------------------------
|
|
960
|
-
// empty-response diagnostics (GENC-1321)
|
|
961
|
-
//
|
|
962
|
-
// The transport surfaces a provider diagnostic (Gemini: finishReason,
|
|
963
|
-
// thoughtsTokens, parts, blockReason) on the message; the driver must fold it
|
|
964
|
-
// into the empty-response meta events so the debug-log timeline shows *why* a
|
|
965
|
-
// turn came back blank — not just that it did.
|
|
966
|
-
// ---------------------------------------------------------------------------
|
|
967
|
-
const emptyDiag = createLogicSuite('ChatDriver empty-response diagnostics');
|
|
968
|
-
emptyDiag('folds the provider responseMeta into the empty-response meta events', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
969
|
-
var _a, _b, _c, _d;
|
|
970
|
-
clearMetaEventRegistry();
|
|
971
|
-
const provider = {
|
|
972
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
973
|
-
return ({
|
|
974
|
-
role: 'assistant',
|
|
975
|
-
content: '',
|
|
976
|
-
responseMeta: {
|
|
977
|
-
finishReason: 'STOP',
|
|
978
|
-
thoughtsTokens: 999,
|
|
979
|
-
parts: { functionCall: 0, thought: 0, text: 0 },
|
|
980
|
-
},
|
|
981
|
-
});
|
|
982
|
-
}),
|
|
983
|
-
};
|
|
984
|
-
const sessionKey = 'empty-diag';
|
|
985
|
-
yield makeDriver(agent({ name: 'Static' }), provider, sessionKey).sendMessage('go');
|
|
986
|
-
// Both the in-turn retries and the final bail carry the diagnostic.
|
|
987
|
-
const retry = getMetaEvents(sessionKey).find((e) => { var _a; return e.type === 'turn.retry' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.reason) === 'empty-response'; });
|
|
988
|
-
assert.ok(retry, 'an empty-response turn.retry should be recorded');
|
|
989
|
-
assert.is((_a = retry.detail) === null || _a === void 0 ? void 0 : _a.finishReason, 'STOP');
|
|
990
|
-
assert.is((_b = retry.detail) === null || _b === void 0 ? void 0 : _b.thoughtsTokens, 999);
|
|
991
|
-
const err = getMetaEvents(sessionKey).find((e) => { var _a; return e.type === 'turn.error' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.reason) === 'empty-response'; });
|
|
992
|
-
assert.ok(err, 'an empty-response turn.error should be recorded after retries');
|
|
993
|
-
assert.is((_c = err.detail) === null || _c === void 0 ? void 0 : _c.finishReason, 'STOP');
|
|
994
|
-
assert.is((_d = err.detail) === null || _d === void 0 ? void 0 : _d.thoughtsTokens, 999);
|
|
995
|
-
}));
|
|
996
|
-
emptyDiag.run();
|
|
997
|
-
// ---------------------------------------------------------------------------
|
|
998
|
-
// synthetic-user echo — a resolved interaction can optionally surface its
|
|
999
|
-
// outcome on the user's side of the chat (GENC-1330). The widget opts in by
|
|
1000
|
-
// returning `userMessage`; the driver appends it as a display-only
|
|
1001
|
-
// `'synthetic-user'` message that transports never serialize.
|
|
1002
|
-
// ---------------------------------------------------------------------------
|
|
1003
|
-
const syntheticUser = createLogicSuite('ChatDriver synthetic-user interaction echo');
|
|
1004
|
-
syntheticUser('resolveInteraction with userMessage appends a synthetic-user message', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1005
|
-
var _a;
|
|
1006
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1007
|
-
const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
|
|
1008
|
-
const interactionMsg = driver.getHistory().at(-1);
|
|
1009
|
-
const id = interactionMsg.interaction.interactionId;
|
|
1010
|
-
driver.resolveInteraction(id, {
|
|
1011
|
-
status: 'approved',
|
|
1012
|
-
payload: { answer: 'Option A' },
|
|
1013
|
-
userMessage: 'Option A',
|
|
1014
|
-
});
|
|
1015
|
-
yield pending;
|
|
1016
|
-
const history = driver.getHistory();
|
|
1017
|
-
const synthetic = history.filter((m) => m.role === 'synthetic-user');
|
|
1018
|
-
assert.is(synthetic.length, 1);
|
|
1019
|
-
assert.is(synthetic[0].content, 'Option A');
|
|
1020
|
-
// The original interaction message is still present and now marked resolved
|
|
1021
|
-
// (the existing resolved-widget mechanism is untouched).
|
|
1022
|
-
const resolved = history.find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
1023
|
-
assert.ok((_a = resolved === null || resolved === void 0 ? void 0 : resolved.interaction) === null || _a === void 0 ? void 0 : _a.resolved);
|
|
1024
|
-
// Chronology: the user-side echo follows the (resolved) interaction.
|
|
1025
|
-
const idxInteraction = history.findIndex((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
1026
|
-
const idxSynthetic = history.findIndex((m) => m.role === 'synthetic-user');
|
|
1027
|
-
assert.ok(idxSynthetic > idxInteraction);
|
|
1028
|
-
// It is attributed to the agent that created it — same stamping as any other
|
|
1029
|
-
// appended message — so the debug log can say which agent produced it.
|
|
1030
|
-
assert.is(synthetic[0].agentName, resolved === null || resolved === void 0 ? void 0 : resolved.agentName);
|
|
1031
|
-
}));
|
|
1032
|
-
syntheticUser('omitted or whitespace-only userMessage appends no synthetic-user message', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1033
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1034
|
-
// Omitted entirely — the previous behaviour.
|
|
1035
|
-
const first = driver.requestInteraction('w', {});
|
|
1036
|
-
const firstId = driver.getHistory().at(-1).interaction.interactionId;
|
|
1037
|
-
driver.resolveInteraction(firstId, { status: 'approved' });
|
|
1038
|
-
yield first;
|
|
1039
|
-
// Whitespace-only — treated as absent, never appended.
|
|
1040
|
-
const second = driver.requestInteraction('w', {});
|
|
1041
|
-
const secondId = driver.getHistory().at(-1).interaction.interactionId;
|
|
1042
|
-
driver.resolveInteraction(secondId, { status: 'approved', userMessage: ' ' });
|
|
1043
|
-
yield second;
|
|
1044
|
-
assert.is(driver.getHistory().filter((m) => m.role === 'synthetic-user').length, 0);
|
|
1045
|
-
}));
|
|
1046
|
-
syntheticUser.run();
|
|
1047
|
-
// ---------------------------------------------------------------------------
|
|
1048
|
-
// interaction presentation — the per-call `presentation` option is stamped
|
|
1049
|
-
// onto the appended interaction so the renderer can frame the widget, and is
|
|
1050
|
-
// absent (the historical default) when the option is omitted.
|
|
1051
|
-
// ---------------------------------------------------------------------------
|
|
1052
|
-
const interactionPresentation = createLogicSuite('ChatDriver interaction presentation');
|
|
1053
|
-
interactionPresentation('presentation option is stamped onto the appended interaction', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1054
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1055
|
-
const pending = driver.requestInteraction('w', {}, { presentation: 'bubble' });
|
|
1056
|
-
const interaction = driver.getHistory().at(-1).interaction;
|
|
1057
|
-
assert.is(interaction.presentation, 'bubble');
|
|
1058
|
-
// Clean up the pending interaction so it does not dangle.
|
|
1059
|
-
driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
|
|
1060
|
-
yield pending;
|
|
1061
|
-
}));
|
|
1062
|
-
interactionPresentation('presentation is absent when the option is omitted', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1063
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1064
|
-
const pending = driver.requestInteraction('w', {});
|
|
1065
|
-
const interaction = driver.getHistory().at(-1).interaction;
|
|
1066
|
-
assert.ok(!('presentation' in interaction));
|
|
1067
|
-
driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
|
|
1068
|
-
yield pending;
|
|
1069
|
-
}));
|
|
1070
|
-
interactionPresentation.run();
|
|
1071
|
-
// ---------------------------------------------------------------------------
|
|
1072
|
-
// external diagnostics harvest (GENC-1461) — a widget may return an out-of-band
|
|
1073
|
-
// engine's collated debug log on `InteractionResult.diagnostics` (e.g. a
|
|
1074
|
-
// server-side ChatDriver's). Those entries carry the SOURCE engine's own 0-based
|
|
1075
|
-
// index space, which would collide with this host's forward-capture cursors and
|
|
1076
|
-
// be dropped: the persister dedups `turn` entries by `turnIndex` and `event`
|
|
1077
|
-
// entries by an `index` high-water mark. So the driver decomposes the harvest by
|
|
1078
|
-
// kind — events fold into the session's meta-event registry (re-indexed onto the
|
|
1079
|
-
// host's monotonic counter), turns are re-keyed into a per-batch namespace, and
|
|
1080
|
-
// messages pass through unchanged (the persister already keys those by content).
|
|
1081
|
-
// ---------------------------------------------------------------------------
|
|
1082
|
-
const externalDiagnostics = createLogicSuite('ChatDriver external diagnostics harvest');
|
|
1083
|
-
/** A `diagnostics` payload as a headless engine would collate it — one of each kind,
|
|
1084
|
-
* all carrying that engine's own 0-based indices (`turnIndex: '0'`, event `index: 0`). */
|
|
1085
|
-
const harvestPayload = (marker) => [
|
|
1086
|
-
{ kind: 'message', timestamp: '2026-07-23T10:00:00.000Z', role: 'assistant', content: marker },
|
|
1087
|
-
{ kind: 'turn', timestamp: '2026-07-23T10:00:01.000Z', turnIndex: '0', systemPrompt: 'sys' },
|
|
1088
|
-
{
|
|
1089
|
-
kind: 'event',
|
|
1090
|
-
index: 0,
|
|
1091
|
-
timestamp: '2026-07-23T10:00:02.000Z',
|
|
1092
|
-
type: 'turn.error',
|
|
1093
|
-
importance: 'high',
|
|
1094
|
-
detail: { marker },
|
|
1095
|
-
},
|
|
1096
|
-
];
|
|
1097
|
-
externalDiagnostics('routes harvested events to the meta-event registry (re-indexed) and keeps turns + messages', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1098
|
-
var _a, _b;
|
|
1099
|
-
clearMetaEventRegistry();
|
|
1100
|
-
const sessionKey = 'external-diag-harvest';
|
|
1101
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
|
|
1102
|
-
// Requesting the interaction records a host `interaction.requested` meta-event, so the
|
|
1103
|
-
// registry's index counter is already advanced when the harvested event is merged — exactly
|
|
1104
|
-
// the condition that made the foreign 0-based `index` lose the persister's high-water race.
|
|
1105
|
-
const pending = driver.requestInteraction('w', {});
|
|
1106
|
-
const { interactionId } = driver.getHistory().at(-1).interaction;
|
|
1107
|
-
const requestedIndex = getMetaEvents(sessionKey).find((e) => e.type === 'interaction.requested').index;
|
|
1108
|
-
driver.resolveInteraction(interactionId, {
|
|
1109
|
-
status: 'approved',
|
|
1110
|
-
diagnostics: harvestPayload('m1'),
|
|
1111
|
-
});
|
|
1112
|
-
yield pending;
|
|
1113
|
-
// The event left the external buffer and landed in the registry, re-indexed ABOVE the host's
|
|
1114
|
-
// prior event (so it beats the persister's `index <= lastEventIndex` high-water), timestamp kept.
|
|
1115
|
-
const external = driver.getExternalDiagnostics();
|
|
1116
|
-
assert.is(external.filter((e) => e.kind === 'event').length, 0, 'events do not stay in the buffer');
|
|
1117
|
-
const merged = getMetaEvents(sessionKey).find((e) => { var _a; return ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.marker) === 'm1'; });
|
|
1118
|
-
assert.ok(merged, 'the harvested event is folded into the session registry');
|
|
1119
|
-
assert.ok(merged.index > requestedIndex, 're-indexed onto the host counter, above prior events');
|
|
1120
|
-
assert.is(merged.timestamp, '2026-07-23T10:00:02.000Z', 'original timestamp preserved for ordering');
|
|
1121
|
-
// The turn is kept in the buffer but re-keyed out of the host's bare-integer turn-key space,
|
|
1122
|
-
// and the message passes through untouched.
|
|
1123
|
-
const turn = external.find((e) => e.kind === 'turn');
|
|
1124
|
-
assert.ok(turn, 'the harvested turn is kept in the external buffer');
|
|
1125
|
-
assert.is(turn.turnIndex, 'server-generation.1:0', 'turnIndex namespaced per batch');
|
|
1126
|
-
const message = external.find((e) => e.kind === 'message');
|
|
1127
|
-
assert.ok(message, 'the harvested message is kept in the external buffer');
|
|
1128
|
-
assert.is(message.content, 'm1', 'message passes through unchanged');
|
|
1129
|
-
// A single manifest event records the fold's provenance — the fragile boundary this feature
|
|
1130
|
-
// guards is now observable: a dropped generation shows up as a zero/absent manifest.
|
|
1131
|
-
const manifest = getMetaEvents(sessionKey).find((e) => e.type === 'external-diagnostics.folded');
|
|
1132
|
-
assert.ok(manifest, 'a manifest meta-event is recorded for the fold');
|
|
1133
|
-
assert.is((_a = manifest.detail) === null || _a === void 0 ? void 0 : _a.interactionId, interactionId);
|
|
1134
|
-
assert.equal((_b = manifest.detail) === null || _b === void 0 ? void 0 : _b.counts, { turn: 1, event: 1, message: 1 });
|
|
1135
|
-
}));
|
|
1136
|
-
externalDiagnostics('namespaces each batch so distinct server generations cannot collide on turnIndex', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1137
|
-
clearMetaEventRegistry();
|
|
1138
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-batches');
|
|
1139
|
-
const first = driver.requestInteraction('w', {});
|
|
1140
|
-
const firstId = driver.getHistory().at(-1).interaction.interactionId;
|
|
1141
|
-
driver.resolveInteraction(firstId, { status: 'approved', diagnostics: harvestPayload('a') });
|
|
1142
|
-
yield first;
|
|
1143
|
-
const second = driver.requestInteraction('w', {});
|
|
1144
|
-
const secondId = driver.getHistory().at(-1).interaction.interactionId;
|
|
1145
|
-
driver.resolveInteraction(secondId, { status: 'approved', diagnostics: harvestPayload('b') });
|
|
1146
|
-
yield second;
|
|
1147
|
-
// Both harvests carried `turnIndex: '0'`; the per-batch namespace keeps them distinct, so the
|
|
1148
|
-
// persister's `emittedTurnKeys` can't fold the second generation's turn into the first.
|
|
1149
|
-
const turnKeys = driver
|
|
1150
|
-
.getExternalDiagnostics()
|
|
1151
|
-
.filter((e) => e.kind === 'turn')
|
|
1152
|
-
.map((e) => e.turnIndex);
|
|
1153
|
-
assert.equal(turnKeys, ['server-generation.1:0', 'server-generation.2:0']);
|
|
1154
|
-
}));
|
|
1155
|
-
externalDiagnostics('keys turns by local position so several server runs in one harvest do not collide', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1156
|
-
clearMetaEventRegistry();
|
|
1157
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-multirun');
|
|
1158
|
-
// One interaction carrying two independent server runs (e.g. a consolidator's consolidator-code
|
|
1159
|
-
// run and its table-code run) — each numbers its own turns from 0, so the harvest has two
|
|
1160
|
-
// `turnIndex: '0'` entries. Keying by source index would collapse them; local position keeps
|
|
1161
|
-
// both, so the persister's per-turnIndex dedup drops neither.
|
|
1162
|
-
const pending = driver.requestInteraction('w', {});
|
|
1163
|
-
const { interactionId } = driver.getHistory().at(-1).interaction;
|
|
1164
|
-
driver.resolveInteraction(interactionId, {
|
|
1165
|
-
status: 'approved',
|
|
1166
|
-
diagnostics: [
|
|
1167
|
-
{
|
|
1168
|
-
kind: 'turn',
|
|
1169
|
-
timestamp: '2026-07-23T10:00:00.000Z',
|
|
1170
|
-
turnIndex: '0',
|
|
1171
|
-
systemPrompt: 'run1',
|
|
1172
|
-
},
|
|
1173
|
-
{
|
|
1174
|
-
kind: 'turn',
|
|
1175
|
-
timestamp: '2026-07-23T10:00:01.000Z',
|
|
1176
|
-
turnIndex: '1',
|
|
1177
|
-
systemPrompt: 'run1',
|
|
1178
|
-
},
|
|
1179
|
-
{
|
|
1180
|
-
kind: 'turn',
|
|
1181
|
-
timestamp: '2026-07-23T10:00:02.000Z',
|
|
1182
|
-
turnIndex: '0',
|
|
1183
|
-
systemPrompt: 'run2',
|
|
1184
|
-
},
|
|
1185
|
-
],
|
|
1186
|
-
});
|
|
1187
|
-
yield pending;
|
|
1188
|
-
const turnKeys = driver
|
|
1189
|
-
.getExternalDiagnostics()
|
|
1190
|
-
.filter((e) => e.kind === 'turn')
|
|
1191
|
-
.map((e) => e.turnIndex);
|
|
1192
|
-
assert.equal(turnKeys, [
|
|
1193
|
-
'server-generation.1:0',
|
|
1194
|
-
'server-generation.1:1',
|
|
1195
|
-
'server-generation.1:2',
|
|
1196
|
-
]);
|
|
1197
|
-
// All distinct — no key repeats despite the repeated source `turnIndex: '0'`.
|
|
1198
|
-
assert.is(new Set(turnKeys).size, 3, 'every harvested turn gets a distinct key');
|
|
1199
|
-
}));
|
|
1200
|
-
externalDiagnostics('tolerates malformed entries and does not fold or miscount non-standard kinds', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1201
|
-
var _a;
|
|
1202
|
-
clearMetaEventRegistry();
|
|
1203
|
-
const sessionKey = 'external-diag-malformed';
|
|
1204
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
|
|
1205
|
-
const pending = driver.requestInteraction('w', {});
|
|
1206
|
-
const { interactionId } = driver.getHistory().at(-1).interaction;
|
|
1207
|
-
// The harvest is untrusted `unknown[]`: primitives/null and an object without a string `kind`
|
|
1208
|
-
// must be skipped (not throw), and a `meta-snapshot` (host-owned, never emitted by a sibling)
|
|
1209
|
-
// must be dropped rather than folded or miscounted as a message.
|
|
1210
|
-
driver.resolveInteraction(interactionId, {
|
|
1211
|
-
status: 'approved',
|
|
1212
|
-
diagnostics: [
|
|
1213
|
-
null,
|
|
1214
|
-
'oops',
|
|
1215
|
-
42,
|
|
1216
|
-
{ timestamp: '2026-07-23T10:00:00.000Z' }, // no kind
|
|
1217
|
-
{
|
|
1218
|
-
kind: 'message',
|
|
1219
|
-
timestamp: '2026-07-23T10:00:01.000Z',
|
|
1220
|
-
role: 'assistant',
|
|
1221
|
-
content: 'real',
|
|
1222
|
-
},
|
|
1223
|
-
{ kind: 'meta-snapshot', timestamp: '2026-07-23T10:00:02.000Z', meta: { host: 'foreign' } },
|
|
1224
|
-
],
|
|
1225
|
-
});
|
|
1226
|
-
yield pending;
|
|
1227
|
-
// Only the one real message survives the buffer; the meta-snapshot is not folded.
|
|
1228
|
-
const external = driver.getExternalDiagnostics();
|
|
1229
|
-
assert.equal(external.map((e) => e.kind), ['message']);
|
|
1230
|
-
assert.is(external[0].content, 'real');
|
|
1231
|
-
// Manifest counts only the three real kinds — the meta-snapshot is not miscounted as a message.
|
|
1232
|
-
const manifest = getMetaEvents(sessionKey).find((e) => e.type === 'external-diagnostics.folded');
|
|
1233
|
-
assert.equal((_a = manifest.detail) === null || _a === void 0 ? void 0 : _a.counts, { turn: 0, event: 0, message: 1 });
|
|
1234
|
-
}));
|
|
1235
|
-
externalDiagnostics.run();
|
|
1236
|
-
// ---------------------------------------------------------------------------
|
|
1237
|
-
// interaction context lifecycle (GENC-1390) — the driver creates a live
|
|
1238
|
-
// InteractionContext on requestInteraction and disposes+drops it on resolve, so
|
|
1239
|
-
// widget state survives element remounts but never outlives the interaction.
|
|
1240
|
-
// ---------------------------------------------------------------------------
|
|
1241
|
-
const interactionContextLifecycle = createLogicSuite('ChatDriver interaction context');
|
|
1242
|
-
interactionContextLifecycle('creates a context on request and returns it by id', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1243
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1244
|
-
const pending = driver.requestInteraction('w', {});
|
|
1245
|
-
const { interactionId } = driver.getHistory().at(-1).interaction;
|
|
1246
|
-
const ctx = driver.getInteractionContext(interactionId);
|
|
1247
|
-
assert.ok(ctx, 'a context exists for the pending interaction');
|
|
1248
|
-
assert.is(ctx.interactionId, interactionId);
|
|
1249
|
-
// Same instance on a second lookup — this stable identity is what survives remounts.
|
|
1250
|
-
assert.is(driver.getInteractionContext(interactionId), ctx, 'the context is a stable instance');
|
|
1251
|
-
driver.resolveInteraction(interactionId, { status: 'approved' });
|
|
1252
|
-
yield pending;
|
|
1253
|
-
}));
|
|
1254
|
-
interactionContextLifecycle('disposes and drops the context on resolve', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1255
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1256
|
-
const pending = driver.requestInteraction('w', {});
|
|
1257
|
-
const { interactionId } = driver.getHistory().at(-1).interaction;
|
|
1258
|
-
let disposed = false;
|
|
1259
|
-
driver.getInteractionContext(interactionId).onDispose(() => {
|
|
1260
|
-
disposed = true;
|
|
1261
|
-
});
|
|
1262
|
-
driver.resolveInteraction(interactionId, { status: 'approved' });
|
|
1263
|
-
yield pending;
|
|
1264
|
-
assert.ok(disposed, 'onDispose fired on resolve');
|
|
1265
|
-
assert.is(driver.getInteractionContext(interactionId), undefined, 'the context is dropped after resolve');
|
|
1266
|
-
}));
|
|
1267
|
-
interactionContextLifecycle.run();
|
|
1268
|
-
// ---------------------------------------------------------------------------
|
|
1269
|
-
// interaction activity-bus signals (GENC-1346) — the driver brackets a parked
|
|
1270
|
-
// widget interaction with `interaction-requested` / `interaction-resolved`, so
|
|
1271
|
-
// turn-aware UI can distinguish "actively computing" from "parked awaiting the
|
|
1272
|
-
// user". No tool-loop event fires at a park boundary, so these are the signal.
|
|
1273
|
-
// ---------------------------------------------------------------------------
|
|
1274
|
-
const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');
|
|
1275
|
-
// These two tests need to observe what the driver publishes, so they inject a local
|
|
1276
|
-
// in-memory bus (no `crossTabEvents` → no BroadcastChannel is ever opened, nothing to close)
|
|
1277
|
-
// rather than the shared singleton. Every other suite lets the driver default to the no-op bus.
|
|
1278
|
-
interactionBus('brackets a park with interaction-requested then -resolved', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1279
|
-
const events = [];
|
|
1280
|
-
const bus = new AgenticActivityBus();
|
|
1281
|
-
const unsubs = [
|
|
1282
|
-
bus.subscribe('interaction-requested', () => events.push('requested')),
|
|
1283
|
-
bus.subscribe('interaction-resolved', () => events.push('resolved')),
|
|
1284
|
-
];
|
|
1285
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
|
|
1286
|
-
const pending = driver.requestInteraction('w', {});
|
|
1287
|
-
assert.equal(events, ['requested'], 'parking fires interaction-requested');
|
|
1288
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
1289
|
-
driver.resolveInteraction(id, { status: 'approved' });
|
|
1290
|
-
yield pending;
|
|
1291
|
-
assert.equal(events, ['requested', 'resolved'], 'resolving fires interaction-resolved');
|
|
1292
|
-
unsubs.forEach((u) => u());
|
|
1293
|
-
driver.dispose();
|
|
1294
|
-
}));
|
|
1295
|
-
interactionBus('a timed-out interaction still fires interaction-resolved', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1296
|
-
const events = [];
|
|
1297
|
-
const bus = new AgenticActivityBus();
|
|
1298
|
-
const unsub = bus.subscribe('interaction-resolved', () => events.push('resolved'));
|
|
1299
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);
|
|
1300
|
-
// Never resolved by a user — the timeout path runs the same teardown, so it
|
|
1301
|
-
// must signal the bus too (else the button would stay enabled after a timeout).
|
|
1302
|
-
const pending = driver.requestInteraction('w', {}, { timeoutMs: 1 });
|
|
1303
|
-
yield pending;
|
|
1304
|
-
assert.equal(events, ['resolved'], 'the timeout resolution path also signals the bus');
|
|
1305
|
-
unsub();
|
|
1306
|
-
driver.dispose();
|
|
1307
|
-
}));
|
|
1308
|
-
interactionBus.run();
|
|
1309
|
-
// ---------------------------------------------------------------------------
|
|
1310
|
-
// interaction timeout — requestInteraction({ timeoutMs }) resolves with a
|
|
1311
|
-
// status:'timeout' result (never rejects) and closes the widget read-only.
|
|
1312
|
-
// ---------------------------------------------------------------------------
|
|
1313
|
-
const interactionTimeout = createLogicSuite('ChatDriver interaction timeout');
|
|
1314
|
-
interactionTimeout('resolves with status "timeout" when the user does not respond in time', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1315
|
-
var _a;
|
|
1316
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1317
|
-
const result = (yield driver.requestInteraction('planning-question', { question: 'Pick one' }, { timeoutMs: 10 }));
|
|
1318
|
-
assert.is(result.status, 'timeout');
|
|
1319
|
-
// The widget message is stamped resolved (renders read-only) with the timeout.
|
|
1320
|
-
const msg = driver.getHistory().find((m) => m.interaction);
|
|
1321
|
-
assert.ok((_a = msg === null || msg === void 0 ? void 0 : msg.interaction) === null || _a === void 0 ? void 0 : _a.resolved);
|
|
1322
|
-
assert.is(msg.interaction.resolved.status, 'timeout');
|
|
1323
|
-
}));
|
|
1324
|
-
interactionTimeout('a user response before the deadline wins and cancels the timeout', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1325
|
-
var _a;
|
|
1326
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1327
|
-
// A long timeout that must NOT fire because we resolve immediately.
|
|
1328
|
-
const pending = driver.requestInteraction('planning-question', { question: 'Pick one' }, { timeoutMs: 10000 });
|
|
1329
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
1330
|
-
driver.resolveInteraction(id, { status: 'approved', payload: { answer: 'A' } });
|
|
1331
|
-
const result = yield pending;
|
|
1332
|
-
assert.is(result.status, 'approved');
|
|
1333
|
-
assert.is((_a = result.payload) === null || _a === void 0 ? void 0 : _a.answer, 'A');
|
|
1334
|
-
}));
|
|
1335
|
-
interactionTimeout.run();
|
|
1336
|
-
// ---------------------------------------------------------------------------
|
|
1337
|
-
// interaction external cost — a widget may report its own (non-LLM) cost via
|
|
1338
|
-
// InteractionResult.costUsd; the driver folds it onto the message as
|
|
1339
|
-
// externalCostUsd so the host's sumCosts recompute includes it.
|
|
1340
|
-
// ---------------------------------------------------------------------------
|
|
1341
|
-
const interactionCost = createLogicSuite('ChatDriver interaction external cost');
|
|
1342
|
-
interactionCost('folds a widget-reported costUsd onto the message as externalCostUsd', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1343
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1344
|
-
const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
|
|
1345
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
1346
|
-
driver.resolveInteraction(id, { status: 'approved', payload: { answer: 'A' }, costUsd: 0.05 });
|
|
1347
|
-
yield pending;
|
|
1348
|
-
const msg = driver.getHistory().find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
1349
|
-
assert.is(msg === null || msg === void 0 ? void 0 : msg.externalCostUsd, 0.05);
|
|
1350
|
-
}));
|
|
1351
|
-
interactionCost('leaves externalCostUsd unset for a missing, zero, or negative costUsd', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1352
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1353
|
-
// Omitted entirely.
|
|
1354
|
-
const p1 = driver.requestInteraction('w', {});
|
|
1355
|
-
const id1 = driver.getHistory().at(-1).interaction.interactionId;
|
|
1356
|
-
driver.resolveInteraction(id1, { status: 'approved' });
|
|
1357
|
-
yield p1;
|
|
1358
|
-
// Zero and negative are both ignored (only a positive, finite amount counts).
|
|
1359
|
-
const p2 = driver.requestInteraction('w', {});
|
|
1360
|
-
const id2 = driver.getHistory().at(-1).interaction.interactionId;
|
|
1361
|
-
driver.resolveInteraction(id2, { status: 'approved', costUsd: 0 });
|
|
1362
|
-
yield p2;
|
|
1363
|
-
const p3 = driver.requestInteraction('w', {});
|
|
1364
|
-
const id3 = driver.getHistory().at(-1).interaction.interactionId;
|
|
1365
|
-
driver.resolveInteraction(id3, { status: 'approved', costUsd: -1 });
|
|
1366
|
-
yield p3;
|
|
1367
|
-
assert.not.ok(driver.getHistory().some((m) => m.externalCostUsd != null));
|
|
1368
|
-
}));
|
|
1369
|
-
interactionCost.run();
|
|
1370
|
-
/**
|
|
1371
|
-
* A single-name (`'high'`) observable registry. `get`/`default` always return
|
|
1372
|
-
* the current provider, so swapping it mid-session models a same-name vendor
|
|
1373
|
-
* switch (the tier name stays `'high'`, the provider underneath changes).
|
|
1374
|
-
*/
|
|
1375
|
-
const makeObservableRegistry = (initial) => {
|
|
1376
|
-
let current = initial;
|
|
1377
|
-
const listeners = new Set();
|
|
1378
|
-
return {
|
|
1379
|
-
get: () => current,
|
|
1380
|
-
default: () => current,
|
|
1381
|
-
defaultName: () => 'high',
|
|
1382
|
-
names: () => ['high'],
|
|
1383
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
1384
|
-
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
1385
|
-
subscribe(listener) {
|
|
1386
|
-
listeners.add(listener);
|
|
1387
|
-
return () => {
|
|
1388
|
-
listeners.delete(listener);
|
|
1389
|
-
};
|
|
1390
|
-
},
|
|
1391
|
-
swap(provider) {
|
|
1392
|
-
current = provider;
|
|
1393
|
-
for (const l of Array.from(listeners))
|
|
1394
|
-
l();
|
|
1395
|
-
},
|
|
1396
|
-
listenerCount: () => listeners.size,
|
|
1397
|
-
};
|
|
1398
|
-
};
|
|
1399
|
-
const makeDriverWithRegistry = (config, registry) => {
|
|
1400
|
-
const driver = new ChatDriver(registry, { maxToolIterations: 50, maxFoldOperations: 5 });
|
|
1401
|
-
driver.applyAgent(config);
|
|
1402
|
-
return driver;
|
|
1403
|
-
};
|
|
1404
|
-
const observable = createLogicSuite('ChatDriver observable provider registry');
|
|
1405
|
-
observable('a registry change clears the resolved-provider cache so the next turn uses the new provider', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1406
|
-
const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
|
|
1407
|
-
const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
|
|
1408
|
-
const registry = makeObservableRegistry(providerA);
|
|
1409
|
-
// A static provider name means lookups go through `resolvedProviderCache` —
|
|
1410
|
-
// the cache that must self-invalidate on a registry change.
|
|
1411
|
-
const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
|
|
1412
|
-
yield driver.sendMessage('first');
|
|
1413
|
-
assert.is(providerA.advertisedPerCall.length, 1, 'turn 1 resolves the original provider');
|
|
1414
|
-
assert.is(providerB.advertisedPerCall.length, 0);
|
|
1415
|
-
registry.swap(providerB); // notify → cache cleared
|
|
1416
|
-
yield driver.sendMessage('second');
|
|
1417
|
-
assert.is(providerB.advertisedPerCall.length, 1, 'turn 2 resolves the swapped-in provider');
|
|
1418
|
-
assert.is(providerA.advertisedPerCall.length, 1, 'the stale provider is not reused');
|
|
1419
|
-
driver.dispose();
|
|
1420
|
-
}));
|
|
1421
|
-
observable('re-emits provider-changed on a same-name swap', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1422
|
-
const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
|
|
1423
|
-
const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
|
|
1424
|
-
const registry = makeObservableRegistry(providerA);
|
|
1425
|
-
const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
|
|
1426
|
-
const names = [];
|
|
1427
|
-
driver.addEventListener('provider-changed', (e) => {
|
|
1428
|
-
names.push(e.detail.name);
|
|
1429
|
-
});
|
|
1430
|
-
yield driver.sendMessage('first');
|
|
1431
|
-
registry.swap(providerB);
|
|
1432
|
-
yield driver.sendMessage('second');
|
|
1433
|
-
// The resolved name ('high') never changes, but the swap resets the
|
|
1434
|
-
// last-dispatched name so the cog can refresh — two events, not one.
|
|
1435
|
-
assert.equal(names, ['high', 'high']);
|
|
1436
|
-
driver.dispose();
|
|
1437
|
-
}));
|
|
1438
|
-
observable('a non-observable registry is a no-op — turn runs, dispose does not throw', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1439
|
-
const provider = scriptedProvider([{ role: 'assistant', content: 'hi' }]);
|
|
1440
|
-
const driver = makeDriverWithRegistry(agent({ name: 'plain' }), makeRegistry(provider));
|
|
1441
|
-
yield driver.sendMessage('go');
|
|
1442
|
-
assert.is(provider.advertisedPerCall.length, 1);
|
|
1443
|
-
driver.dispose(); // no subscription was wired — must still be safe
|
|
1444
|
-
}));
|
|
1445
|
-
observable('a child sub-agent driver unsubscribes on completion — no listener leak', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1446
|
-
const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
|
|
1447
|
-
const registry = makeObservableRegistry(provider);
|
|
1448
|
-
const parent = delegatingParent(completingWorker({ ok: true }), () => { });
|
|
1449
|
-
const driver = makeDriverWithRegistry(parent, registry);
|
|
1450
|
-
assert.is(registry.listenerCount(), 1, 'the parent driver subscribed on construction');
|
|
1451
|
-
yield driver.sendMessage('go');
|
|
1452
|
-
// The child subscribed during the run; if it didn't clean up on its (normal)
|
|
1453
|
-
// completion the registry would now hold two listeners.
|
|
1454
|
-
assert.is(registry.listenerCount(), 1, 'the completed child unsubscribed');
|
|
1455
|
-
driver.dispose();
|
|
1456
|
-
assert.is(registry.listenerCount(), 0, 'the parent unsubscribed on dispose');
|
|
1457
|
-
}));
|
|
1458
|
-
observable.run();
|
|
1459
|
-
// ---------------------------------------------------------------------------
|
|
1460
|
-
// per-message model attribution (GENC-1346)
|
|
1461
|
-
//
|
|
1462
|
-
// Each model-produced assistant message carries `model` (the concrete model id
|
|
1463
|
-
// the active provider's getStatus reports) and `providerName` (the registry slot
|
|
1464
|
-
// it resolved under), so the exported debug log shows which model produced each
|
|
1465
|
-
// message — and, since tool calls ride on the assistant message, each tool call.
|
|
1466
|
-
// ---------------------------------------------------------------------------
|
|
1467
|
-
/** A provider that replays scripted replies and reports `model` via getStatus
|
|
1468
|
-
* (omitted entirely when `model` is undefined, to model a provider with no
|
|
1469
|
-
* status). */
|
|
1470
|
-
const modelProvider = (model, responses) => {
|
|
1471
|
-
const queue = [...responses];
|
|
1472
|
-
const provider = {
|
|
1473
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = queue.shift()) !== null && _a !== void 0 ? _a : { role: 'assistant', content: 'done' }; }),
|
|
1474
|
-
};
|
|
1475
|
-
if (model !== undefined) {
|
|
1476
|
-
provider.getStatus = () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model }); });
|
|
1477
|
-
}
|
|
1478
|
-
return provider;
|
|
1479
|
-
};
|
|
1480
|
-
const modelAttr = createLogicSuite('ChatDriver per-message model attribution');
|
|
1481
|
-
modelAttr('stamps the resolved model and registry name onto an assistant reply', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1482
|
-
const provider = modelProvider('gemini-2.5-flash-lite', [
|
|
1483
|
-
{ role: 'assistant', content: 'hi there' },
|
|
1484
|
-
]);
|
|
1485
|
-
const driver = makeDriver(agent({ name: 'plain' }), provider);
|
|
1486
|
-
yield driver.sendMessage('hello');
|
|
1487
|
-
const reply = driver.getHistory().find((m) => m.role === 'assistant');
|
|
1488
|
-
assert.ok(reply, 'assistant reply present');
|
|
1489
|
-
assert.is(reply.model, 'gemini-2.5-flash-lite', 'model id read from provider getStatus');
|
|
1490
|
-
assert.is(reply.provider, 'gemini', 'vendor id read from provider getStatus');
|
|
1491
|
-
// makeRegistry registers a single provider under the name 'test'.
|
|
1492
|
-
assert.is(reply.providerName, 'test', 'registry slot the turn resolved under');
|
|
1493
|
-
driver.dispose();
|
|
1494
|
-
}));
|
|
1495
|
-
modelAttr('attributes a tool-calling assistant message (and so its tool calls) to the model', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1496
|
-
const provider = modelProvider('claude-haiku-4-5-20251001', [
|
|
1497
|
-
callsTool('noop', 't1'),
|
|
1498
|
-
{ role: 'assistant', content: 'finished' },
|
|
1499
|
-
]);
|
|
1500
|
-
const config = agent({
|
|
1501
|
-
name: 'withTool',
|
|
1502
|
-
toolDefinitions: [def('noop')],
|
|
1503
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
1504
|
-
});
|
|
1505
|
-
const driver = makeDriver(config, provider);
|
|
1506
|
-
yield driver.sendMessage('go');
|
|
1507
|
-
const toolCallMsg = driver.getHistory().find((m) => { var _a, _b; return ((_b = (_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0; });
|
|
1508
|
-
assert.ok(toolCallMsg, 'an assistant message with tool calls is present');
|
|
1509
|
-
assert.is(toolCallMsg.model, 'claude-haiku-4-5-20251001');
|
|
1510
|
-
assert.is(toolCallMsg.provider, 'gemini');
|
|
1511
|
-
assert.is(toolCallMsg.providerName, 'test');
|
|
1512
|
-
driver.dispose();
|
|
1513
|
-
}));
|
|
1514
|
-
modelAttr('picks up a model swapped behind a stable name', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1515
|
-
var _a, _b, _c;
|
|
1516
|
-
const before = modelProvider('gemini-2.5-flash-lite', [{ role: 'assistant', content: 'A' }]);
|
|
1517
|
-
const after = modelProvider('gemini-2.5-pro', [{ role: 'assistant', content: 'B' }]);
|
|
1518
|
-
const registry = makeObservableRegistry(before);
|
|
1519
|
-
const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
|
|
1520
|
-
yield driver.sendMessage('first');
|
|
1521
|
-
registry.swap(after); // notify → model cache cleared, next turn re-resolves
|
|
1522
|
-
yield driver.sendMessage('second');
|
|
1523
|
-
const replies = driver.getHistory().filter((m) => m.role === 'assistant');
|
|
1524
|
-
assert.is((_a = replies.at(-2)) === null || _a === void 0 ? void 0 : _a.model, 'gemini-2.5-flash-lite', 'first turn keeps the original model');
|
|
1525
|
-
assert.is((_b = replies.at(-1)) === null || _b === void 0 ? void 0 : _b.model, 'gemini-2.5-pro', 'after the swap the new model is stamped');
|
|
1526
|
-
// The tier name ('high') never changed across the swap — only the model behind it.
|
|
1527
|
-
assert.is((_c = replies.at(-1)) === null || _c === void 0 ? void 0 : _c.providerName, 'high');
|
|
1528
|
-
driver.dispose();
|
|
1529
|
-
}));
|
|
1530
|
-
modelAttr('omits the model key entirely when the provider reports no status', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1531
|
-
const provider = modelProvider(undefined, [{ role: 'assistant', content: 'no status here' }]);
|
|
1532
|
-
const driver = makeDriver(agent({ name: 'plain' }), provider);
|
|
1533
|
-
yield driver.sendMessage('hi');
|
|
1534
|
-
const reply = driver.getHistory().find((m) => m.role === 'assistant');
|
|
1535
|
-
assert.ok(reply, 'assistant reply present');
|
|
1536
|
-
// No getStatus → no model. The key is left off, not set to undefined, so the
|
|
1537
|
-
// exported log carries no dead `model` line for this message.
|
|
1538
|
-
assert.not.ok('model' in reply, 'model key is absent, not present-as-undefined');
|
|
1539
|
-
assert.is(reply.providerName, 'test', 'provider name is still recorded');
|
|
1540
|
-
driver.dispose();
|
|
1541
|
-
}));
|
|
1542
|
-
modelAttr.run();
|
|
1543
|
-
// ---------------------------------------------------------------------------
|
|
1544
|
-
// condenseWhen — tool-declared context condensation (wiring through the loop)
|
|
1545
|
-
// ---------------------------------------------------------------------------
|
|
1546
|
-
const condense = createLogicSuite('ChatDriver condenseWhen');
|
|
1547
|
-
const bigBody = 'A'.repeat(2000);
|
|
1548
|
-
/** An assistant turn calling `read` with a path arg (so `by: args.path` resolves). */
|
|
1549
|
-
const readsPath = (id, path) => ({
|
|
1550
|
-
role: 'assistant',
|
|
1551
|
-
content: '',
|
|
1552
|
-
toolCalls: [{ id, name: 'read', args: { path } }],
|
|
1553
|
-
});
|
|
1554
|
-
condense('collapses a superseded result in the model slice but keeps stored history full', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1555
|
-
var _a, _b;
|
|
1556
|
-
const sessionKey = 'condense-supersede';
|
|
1557
|
-
clearMetaEventRegistry();
|
|
1558
|
-
const provider = scriptedProvider([
|
|
1559
|
-
readsPath('r1', 'A'),
|
|
1560
|
-
readsPath('r2', 'A'), // re-read of the same path supersedes r1
|
|
1561
|
-
]);
|
|
1562
|
-
const config = agent({
|
|
1563
|
-
name: 'reader',
|
|
1564
|
-
systemPrompt: 'read files',
|
|
1565
|
-
toolDefinitions: [def('read')],
|
|
1566
|
-
toolHandlers: {
|
|
1567
|
-
read: (args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1568
|
-
ctx.condenseWhen({
|
|
1569
|
-
on: { kind: 'superseded', by: String(args.path) },
|
|
1570
|
-
response: 'pointer',
|
|
1571
|
-
});
|
|
1572
|
-
return bigBody;
|
|
1573
|
-
}),
|
|
1574
|
-
},
|
|
1575
|
-
});
|
|
1576
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
1577
|
-
const result = yield driver.sendMessage('go');
|
|
1578
|
-
assert.is(result.reason, 'done');
|
|
1579
|
-
// The 3rd provider call (index 2) ran after both reads: r1 is superseded, r2 is latest.
|
|
1580
|
-
const slice = provider.historyPerCall[2];
|
|
1581
|
-
const r1 = slice.find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'r1'; });
|
|
1582
|
-
const r2 = slice.find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'r2'; });
|
|
1583
|
-
assert.match(r1.toolResult.content, /re-call to restore/, 'earlier read collapsed for the model');
|
|
1584
|
-
assert.is(r2.toolResult.content, bigBody, 'latest read still full');
|
|
1585
|
-
// Stored history is untouched — both reads remain full for the UI / debug export.
|
|
1586
|
-
assert.is(toolResultContents(driver).filter((c) => c === bigBody).length, 2, 'stored history retains both full reads');
|
|
1587
|
-
// One context.condensed meta-event, for r1's response, recorded once.
|
|
1588
|
-
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1589
|
-
assert.is(events.length, 1, 'the full→stub transition is reported exactly once');
|
|
1590
|
-
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.toolCallId, 'r1');
|
|
1591
|
-
assert.is((_b = events[0].detail) === null || _b === void 0 ? void 0 : _b.target, 'response');
|
|
1592
|
-
}));
|
|
1593
|
-
condense('first-wins: a second condenseWhen for the same tool call is ignored', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1594
|
-
const sessionKey = 'condense-first-wins';
|
|
1595
|
-
clearMetaEventRegistry();
|
|
1596
|
-
const provider = scriptedProvider([readsPath('r1', 'A'), readsPath('r2', 'A')]);
|
|
1597
|
-
const config = agent({
|
|
1598
|
-
name: 'reader',
|
|
1599
|
-
systemPrompt: 'read files',
|
|
1600
|
-
toolDefinitions: [def('read')],
|
|
1601
|
-
toolHandlers: {
|
|
1602
|
-
read: (args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1603
|
-
// First declaration wins; the second (a different mode) is dropped.
|
|
1604
|
-
ctx.condenseWhen({
|
|
1605
|
-
on: { kind: 'superseded', by: String(args.path) },
|
|
1606
|
-
response: 'pointer',
|
|
1607
|
-
});
|
|
1608
|
-
ctx.condenseWhen({
|
|
1609
|
-
on: { kind: 'superseded', by: String(args.path) },
|
|
1610
|
-
response: { replaceWith: 'SECOND' },
|
|
1611
|
-
});
|
|
1612
|
-
return bigBody;
|
|
1613
|
-
}),
|
|
1614
|
-
},
|
|
1615
|
-
});
|
|
1616
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
1617
|
-
yield driver.sendMessage('go');
|
|
1618
|
-
const collapsed = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'r1'; });
|
|
1619
|
-
assert.match(collapsed.toolResult.content, /re-call to restore/, 'the first (pointer) policy applied');
|
|
1620
|
-
assert.is.not(collapsed.toolResult.content, 'SECOND', 'the later policy was ignored');
|
|
1621
|
-
}));
|
|
1622
|
-
condense('age clock is monotonic across turns — collapses on a later, separate turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1623
|
-
var _a;
|
|
1624
|
-
// Regression guard for the per-turn-reset bug: the age clock is the local loop
|
|
1625
|
-
// counter that resets every `sendMessage`, so an `age` payload from turn 1
|
|
1626
|
-
// would never collapse across short turns. The driver now uses a monotonic
|
|
1627
|
-
// model-call counter, so it does.
|
|
1628
|
-
const sessionKey = 'condense-age-cross-turn';
|
|
1629
|
-
clearMetaEventRegistry();
|
|
1630
|
-
const provider = scriptedProvider([
|
|
1631
|
-
{ role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
|
|
1632
|
-
{ role: 'assistant', content: 'noted' }, // turn 1, call 2 — model sees n1 once (full), turn ends
|
|
1633
|
-
{ role: 'assistant', content: 'ok' }, // turn 2, call 1 — a short follow-up, no tools
|
|
1634
|
-
]);
|
|
1635
|
-
const config = agent({
|
|
1636
|
-
name: 'note-taker',
|
|
1637
|
-
systemPrompt: 'take notes',
|
|
1638
|
-
toolDefinitions: [def('notes')],
|
|
1639
|
-
toolHandlers: {
|
|
1640
|
-
notes: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1641
|
-
ctx.condenseWhen({ on: { kind: 'age', turns: 1 }, response: 'pointer' });
|
|
1642
|
-
return bigBody;
|
|
1643
|
-
}),
|
|
1644
|
-
},
|
|
1645
|
-
});
|
|
1646
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
1647
|
-
yield driver.sendMessage('first'); // turn 1
|
|
1648
|
-
yield driver.sendMessage('second'); // turn 2 — a separate, short sendMessage
|
|
1649
|
-
// Turn 1's consume call (provider call #1) saw n1 in full — its one allowed look.
|
|
1650
|
-
const turn1Consume = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1651
|
-
assert.is(turn1Consume.toolResult.content, bigBody, 'full on its one allowed turn');
|
|
1652
|
-
// Turn 2's first call (#2) is a SEPARATE sendMessage — the local loop counter has
|
|
1653
|
-
// reset to 0, but the monotonic clock kept counting, so n1 is now collapsed.
|
|
1654
|
-
// Under the old per-turn counter it would still be full here.
|
|
1655
|
-
const turn2 = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1656
|
-
assert.match(turn2.toolResult.content, /re-call to restore/, 'collapsed on the next turn');
|
|
1657
|
-
assert.ok(toolResultContents(driver).includes(bigBody), 'stored history retains the full note');
|
|
1658
|
-
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1659
|
-
assert.is(events.length, 1);
|
|
1660
|
-
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'age:1');
|
|
1661
|
-
}));
|
|
1662
|
-
condense('turnEnd: full during its request, collapsed on the next user turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1663
|
-
var _a;
|
|
1664
|
-
const sessionKey = 'condense-turn-end';
|
|
1665
|
-
clearMetaEventRegistry();
|
|
1666
|
-
const provider = scriptedProvider([
|
|
1667
|
-
{ role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
|
|
1668
|
-
{ role: 'assistant', content: 'noted' }, // turn 1, call 2 — n1 still full this turn
|
|
1669
|
-
{ role: 'assistant', content: 'ok' }, // turn 2 — a separate request
|
|
1670
|
-
]);
|
|
1671
|
-
const config = agent({
|
|
1672
|
-
name: 'note-taker',
|
|
1673
|
-
systemPrompt: 'take notes',
|
|
1674
|
-
toolDefinitions: [def('notes')],
|
|
1675
|
-
toolHandlers: {
|
|
1676
|
-
notes: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1677
|
-
ctx.condenseWhen({ on: { kind: 'turnEnd' }, response: 'pointer' });
|
|
1678
|
-
return bigBody;
|
|
1679
|
-
}),
|
|
1680
|
-
},
|
|
1681
|
-
});
|
|
1682
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
1683
|
-
yield driver.sendMessage('first'); // turn 1
|
|
1684
|
-
yield driver.sendMessage('second'); // turn 2
|
|
1685
|
-
const inTurn1 = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1686
|
-
assert.is(inTurn1.toolResult.content, bigBody, 'full for the rest of the request it served');
|
|
1687
|
-
const inTurn2 = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1688
|
-
assert.match(inTurn2.toolResult.content, /re-call to restore/, 'collapsed once the turn ended');
|
|
1689
|
-
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1690
|
-
assert.is(events.length, 1);
|
|
1691
|
-
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'turnEnd');
|
|
1692
|
-
}));
|
|
1693
|
-
condense('agentEnd: collapses once the agent releases (flow complete)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1694
|
-
var _a;
|
|
1695
|
-
const sessionKey = 'condense-agent-end';
|
|
1696
|
-
clearMetaEventRegistry();
|
|
1697
|
-
const provider = scriptedProvider([
|
|
1698
|
-
{ role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
|
|
1699
|
-
{ role: 'assistant', content: '', toolCalls: [{ id: 'f1', name: 'finish', args: {} }] }, // call 2 — releases
|
|
1700
|
-
{ role: 'assistant', content: 'wrapped up' }, // call 3 — flow done
|
|
1701
|
-
]);
|
|
1702
|
-
const config = agent({
|
|
1703
|
-
name: 'wizard',
|
|
1704
|
-
systemPrompt: 'a flow',
|
|
1705
|
-
toolDefinitions: [def('load'), def('finish')],
|
|
1706
|
-
toolHandlers: {
|
|
1707
|
-
load: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1708
|
-
ctx.condenseWhen({ on: { kind: 'agentEnd' }, response: 'pointer' });
|
|
1709
|
-
return bigBody;
|
|
1710
|
-
}),
|
|
1711
|
-
finish: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1712
|
-
ctx.releaseAgent();
|
|
1713
|
-
return 'flow complete';
|
|
1714
|
-
}),
|
|
1715
|
-
},
|
|
1716
|
-
});
|
|
1717
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
1718
|
-
yield driver.sendMessage('run the flow');
|
|
1719
|
-
// call #2 (index 1) ran before `finish` released — schema still full.
|
|
1720
|
-
const beforeRelease = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 's1'; });
|
|
1721
|
-
assert.is(beforeRelease.toolResult.content, bigBody, 'full while the flow is active');
|
|
1722
|
-
// call #3 (index 2) ran after release — schema collapsed.
|
|
1723
|
-
const afterRelease = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 's1'; });
|
|
1724
|
-
assert.match(afterRelease.toolResult.content, /re-call to restore/, 'collapsed once released');
|
|
1725
|
-
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1726
|
-
assert.is(events.length, 1);
|
|
1727
|
-
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'agentEnd');
|
|
1728
|
-
}));
|
|
1729
|
-
condense('phaseEnd: collapses at endPhase() while the agent keeps running', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1730
|
-
var _a;
|
|
1731
|
-
const sessionKey = 'condense-phase-end';
|
|
1732
|
-
clearMetaEventRegistry();
|
|
1733
|
-
const provider = scriptedProvider([
|
|
1734
|
-
{ role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
|
|
1735
|
-
{ role: 'assistant', content: '', toolCalls: [{ id: 'a1', name: 'advance', args: {} }] }, // call 2 — ends the phase
|
|
1736
|
-
{ role: 'assistant', content: 'continuing' }, // call 3 — agent still active, just answers
|
|
1737
|
-
]);
|
|
1738
|
-
const config = agent({
|
|
1739
|
-
name: 'journey',
|
|
1740
|
-
systemPrompt: 'a multi-phase flow',
|
|
1741
|
-
toolDefinitions: [def('load'), def('advance')],
|
|
1742
|
-
toolHandlers: {
|
|
1743
|
-
load: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1744
|
-
ctx.condenseWhen({ on: { kind: 'phaseEnd' }, response: 'pointer' });
|
|
1745
|
-
return bigBody;
|
|
1746
|
-
}),
|
|
1747
|
-
advance: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1748
|
-
var _a;
|
|
1749
|
-
// Declares a phase boundary WITHOUT ending the agent (no releaseAgent).
|
|
1750
|
-
(_a = ctx.endPhase) === null || _a === void 0 ? void 0 : _a.call(ctx);
|
|
1751
|
-
return 'next phase';
|
|
1752
|
-
}),
|
|
1753
|
-
},
|
|
1754
|
-
});
|
|
1755
|
-
const driver = makeDriver(config, provider, sessionKey);
|
|
1756
|
-
const result = yield driver.sendMessage('run the flow');
|
|
1757
|
-
// The agent never released — the turn ends normally on the text response.
|
|
1758
|
-
assert.is(result.reason, 'done');
|
|
1759
|
-
// call #2 (index 1) ran before `advance` ended the phase — result still full.
|
|
1760
|
-
const beforePhaseEnd = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 's1'; });
|
|
1761
|
-
assert.is(beforePhaseEnd.toolResult.content, bigBody, 'full while the phase is current');
|
|
1762
|
-
// call #3 (index 2) ran after endPhase() — collapsed. The activation never ended
|
|
1763
|
-
// (no release/swap), so this collapse can ONLY be the phase boundary, not the
|
|
1764
|
-
// agentEnd backstop.
|
|
1765
|
-
const afterPhaseEnd = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 's1'; });
|
|
1766
|
-
assert.match(afterPhaseEnd.toolResult.content, /re-call to restore/, 'collapsed at phase end');
|
|
1767
|
-
assert.match(afterPhaseEnd.toolResult.content, /phase ended/);
|
|
1768
|
-
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1769
|
-
assert.is(events.length, 1);
|
|
1770
|
-
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'phaseEnd');
|
|
1771
|
-
}));
|
|
1772
|
-
condense.run();
|
|
1773
|
-
// ---------------------------------------------------------------------------
|
|
1774
|
-
// turn-outcome surfacing (PTC-0) — the typed failure reason must reach BOTH
|
|
1775
|
-
// the loop-boundary result (`ChatDriverResult.failureReason`) and the
|
|
1776
|
-
// `tool-loop-end` activity-bus event detail. Historically every exit flattened
|
|
1777
|
-
// to `{ reason: 'done' }` and the bus detail was `undefined`; these lock the
|
|
1778
|
-
// two seams together, one per `TurnFailureReason`, plus a happy-path compat
|
|
1779
|
-
// check that the legacy shape is byte-unchanged.
|
|
1780
|
-
// ---------------------------------------------------------------------------
|
|
1781
|
-
const outcome = createLogicSuite('ChatDriver turn-outcome surfacing');
|
|
1782
|
-
// This branch routes activity events to an INJECTED bus (default NOOP), not the module
|
|
1783
|
-
// singleton, so these tests inject a local bus and observe the same seam the driver publishes to.
|
|
1784
|
-
const outcomeBus = new AgenticActivityBus();
|
|
1785
|
-
outcome.after(() => {
|
|
1786
|
-
outcomeBus.close();
|
|
1787
|
-
});
|
|
1788
|
-
/**
|
|
1789
|
-
* Subscribe to `tool-loop-end` and expose the most recent detail seen. `fired()` reports
|
|
1790
|
-
* whether the event was seen at all — distinct from `detail()`, which is `undefined` both
|
|
1791
|
-
* when no event fired and when a clean turn fired with the historical `undefined` detail.
|
|
1792
|
-
*/
|
|
1793
|
-
const captureLoopEnd = () => {
|
|
1794
|
-
let last;
|
|
1795
|
-
let seen = false;
|
|
1796
|
-
const stop = outcomeBus.subscribe('tool-loop-end', (d) => {
|
|
1797
|
-
last = d;
|
|
1798
|
-
seen = true;
|
|
1799
|
-
});
|
|
1800
|
-
return { detail: () => (seen ? last : undefined), fired: () => seen, stop };
|
|
1801
|
-
};
|
|
1802
|
-
/** A ChatDriver with an explicit (small) tool-iteration cap. */
|
|
1803
|
-
const makeCappedDriver = (config, provider, maxIterations, sessionKey = '') => {
|
|
1804
|
-
const driver = new ChatDriver(makeRegistry(provider), {
|
|
1805
|
-
maxToolIterations: maxIterations,
|
|
1806
|
-
maxFoldOperations: 5,
|
|
1807
|
-
sessionKey,
|
|
1808
|
-
activityBus: outcomeBus,
|
|
1809
|
-
});
|
|
1810
|
-
driver.applyAgent(config);
|
|
1811
|
-
return driver;
|
|
1812
|
-
};
|
|
1813
|
-
/**
|
|
1814
|
-
* Assert that a turn driven by `provider` surfaces `expected` at BOTH seams.
|
|
1815
|
-
* `driverFor` lets the max-iterations case swap in a low cap.
|
|
1816
|
-
*/
|
|
1817
|
-
const assertSurfacesReason = (label_1, provider_1, expected_1, ...args_1) => __awaiter(void 0, [label_1, provider_1, expected_1, ...args_1], void 0, function* (label, provider, expected, driverFor = (c, p, k) => makeDriver(c, p, k, outcomeBus)) {
|
|
1818
|
-
var _a;
|
|
1819
|
-
clearMetaEventRegistry();
|
|
1820
|
-
const sessionKey = `outcome-${label}`;
|
|
1821
|
-
const config = agent({
|
|
1822
|
-
name: 'Static',
|
|
1823
|
-
toolDefinitions: [def('noop')],
|
|
1824
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
1825
|
-
});
|
|
1826
|
-
const driver = driverFor(config, provider, sessionKey);
|
|
1827
|
-
const cap = captureLoopEnd();
|
|
1828
|
-
const result = yield driver.sendMessage('go');
|
|
1829
|
-
// Seam 1 — the loop-boundary result. Discriminant stays 'done' (compat).
|
|
1830
|
-
assert.is(result.reason, 'done', `[${label}] discriminant stays 'done'`);
|
|
1831
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, expected, `[${label}] result.failureReason surfaces the typed reason`);
|
|
1832
|
-
// Seam 2 — the activity-bus tool-loop-end detail.
|
|
1833
|
-
assert.ok(cap.detail(), `[${label}] a tool-loop-end event fired`);
|
|
1834
|
-
assert.is(cap.detail().failureReason, expected, `[${label}] the bus detail carries the same reason`);
|
|
1835
|
-
// Consistency: the debug-log turn.error records the same taxonomy.
|
|
1836
|
-
const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
|
|
1837
|
-
assert.ok(err, `[${label}] a turn.error is recorded`);
|
|
1838
|
-
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.reason, expected, `[${label}] the debug-log reason matches`);
|
|
1839
|
-
cap.stop();
|
|
1840
|
-
});
|
|
1841
|
-
/** A provider that returns a MALFORMED_FUNCTION_CALL on every call. */
|
|
1842
|
-
const malformedProvider = () => ({
|
|
1843
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1844
|
-
throw new MalformedFunctionCallError('bad call');
|
|
1845
|
-
}),
|
|
1846
|
-
});
|
|
1847
|
-
/** A provider that returns an empty response on every call. */
|
|
1848
|
-
const emptyProvider = () => ({
|
|
1849
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: '' }); }),
|
|
1850
|
-
});
|
|
1851
|
-
/** A provider that throws a ResponseTruncatedError (deterministic, no retry). */
|
|
1852
|
-
const truncatedProvider = () => ({
|
|
1853
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1854
|
-
throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
|
|
1855
|
-
}),
|
|
1856
|
-
});
|
|
1857
|
-
/** A provider refused for budget (deterministic, terminal — must not retry). */
|
|
1858
|
-
const budgetExhaustedProvider = () => ({
|
|
1859
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1860
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
|
|
1861
|
-
}),
|
|
1862
|
-
});
|
|
1863
|
-
/** A provider the vendor refused outright (GENC-1506 — terminal, must not retry). */
|
|
1864
|
-
const providerRefusedProvider = (kind = 'spend') => ({
|
|
1865
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1866
|
-
// Both fixtures are the real captured shapes (2026-08-18), matched to their kind — an `auth`
|
|
1867
|
-
// fixture carrying a credit-balance message would read as a classification bug to the next person.
|
|
1868
|
-
throw kind === 'auth'
|
|
1869
|
-
? new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.')
|
|
1870
|
-
: new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'Your credit balance is too low to access the Anthropic API.');
|
|
1871
|
-
}),
|
|
1872
|
-
});
|
|
1873
|
-
/** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
|
|
1874
|
-
const throwingProvider = () => ({
|
|
1875
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1876
|
-
throw new Error('boom');
|
|
1877
|
-
}),
|
|
1878
|
-
});
|
|
1879
|
-
/** A provider that never stops calling a valid tool (drives the iteration cap). */
|
|
1880
|
-
const neverStopsProvider = () => {
|
|
1881
|
-
let n = 0;
|
|
1882
|
-
return {
|
|
1883
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1884
|
-
const id = `noop-${n}`;
|
|
1885
|
-
n += 1;
|
|
1886
|
-
return { role: 'assistant', content: '', toolCalls: [{ id, name: 'noop', args: {} }] };
|
|
1887
|
-
}),
|
|
1888
|
-
};
|
|
1889
|
-
};
|
|
1890
|
-
/** A provider that keeps calling a tool with no handler (hallucinated → limit). */
|
|
1891
|
-
const hallucinatedProvider = () => scriptedProvider(Array.from({ length: 6 }, (_u, i) => callsTool('ghost', `ghost-${i}`)));
|
|
1892
|
-
/** A provider that refuses: empty content + finishReason 'refusal' (deterministic, must not retry). */
|
|
1893
|
-
const refusalProvider = () => ({
|
|
1894
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1895
|
-
return ({
|
|
1896
|
-
role: 'assistant',
|
|
1897
|
-
content: '',
|
|
1898
|
-
responseMeta: { finishReason: 'refusal' },
|
|
1899
|
-
});
|
|
1900
|
-
}),
|
|
1901
|
-
});
|
|
1902
|
-
outcome('malformed-function-call surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1903
|
-
yield assertSurfacesReason('malformed', malformedProvider(), 'malformed-function-call');
|
|
1904
|
-
}));
|
|
1905
|
-
outcome('empty-response surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1906
|
-
yield assertSurfacesReason('empty', emptyProvider(), 'empty-response');
|
|
1907
|
-
}));
|
|
1908
|
-
outcome('response-truncated surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1909
|
-
yield assertSurfacesReason('truncated', truncatedProvider(), 'response-truncated');
|
|
1910
|
-
}));
|
|
1911
|
-
outcome('exception surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1912
|
-
yield assertSurfacesReason('exception', throwingProvider(), 'exception');
|
|
1913
|
-
}));
|
|
1914
|
-
outcome('unknown-tool-limit surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1915
|
-
yield assertSurfacesReason('unknown-tool', hallucinatedProvider(), 'unknown-tool-limit');
|
|
1916
|
-
}));
|
|
1917
|
-
outcome('max-iterations surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1918
|
-
yield assertSurfacesReason('max-iter', neverStopsProvider(), 'max-iterations', (c, p, k) => makeCappedDriver(c, p, 2, k));
|
|
1919
|
-
}));
|
|
1920
|
-
// A refusal reaching 'refusal' (not 'empty-response') at both seams proves the short-circuit fired —
|
|
1921
|
-
// otherwise the empty content would retry to exhaustion and surface as 'empty-response'.
|
|
1922
|
-
outcome('refusal surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1923
|
-
yield assertSurfacesReason('refusal', refusalProvider(), 'refusal');
|
|
1924
|
-
}));
|
|
1925
|
-
outcome('a refusal is deterministic — surfaced without any empty-response retry', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1926
|
-
clearMetaEventRegistry();
|
|
1927
|
-
let calls = 0;
|
|
1928
|
-
const provider = {
|
|
1929
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1930
|
-
calls += 1;
|
|
1931
|
-
return { role: 'assistant', content: '', responseMeta: { finishReason: 'refusal' } };
|
|
1932
|
-
}),
|
|
1933
|
-
};
|
|
1934
|
-
const config = agent({
|
|
1935
|
-
name: 'Static',
|
|
1936
|
-
toolDefinitions: [def('noop')],
|
|
1937
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
1938
|
-
});
|
|
1939
|
-
const driver = makeDriver(config, provider, 'outcome-refusal-once', outcomeBus);
|
|
1940
|
-
const result = yield driver.sendMessage('go');
|
|
1941
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'refusal', 'refusal surfaces as the typed failure reason');
|
|
1942
|
-
assert.is(calls, 1, 'a refusal is not retried — exactly one model call');
|
|
1943
|
-
}));
|
|
1944
|
-
outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1945
|
-
clearMetaEventRegistry();
|
|
1946
|
-
const config = agent({ name: 'Static' });
|
|
1947
|
-
// Plain-text reply ends the turn cleanly on the first call.
|
|
1948
|
-
const driver = makeDriver(config, scriptedProvider([{ role: 'assistant', content: 'hi' }]), 'outcome-ok', outcomeBus);
|
|
1949
|
-
const cap = captureLoopEnd();
|
|
1950
|
-
const result = yield driver.sendMessage('go');
|
|
1951
|
-
// The result is exactly `{ reason: 'done' }` — no `failureReason` key added.
|
|
1952
|
-
assert.equal(result, { reason: 'done' }, 'happy-path result is the historical shape');
|
|
1953
|
-
assert.not.ok('failureReason' in result, 'no failureReason key is present on a clean turn');
|
|
1954
|
-
// The bus event still fires, and its detail is the historical `undefined` — a clean
|
|
1955
|
-
// turn emits no detail object at all (byte-shape compat), not `{ failureReason: undefined }`.
|
|
1956
|
-
assert.ok(cap.fired(), 'a tool-loop-end event fired');
|
|
1957
|
-
assert.is(cap.detail(), undefined, 'a clean turn emits the historical undefined detail');
|
|
1958
|
-
// And no turn.error was recorded.
|
|
1959
|
-
assert.not.ok(getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'), 'a clean turn records no turn.error');
|
|
1960
|
-
cap.stop();
|
|
1961
|
-
}));
|
|
1962
|
-
// ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
|
|
1963
|
-
outcome('provider-refused surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1964
|
-
yield assertSurfacesReason('provider-refused', providerRefusedProvider(), 'provider-refused');
|
|
1965
|
-
}));
|
|
1966
|
-
outcome('a provider refusal is terminal — no retry, cause-free copy, kind in the log', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1967
|
-
var _a, _b;
|
|
1968
|
-
// GENC-1506. The measured bug this pins: the refusal used to arrive as an untyped transport error,
|
|
1969
|
-
// get re-issued MAX_SETUP_TRANSPORT_RETRIES times against a wall that cannot move, and then surface
|
|
1970
|
-
// as "something went wrong on my end" — wrong twice over, since nothing went wrong on our end and
|
|
1971
|
-
// trying again cannot help.
|
|
1972
|
-
clearMetaEventRegistry();
|
|
1973
|
-
let calls = 0;
|
|
1974
|
-
const provider = {
|
|
1975
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1976
|
-
calls += 1;
|
|
1977
|
-
throw new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'credit too low');
|
|
1978
|
-
}),
|
|
1979
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'anthropic', model: 'test-model' }); }),
|
|
1980
|
-
};
|
|
1981
|
-
const config = agent({
|
|
1982
|
-
name: 'Static',
|
|
1983
|
-
toolDefinitions: [def('noop')],
|
|
1984
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
1985
|
-
});
|
|
1986
|
-
const driver = makeDriver(config, provider, 'outcome-provider-refused', outcomeBus);
|
|
1987
|
-
const result = yield driver.sendMessage('go');
|
|
1988
|
-
assert.is(calls, 1, 'a provider refusal is not retried — exactly one model call');
|
|
1989
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused', 'the turn ends with the provider-refused failure reason');
|
|
1990
|
-
// The kind reaches the CALLER, which is what lets ai-service's Sentry alert say whether an operator
|
|
1991
|
-
// must top up an account or rotate a key. It is the only place that distinction survives, because
|
|
1992
|
-
// the user-facing sentence is deliberately cause-free.
|
|
1993
|
-
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
1994
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'spend');
|
|
1995
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
|
|
1996
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 400);
|
|
1997
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamType, 'invalid_request_error');
|
|
1998
|
-
const last = driver.getHistory().at(-1);
|
|
1999
|
-
assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
|
|
2000
|
-
// Identity, not a substring: the sentence is provisional copy shared with ai-service via one
|
|
2001
|
-
// exported const, and this is what stops a second copy drifting in.
|
|
2002
|
-
assert.is(last.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2003
|
-
assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic apology');
|
|
2004
|
-
// The copy must not leak the cause, whatever the kind — a user who can see remaining spend must not
|
|
2005
|
-
// be told about a limit, and we must not imply the bill went unpaid.
|
|
2006
|
-
for (const banned of ['credit', 'billing', 'balance', 'quota', 'limit']) {
|
|
2007
|
-
assert.not.ok(last.content.toLowerCase().includes(banned), `copy must not mention "${banned}"`);
|
|
2008
|
-
}
|
|
2009
|
-
const err = getMetaEvents('outcome-provider-refused').find((e) => e.type === 'turn.error');
|
|
2010
|
-
assert.is((_a = err === null || err === void 0 ? void 0 : err.detail) === null || _a === void 0 ? void 0 : _a.reason, 'provider-refused');
|
|
2011
|
-
assert.is((_b = err === null || err === void 0 ? void 0 : err.detail) === null || _b === void 0 ? void 0 : _b.kind, 'spend');
|
|
2012
|
-
}));
|
|
2013
|
-
outcome('an auth refusal shows the SAME sentence but logs a different kind', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2014
|
-
var _a;
|
|
2015
|
-
// The whole justification for one type with two kinds: identical user experience, distinct
|
|
2016
|
-
// diagnostics. If these ever diverge in the transcript, the copy decision has been undone.
|
|
2017
|
-
clearMetaEventRegistry();
|
|
2018
|
-
const config = agent({
|
|
2019
|
-
name: 'Static',
|
|
2020
|
-
toolDefinitions: [def('noop')],
|
|
2021
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2022
|
-
});
|
|
2023
|
-
const driver = makeDriver(config, providerRefusedProvider('auth'), 'outcome-refused-auth', outcomeBus);
|
|
2024
|
-
const result = yield driver.sendMessage('go');
|
|
2025
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2026
|
-
assert.is(result.reason === 'done' ? (_a = result.providerRefused) === null || _a === void 0 ? void 0 : _a.kind : undefined, 'auth');
|
|
2027
|
-
assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2028
|
-
}));
|
|
2029
|
-
outcome('a host override replaces the provider-refusal sentence verbatim', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2030
|
-
const config = agent({
|
|
2031
|
-
name: 'Static',
|
|
2032
|
-
toolDefinitions: [def('noop')],
|
|
2033
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2034
|
-
});
|
|
2035
|
-
const driver = new ChatDriver(makeRegistry(providerRefusedProvider()), {
|
|
2036
|
-
maxToolIterations: 50,
|
|
2037
|
-
maxFoldOperations: 5,
|
|
2038
|
-
sessionKey: 'outcome-refused-override',
|
|
2039
|
-
providerRefusedMessage: 'Bespoke wording for a white-labelled host.',
|
|
2040
|
-
});
|
|
2041
|
-
driver.applyAgent(config);
|
|
2042
|
-
yield driver.sendMessage('go');
|
|
2043
|
-
assert.is(driver.getHistory().at(-1).content, 'Bespoke wording for a white-labelled host.');
|
|
2044
|
-
driver.dispose();
|
|
2045
|
-
}));
|
|
2046
|
-
outcome('budget-exhausted surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2047
|
-
yield assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
|
|
2048
|
-
}));
|
|
2049
|
-
outcome('a budget wall is terminal — no transient retry, and actionable copy', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2050
|
-
var _a, _b, _c, _d;
|
|
2051
|
-
clearMetaEventRegistry();
|
|
2052
|
-
let calls = 0;
|
|
2053
|
-
const provider = {
|
|
2054
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2055
|
-
calls += 1;
|
|
2056
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2057
|
-
}),
|
|
2058
|
-
// Resolvable vendor, so the per-vendor fields below are exercised.
|
|
2059
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'anthropic', model: 'test-model' }); }),
|
|
2060
|
-
};
|
|
2061
|
-
const config = agent({
|
|
2062
|
-
name: 'Static',
|
|
2063
|
-
toolDefinitions: [def('noop')],
|
|
2064
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2065
|
-
});
|
|
2066
|
-
const driver = makeDriver(config, provider, 'outcome-budget-once', outcomeBus);
|
|
2067
|
-
const cap = captureLoopEnd();
|
|
2068
|
-
const result = yield driver.sendMessage('go');
|
|
2069
|
-
// The whole point of the typed branch: it sits BEFORE the transient-retry
|
|
2070
|
-
// catch, so the driver must not burn MAX_SETUP_TRANSPORT_RETRIES attempts
|
|
2071
|
-
// against a wall that no retry can clear.
|
|
2072
|
-
assert.is(calls, 1, 'a budget wall is not retried — exactly one model call');
|
|
2073
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted', 'the turn ends with the budget-exhausted failure reason');
|
|
2074
|
-
// The figures now reach the CALLER, not just the debug log — this is what lets
|
|
2075
|
-
// the element render "$25.40 of $25" in its banner without catching the
|
|
2076
|
-
// transport error itself.
|
|
2077
|
-
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2078
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.budgetUsd, 25);
|
|
2079
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.spentUsd, 25.4);
|
|
2080
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendorLabel, 'Anthropic');
|
|
2081
|
-
// The bus detail carries the resolved vendor beside the reason.
|
|
2082
|
-
assert.is(cap.detail().failureReason, 'budget-exhausted');
|
|
2083
|
-
assert.is(cap.detail().vendor, 'anthropic');
|
|
2084
|
-
cap.stop();
|
|
2085
|
-
const last = driver.getHistory().at(-1);
|
|
2086
|
-
assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
|
|
2087
|
-
// Identity, not substrings: the transcript bubble and the element's blocked
|
|
2088
|
-
// banner now read from one shared const, and this is what pins them together.
|
|
2089
|
-
assert.is(last.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2090
|
-
assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic apology');
|
|
2091
|
-
// The budget figures ride the debug-log entry so a support ticket can show them.
|
|
2092
|
-
const err = getMetaEvents('outcome-budget-once').find((e) => e.type === 'turn.error');
|
|
2093
|
-
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.budgetUsd, 25);
|
|
2094
|
-
assert.is((_b = err.detail) === null || _b === void 0 ? void 0 : _b.spentUsd, 25.4);
|
|
2095
|
-
// The registry ALIAS and the VENDOR are different things; record both.
|
|
2096
|
-
assert.is((_c = err.detail) === null || _c === void 0 ? void 0 : _c.provider, 'test');
|
|
2097
|
-
assert.is((_d = err.detail) === null || _d === void 0 ? void 0 : _d.vendor, 'anthropic');
|
|
2098
|
-
}));
|
|
2099
|
-
outcome('a non-budget failure emits the historical detail shape, with no vendor', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2100
|
-
// Back-compat for subscribers that structurally compare the `tool-loop-end`
|
|
2101
|
-
// detail: only a budget failure grew a field.
|
|
2102
|
-
clearMetaEventRegistry();
|
|
2103
|
-
const config = agent({
|
|
2104
|
-
name: 'Static',
|
|
2105
|
-
toolDefinitions: [def('noop')],
|
|
2106
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2107
|
-
});
|
|
2108
|
-
const driver = makeDriver(config, throwingProvider(), 'outcome-vendor-compat', outcomeBus);
|
|
2109
|
-
const cap = captureLoopEnd();
|
|
2110
|
-
yield driver.sendMessage('go');
|
|
2111
|
-
assert.equal(cap.detail(), { failureReason: 'exception' }, 'no vendor key was added');
|
|
2112
|
-
cap.stop();
|
|
2113
|
-
}));
|
|
2114
|
-
outcome('a 402 with no figures still carries the vendor that refused it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2115
|
-
var _a;
|
|
2116
|
-
// The regression this pins: the driver used to drop the ENTIRE budget object
|
|
2117
|
-
// when both figures were absent, and `vendorLabel` — the authoritative
|
|
2118
|
-
// attribution source — went with it. The element then fell back to the
|
|
2119
|
-
// driver's last-resolved provider, i.e. the PREVIOUS turn's vendor, and walled
|
|
2120
|
-
// the wrong one. Three live shapes arrive here with no figures: a bare gateway
|
|
2121
|
-
// 402, a code-only body, and the framed err frame whose figures are `null`.
|
|
2122
|
-
clearMetaEventRegistry();
|
|
2123
|
-
const provider = {
|
|
2124
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2125
|
-
throw new BudgetExhaustedError('Anthropic');
|
|
2126
|
-
}),
|
|
2127
|
-
};
|
|
2128
|
-
const config = agent({
|
|
2129
|
-
name: 'Static',
|
|
2130
|
-
toolDefinitions: [def('noop')],
|
|
2131
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2132
|
-
});
|
|
2133
|
-
const driver = makeDriver(config, provider, 'outcome-budget-nofigures', outcomeBus);
|
|
2134
|
-
const cap = captureLoopEnd();
|
|
2135
|
-
const result = yield driver.sendMessage('go');
|
|
2136
|
-
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2137
|
-
assert.ok(budget, 'a figure-less wall still reports who refused it');
|
|
2138
|
-
assert.is(budget.vendorLabel, 'Anthropic');
|
|
2139
|
-
assert.is(budget.vendor, 'anthropic');
|
|
2140
|
-
// Still no figures — the element reads their ABSENCE as "leave any host-set
|
|
2141
|
-
// banner copy alone", so they must not be invented.
|
|
2142
|
-
assert.is(budget.budgetUsd, undefined);
|
|
2143
|
-
assert.is(budget.spentUsd, undefined);
|
|
2144
|
-
// Both seams, since the bus one lands first and wins the latch.
|
|
2145
|
-
assert.is((_a = cap.detail().budget) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic');
|
|
2146
|
-
cap.stop();
|
|
2147
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2148
|
-
}));
|
|
2149
|
-
outcome('a wall with nothing to report at all still omits the budget key', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2150
|
-
// The other direction: `budget` is present when there is something to say and
|
|
2151
|
-
// absent when there is not, so presence keeps meaning something. No figures AND
|
|
2152
|
-
// an unclaimed label leaves nothing to carry — and the element's fail-safe
|
|
2153
|
-
// (block everything) is the correct outcome for an unattributable wall.
|
|
2154
|
-
clearMetaEventRegistry();
|
|
2155
|
-
const provider = {
|
|
2156
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2157
|
-
throw new BudgetExhaustedError('Acme AI');
|
|
2158
|
-
}),
|
|
2159
|
-
};
|
|
2160
|
-
const config = agent({
|
|
2161
|
-
name: 'Static',
|
|
2162
|
-
toolDefinitions: [def('noop')],
|
|
2163
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2164
|
-
});
|
|
2165
|
-
const driver = makeDriver(config, provider, 'outcome-budget-nothing', outcomeBus);
|
|
2166
|
-
const result = yield driver.sendMessage('go');
|
|
2167
|
-
assert.not.ok('budget' in result, 'nothing reportable → no budget key');
|
|
2168
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2169
|
-
}));
|
|
2170
|
-
outcome('a figure-less wall attributes to the refusing transport, not the last turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2171
|
-
var _a;
|
|
2172
|
-
// The exact failure: turn 1 runs on Gemini, turn 2's request is refused by
|
|
2173
|
-
// Anthropic with a figure-less 402. `lastResolvedProvider` still says `gemini`,
|
|
2174
|
-
// so the pre-fix driver reported Gemini and the user was told to switch to the
|
|
2175
|
-
// vendor that had actually run out.
|
|
2176
|
-
clearMetaEventRegistry();
|
|
2177
|
-
let walled = false;
|
|
2178
|
-
const provider = {
|
|
2179
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2180
|
-
if (!walled) {
|
|
2181
|
-
walled = true;
|
|
2182
|
-
return { role: 'assistant', content: 'first turn, on Gemini' };
|
|
2183
|
-
}
|
|
2184
|
-
throw new BudgetExhaustedError('Anthropic');
|
|
2185
|
-
}),
|
|
2186
|
-
};
|
|
2187
|
-
// The registry resolves to Gemini, so `lastResolvedProvider` is `gemini` by the
|
|
2188
|
-
// time the Anthropic wall lands — the disagreement has to be seeded to prove
|
|
2189
|
-
// which source is believed.
|
|
2190
|
-
const registry = {
|
|
2191
|
-
get: () => provider,
|
|
2192
|
-
default: () => provider,
|
|
2193
|
-
defaultName: () => 'test',
|
|
2194
|
-
names: () => ['test'],
|
|
2195
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2196
|
-
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2197
|
-
};
|
|
2198
|
-
const driver = new ChatDriver(registry, {
|
|
2199
|
-
sessionKey: 'outcome-budget-stale-vendor',
|
|
2200
|
-
activityBus: outcomeBus,
|
|
2201
|
-
});
|
|
2202
|
-
driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
|
|
2203
|
-
yield driver.sendMessage('one');
|
|
2204
|
-
const result = yield driver.sendMessage('two');
|
|
2205
|
-
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2206
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', 'the transport that said no, not the one before it');
|
|
2207
|
-
const err = getMetaEvents('outcome-budget-stale-vendor').find((e) => e.type === 'turn.error');
|
|
2208
|
-
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic', 'and the debug log agrees');
|
|
2209
|
-
}));
|
|
2210
|
-
outcome("the proxy's own `vendor` rescues an unclaimed transport label", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2211
|
-
// A multiplexing or white-labelled gateway fronts several upstreams behind one
|
|
2212
|
-
// transport, so its static label names none of them. The 402's `vendor` is then
|
|
2213
|
-
// the only thing that knows which pot was actually booked against — second
|
|
2214
|
-
// opinion, never first: the transport's own label still wins where it resolves.
|
|
2215
|
-
clearMetaEventRegistry();
|
|
2216
|
-
const provider = {
|
|
2217
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2218
|
-
throw new BudgetExhaustedError('Acme AI', undefined, undefined, undefined, {
|
|
2219
|
-
serverVendor: 'Gemini',
|
|
2220
|
-
});
|
|
2221
|
-
}),
|
|
2222
|
-
};
|
|
2223
|
-
const config = agent({
|
|
2224
|
-
name: 'Static',
|
|
2225
|
-
toolDefinitions: [def('noop')],
|
|
2226
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2227
|
-
});
|
|
2228
|
-
const driver = makeDriver(config, provider, 'outcome-budget-server-vendor', outcomeBus);
|
|
2229
|
-
const result = yield driver.sendMessage('go');
|
|
2230
|
-
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2231
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendorLabel, 'Acme AI', 'the transport label is reported verbatim');
|
|
2232
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'gemini', 'and attribution still lands');
|
|
2233
|
-
}));
|
|
2234
|
-
outcome("the proxy's `otherVendorAvailable` verdict reaches both seams", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2235
|
-
var _a, _b;
|
|
2236
|
-
// The one fact the element cannot re-derive: registry membership says which
|
|
2237
|
-
// vendors EXIST, never which still have budget. It has to survive the trip.
|
|
2238
|
-
clearMetaEventRegistry();
|
|
2239
|
-
const provider = {
|
|
2240
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2241
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25, undefined, {
|
|
2242
|
-
otherVendorAvailable: false,
|
|
2243
|
-
});
|
|
2244
|
-
}),
|
|
2245
|
-
};
|
|
2246
|
-
const config = agent({
|
|
2247
|
-
name: 'Static',
|
|
2248
|
-
toolDefinitions: [def('noop')],
|
|
2249
|
-
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2250
|
-
});
|
|
2251
|
-
const driver = makeDriver(config, provider, 'outcome-budget-other-vendor', outcomeBus);
|
|
2252
|
-
const cap = captureLoopEnd();
|
|
2253
|
-
const result = yield driver.sendMessage('go');
|
|
2254
|
-
assert.is(result.reason === 'done' ? (_a = result.budget) === null || _a === void 0 ? void 0 : _a.otherVendorAvailable : undefined, false, 'the return-value seam');
|
|
2255
|
-
assert.is((_b = cap.detail().budget) === null || _b === void 0 ? void 0 : _b.otherVendorAvailable, false, 'and the bus seam, which lands first and wins the latch');
|
|
2256
|
-
cap.stop();
|
|
2257
|
-
}));
|
|
2258
|
-
outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2259
|
-
clearMetaEventRegistry();
|
|
2260
|
-
const provider = {
|
|
2261
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2262
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2263
|
-
}),
|
|
2264
|
-
};
|
|
2265
|
-
const driver = new ChatDriver(makeRegistry(provider), {
|
|
2266
|
-
sessionKey: 'outcome-budget-custom',
|
|
2267
|
-
activityBus: outcomeBus,
|
|
2268
|
-
budgetExhaustedMessage: 'Contact ops@acme.com',
|
|
2269
|
-
});
|
|
2270
|
-
driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
|
|
2271
|
-
yield driver.sendMessage('go');
|
|
2272
|
-
// The banner was already overridable; without this seam a white-labelled host
|
|
2273
|
-
// got its own copy in the banner and 'contact Genesis' directly below it.
|
|
2274
|
-
assert.is(driver.getHistory().at(-1).content, 'Contact ops@acme.com');
|
|
2275
|
-
}));
|
|
2276
|
-
// ── The composed default bubble (GENC-1464 tester finding) ─────────────────────
|
|
2277
|
-
// The blocked banner advises switching when another vendor has headroom; the
|
|
2278
|
-
// transcript bubble directly under it used to say only "contact your
|
|
2279
|
-
// administrator" regardless. The two surfaces must agree, and the bubble may
|
|
2280
|
-
// only advise a switch the proxy positively vouched for.
|
|
2281
|
-
/** Drive one walled turn and return the transcript's final (bubble) content. */
|
|
2282
|
-
const bubbleAfterWall = (sessionKey, e, budgetExhaustedMessage) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2283
|
-
clearMetaEventRegistry();
|
|
2284
|
-
const provider = {
|
|
2285
|
-
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2286
|
-
throw e;
|
|
2287
|
-
}),
|
|
2288
|
-
};
|
|
2289
|
-
const driver = new ChatDriver(makeRegistry(provider), Object.assign({ sessionKey, activityBus: outcomeBus }, (budgetExhaustedMessage == null ? {} : { budgetExhaustedMessage })));
|
|
2290
|
-
driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
|
|
2291
|
-
yield driver.sendMessage('go');
|
|
2292
|
-
return String(driver.getHistory().at(-1).content);
|
|
2293
|
-
});
|
|
2294
|
-
outcome('default bubble advises switching when the 402 vouches for headroom elsewhere', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2295
|
-
const content = yield bubbleAfterWall('outcome-bubble-switch', new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }));
|
|
2296
|
-
assert.is(content, "You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.");
|
|
2297
|
-
}));
|
|
2298
|
-
outcome('default bubble stays neutral when every other vendor is exhausted too', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2299
|
-
const content = yield bubbleAfterWall('outcome-bubble-all-walled', new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: false }));
|
|
2300
|
-
// Advising a switch to a vendor the proxy just said is empty would be worse
|
|
2301
|
-
// than the neutral copy.
|
|
2302
|
-
assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2303
|
-
}));
|
|
2304
|
-
outcome('default bubble stays neutral when an older proxy did not say', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2305
|
-
const content = yield bubbleAfterWall('outcome-bubble-no-verdict', new BudgetExhaustedError('Anthropic', 25, 25.4));
|
|
2306
|
-
// `undefined` is "the proxy did not say", not "another vendor is free".
|
|
2307
|
-
assert.is(content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2308
|
-
}));
|
|
2309
|
-
outcome('a host override wins verbatim even when switching would be honest advice', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2310
|
-
const content = yield bubbleAfterWall('outcome-bubble-override-wins', new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, { otherVendorAvailable: true }), 'Contact ops@acme.com');
|
|
2311
|
-
// The white-label contract: host copy is never composed onto.
|
|
2312
|
-
assert.is(content, 'Contact ops@acme.com');
|
|
2313
|
-
}));
|
|
2314
|
-
// ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
|
|
2315
|
-
// `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
|
|
2316
|
-
// parent too". Nothing parent-side read it, so the parent's tool loop appended
|
|
2317
|
-
// the failed outcome as a tool result and called the model again — into the same
|
|
2318
|
-
// wall. With N batched delegations that was N doomed children AND a doomed parent
|
|
2319
|
-
// call, and the element's `failureReason === 'budget-exhausted'` latch never fired.
|
|
2320
|
-
/** A sub-agent whose own turn is refused for budget; the parent's turns are not. */
|
|
2321
|
-
const parentOkChildWalled = () => {
|
|
2322
|
-
let parentCalls = 0;
|
|
2323
|
-
return {
|
|
2324
|
-
parentCalls: () => parentCalls,
|
|
2325
|
-
provider: {
|
|
2326
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2327
|
-
var _a;
|
|
2328
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2329
|
-
if (names.includes('delegate')) {
|
|
2330
|
-
parentCalls += 1;
|
|
2331
|
-
return callsTool('delegate', `d${parentCalls}`);
|
|
2332
|
-
}
|
|
2333
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2334
|
-
}),
|
|
2335
|
-
},
|
|
2336
|
-
};
|
|
2337
|
-
};
|
|
2338
|
-
const walledWorker = () => agent({
|
|
2339
|
-
name: 'worker',
|
|
2340
|
-
toolDefinitions: [def('finish')],
|
|
2341
|
-
toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
|
|
2342
|
-
});
|
|
2343
|
-
/**
|
|
2344
|
-
* The child-refused / parent-fine provider, mirroring `parentOkChildWalled` — the parent's turn call
|
|
2345
|
-
* succeeds and delegates, and only the child's provider refuses.
|
|
2346
|
-
*/
|
|
2347
|
-
const parentOkChildRefused = (kind = 'spend') => {
|
|
2348
|
-
let parentCalls = 0;
|
|
2349
|
-
return {
|
|
2350
|
-
parentCalls: () => parentCalls,
|
|
2351
|
-
provider: {
|
|
2352
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2353
|
-
var _a;
|
|
2354
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2355
|
-
if (names.includes('delegate')) {
|
|
2356
|
-
parentCalls += 1;
|
|
2357
|
-
return callsTool('delegate', `d${parentCalls}`);
|
|
2358
|
-
}
|
|
2359
|
-
throw kind === 'auth'
|
|
2360
|
-
? new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.')
|
|
2361
|
-
: new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'credit too low');
|
|
2362
|
-
}),
|
|
2363
|
-
},
|
|
2364
|
-
};
|
|
2365
|
-
};
|
|
2366
|
-
outcome('a sub-agent provider refusal ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2367
|
-
/*
|
|
2368
|
-
* The seam a review caught, and it is genuinely counter-intuitive: the child is a SEPARATE driver
|
|
2369
|
-
* instance, so the flag it sets on itself is invisible to the parent. The refusal reaches the parent
|
|
2370
|
-
* only as a tool result, and without an `invokeSubAgent` branch that result was ordinary — so the
|
|
2371
|
-
* parent issued another model call straight into the same wall, and if that call had happened to
|
|
2372
|
-
* succeed the turn could have finished with no `failureReason` at all, reporting a clean turn over a
|
|
2373
|
-
* dead account. `parentCalls() === 1` is the assertion that pins it.
|
|
2374
|
-
*/
|
|
2375
|
-
clearMetaEventRegistry();
|
|
2376
|
-
const { parentCalls, provider } = parentOkChildRefused();
|
|
2377
|
-
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2378
|
-
const driver = makeDriver(parent, provider, 'outcome-subagent-refused', outcomeBus);
|
|
2379
|
-
const result = yield driver.sendMessage('go');
|
|
2380
|
-
assert.is(parentCalls(), 1, 'the parent never calls the model again after the refusal');
|
|
2381
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2382
|
-
assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2383
|
-
}));
|
|
2384
|
-
outcome("a sub-agent refusal carries the CHILD's kind and vendor to the parent", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2385
|
-
// The other half of the same finding. The parent can only report what the child bubbled, so without a
|
|
2386
|
-
// payload on the failure the `providerRefused` diagnostic promised on `ChatDriverResult` would be
|
|
2387
|
-
// absent — and with cause-free user copy that payload is the ONLY place an operator learns whether to
|
|
2388
|
-
// top up an account or rotate a credential.
|
|
2389
|
-
clearMetaEventRegistry();
|
|
2390
|
-
const { provider } = parentOkChildRefused('auth');
|
|
2391
|
-
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2392
|
-
const driver = makeDriver(parent, provider, 'outcome-subagent-refused-kind', outcomeBus);
|
|
2393
|
-
const result = yield driver.sendMessage('go');
|
|
2394
|
-
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
2395
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'auth', "the child's kind survives the hop");
|
|
2396
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
|
|
2397
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 401);
|
|
2398
|
-
}));
|
|
2399
|
-
outcome("a GRANDCHILD's refusal keeps its kind and vendor across two hops", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2400
|
-
/*
|
|
2401
|
-
* The third finding, and the one a single-hop test cannot reach. An INTERMEDIATE sub-agent latches its
|
|
2402
|
-
* grandchild's payload correctly via `invokeSubAgent`, but then has to hand it on through
|
|
2403
|
-
* `failSubAgent` — and passing only the reason there still stopped the top-level turn (the reason
|
|
2404
|
-
* travels) while arriving with no vendor and no kind. The turn looked handled and the diagnostics were
|
|
2405
|
-
* gone, which is the failure mode this payload exists to prevent.
|
|
2406
|
-
*
|
|
2407
|
-
* Three tiers: boss -> middle -> worker. Only the worker's turn is refused; every other model call
|
|
2408
|
-
* succeeds, so nothing but the forwarding can carry the detail to the top.
|
|
2409
|
-
*/
|
|
2410
|
-
clearMetaEventRegistry();
|
|
2411
|
-
const worker = agent({
|
|
2412
|
-
name: 'worker',
|
|
2413
|
-
toolDefinitions: [def('finish')],
|
|
2414
|
-
toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
|
|
2415
|
-
});
|
|
2416
|
-
const middle = agent({
|
|
2417
|
-
name: 'middle',
|
|
2418
|
-
subAgents: [worker],
|
|
2419
|
-
toolDefinitions: [def('sub_delegate')],
|
|
2420
|
-
toolHandlers: {
|
|
2421
|
-
sub_delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2422
|
-
const o = yield ctx.requestSubAgent('worker', { task: 'deeper' });
|
|
2423
|
-
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
2424
|
-
}),
|
|
2425
|
-
},
|
|
2426
|
-
});
|
|
2427
|
-
const boss = agent({
|
|
2428
|
-
name: 'boss',
|
|
2429
|
-
subAgents: [middle],
|
|
2430
|
-
toolDefinitions: [def('delegate')],
|
|
2431
|
-
toolHandlers: {
|
|
2432
|
-
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2433
|
-
const o = yield ctx.requestSubAgent('middle', { task: 'do it' });
|
|
2434
|
-
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
2435
|
-
}),
|
|
2436
|
-
},
|
|
2437
|
-
});
|
|
2438
|
-
// Routed by the tool surface each tier is given, so only the deepest turn throws.
|
|
2439
|
-
const provider = {
|
|
2440
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2441
|
-
var _a;
|
|
2442
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2443
|
-
if (names.includes('delegate'))
|
|
2444
|
-
return callsTool('delegate', 'd1');
|
|
2445
|
-
if (names.includes('sub_delegate'))
|
|
2446
|
-
return callsTool('sub_delegate', 's1');
|
|
2447
|
-
throw new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.');
|
|
2448
|
-
}),
|
|
2449
|
-
};
|
|
2450
|
-
const driver = makeDriver(boss, provider, 'outcome-refused-grandchild', outcomeBus);
|
|
2451
|
-
const result = yield driver.sendMessage('go');
|
|
2452
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2453
|
-
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
2454
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'auth', "the grandchild's kind survives BOTH hops");
|
|
2455
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
|
|
2456
|
-
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 401);
|
|
2457
|
-
assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2458
|
-
}));
|
|
2459
|
-
outcome('a sub-agent budget wall ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2460
|
-
clearMetaEventRegistry();
|
|
2461
|
-
const { parentCalls, provider } = parentOkChildWalled();
|
|
2462
|
-
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2463
|
-
const driver = makeDriver(parent, provider, 'outcome-subagent-budget', outcomeBus);
|
|
2464
|
-
const result = yield driver.sendMessage('go');
|
|
2465
|
-
assert.is(parentCalls(), 1, 'the parent never calls the model again after the wall');
|
|
2466
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2467
|
-
assert.is(driver.getHistory().at(-1).content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2468
|
-
}));
|
|
2469
|
-
outcome("a sub-agent wall with headroom elsewhere composes the parent's switch advice", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2470
|
-
// The parent's bubble is composed from the latched `budgetWallDetail` — on the
|
|
2471
|
-
// sub-agent path there is NO error object in scope, only the failure payload
|
|
2472
|
-
// the child attributed (this pins the exact seam a scope bug once lived on).
|
|
2473
|
-
clearMetaEventRegistry();
|
|
2474
|
-
let parentCalls = 0;
|
|
2475
|
-
const provider = {
|
|
2476
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2477
|
-
var _a;
|
|
2478
|
-
if (((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).some((t) => t.name === 'delegate')) {
|
|
2479
|
-
parentCalls += 1;
|
|
2480
|
-
return callsTool('delegate', `d${parentCalls}`);
|
|
2481
|
-
}
|
|
2482
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25.4, undefined, {
|
|
2483
|
-
otherVendorAvailable: true,
|
|
2484
|
-
});
|
|
2485
|
-
}),
|
|
2486
|
-
};
|
|
2487
|
-
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2488
|
-
const driver = makeDriver(parent, provider, 'outcome-subagent-budget-switch', outcomeBus);
|
|
2489
|
-
const result = yield driver.sendMessage('go');
|
|
2490
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2491
|
-
assert.is(driver.getHistory().at(-1).content, "You've reached Anthropic's AI usage limit. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.");
|
|
2492
|
-
}));
|
|
2493
|
-
outcome("a sub-agent's wall is attributed to the CHILD's vendor, not the parent's", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2494
|
-
var _a;
|
|
2495
|
-
// A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
|
|
2496
|
-
// `config.provider`). The wall reaches the parent only as a tool result, so
|
|
2497
|
-
// while the parent carried nothing but a boolean it reported
|
|
2498
|
-
// `lastResolvedProvider` — its own vendor, i.e. precisely the one that did NOT
|
|
2499
|
-
// refuse. Under a two-vendor registry that walls Gemini because an Anthropic
|
|
2500
|
-
// child 402'd, and the host then blocks a composer that still had headroom.
|
|
2501
|
-
clearMetaEventRegistry();
|
|
2502
|
-
const { provider } = parentOkChildWalled();
|
|
2503
|
-
// Seeded disagreement: the registry resolves Gemini, so the parent's
|
|
2504
|
-
// `lastResolvedProvider` is `gemini` while the child is refused by Anthropic.
|
|
2505
|
-
const registry = {
|
|
2506
|
-
get: () => provider,
|
|
2507
|
-
default: () => provider,
|
|
2508
|
-
defaultName: () => 'test',
|
|
2509
|
-
names: () => ['test'],
|
|
2510
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2511
|
-
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2512
|
-
};
|
|
2513
|
-
const driver = new ChatDriver(registry, {
|
|
2514
|
-
sessionKey: 'outcome-subagent-budget-vendor',
|
|
2515
|
-
activityBus: outcomeBus,
|
|
2516
|
-
});
|
|
2517
|
-
driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
|
|
2518
|
-
const result = yield driver.sendMessage('go');
|
|
2519
|
-
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2520
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', "the child's refusing vendor survives the hop");
|
|
2521
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.budgetUsd, 25, "and so do the child's figures");
|
|
2522
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.spentUsd, 25.4);
|
|
2523
|
-
const err = getMetaEvents('outcome-subagent-budget-vendor').find((e) => e.type === 'turn.error');
|
|
2524
|
-
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic', 'and the debug log agrees');
|
|
2525
|
-
}));
|
|
2526
|
-
outcome('N batched sub-agent calls still produce at most one doomed parent call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2527
|
-
clearMetaEventRegistry();
|
|
2528
|
-
let parentCalls = 0;
|
|
2529
|
-
const provider = {
|
|
2530
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2531
|
-
var _a;
|
|
2532
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2533
|
-
if (names.includes('delegate')) {
|
|
2534
|
-
parentCalls += 1;
|
|
2535
|
-
return {
|
|
2536
|
-
role: 'assistant',
|
|
2537
|
-
content: '',
|
|
2538
|
-
toolCalls: [
|
|
2539
|
-
{ id: 'd1', name: 'delegate', args: {} },
|
|
2540
|
-
{ id: 'd2', name: 'delegate', args: {} },
|
|
2541
|
-
],
|
|
2542
|
-
};
|
|
2543
|
-
}
|
|
2544
|
-
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2545
|
-
}),
|
|
2546
|
-
};
|
|
2547
|
-
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2548
|
-
const driver = makeDriver(parent, provider, 'outcome-subagent-budget-batched', outcomeBus);
|
|
2549
|
-
const result = yield driver.sendMessage('go');
|
|
2550
|
-
assert.is(parentCalls, 1);
|
|
2551
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2552
|
-
}));
|
|
2553
|
-
outcome('a non-terminal sub-agent failure still lets the parent continue', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2554
|
-
// The short-circuit is scoped strictly to the budget reason — every other
|
|
2555
|
-
// SubAgentFailureReason is something the parent can legitimately recover from.
|
|
2556
|
-
clearMetaEventRegistry();
|
|
2557
|
-
let parentCalls = 0;
|
|
2558
|
-
const provider = {
|
|
2559
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2560
|
-
var _a;
|
|
2561
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2562
|
-
if (names.includes('delegate')) {
|
|
2563
|
-
parentCalls += 1;
|
|
2564
|
-
return parentCalls === 1
|
|
2565
|
-
? callsTool('delegate', 'd1')
|
|
2566
|
-
: { role: 'assistant', content: 'carried on regardless' };
|
|
2567
|
-
}
|
|
2568
|
-
// The child answers in free text without calling its completion tool →
|
|
2569
|
-
// `max_iterations`, a recoverable failure.
|
|
2570
|
-
return { role: 'assistant', content: 'no structured result' };
|
|
2571
|
-
}),
|
|
2572
|
-
};
|
|
2573
|
-
let captured;
|
|
2574
|
-
const parent = delegatingParent(walledWorker(), (o) => {
|
|
2575
|
-
captured = o;
|
|
2576
|
-
});
|
|
2577
|
-
const driver = makeDriver(parent, provider, 'outcome-subagent-nonterminal', outcomeBus);
|
|
2578
|
-
const result = yield driver.sendMessage('go');
|
|
2579
|
-
assert.equal(captured, { ok: false, reason: 'max_iterations' });
|
|
2580
|
-
assert.is(parentCalls, 2, 'the parent loop kept running');
|
|
2581
|
-
assert.is(result.reason === 'done' ? result.failureReason : undefined, undefined);
|
|
2582
|
-
}));
|
|
2583
|
-
outcome("a later unattributable child wall does not erase the first child's attribution", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2584
|
-
// Batched delegations: child A is refused by Anthropic WITH attribution,
|
|
2585
|
-
// child B by a transport whose label no vendor claims (figure-less 402 → no
|
|
2586
|
-
// BudgetDetail at all). A plain assignment let B's `undefined` clobber A's
|
|
2587
|
-
// attribution, and the fallback then reported the PARENT's vendor — under a
|
|
2588
|
-
// Gemini-resolving registry, precisely the vendor that did not refuse.
|
|
2589
|
-
clearMetaEventRegistry();
|
|
2590
|
-
let childCalls = 0;
|
|
2591
|
-
const provider = {
|
|
2592
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2593
|
-
var _a;
|
|
2594
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2595
|
-
if (names.includes('delegate')) {
|
|
2596
|
-
return {
|
|
2597
|
-
role: 'assistant',
|
|
2598
|
-
content: '',
|
|
2599
|
-
toolCalls: [
|
|
2600
|
-
{ id: 'd1', name: 'delegate', args: {} },
|
|
2601
|
-
{ id: 'd2', name: 'delegate', args: {} },
|
|
2602
|
-
],
|
|
2603
|
-
};
|
|
2604
|
-
}
|
|
2605
|
-
childCalls += 1;
|
|
2606
|
-
throw childCalls === 1
|
|
2607
|
-
? new BudgetExhaustedError('Anthropic', 25, 25.4)
|
|
2608
|
-
: new BudgetExhaustedError('Acme AI'); // unclaimed label, no figures
|
|
2609
|
-
}),
|
|
2610
|
-
};
|
|
2611
|
-
const registry = {
|
|
2612
|
-
get: () => provider,
|
|
2613
|
-
default: () => provider,
|
|
2614
|
-
defaultName: () => 'test',
|
|
2615
|
-
names: () => ['test'],
|
|
2616
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2617
|
-
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2618
|
-
};
|
|
2619
|
-
const driver = new ChatDriver(registry, {
|
|
2620
|
-
sessionKey: 'outcome-subagent-budget-clobber',
|
|
2621
|
-
activityBus: outcomeBus,
|
|
2622
|
-
});
|
|
2623
|
-
driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
|
|
2624
|
-
const result = yield driver.sendMessage('one');
|
|
2625
|
-
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2626
|
-
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', "child A's attribution survives child B's blank wall");
|
|
2627
|
-
}));
|
|
2628
|
-
outcome('a fully unattributable child wall degrades vendor-agnostic, never to the parent', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2629
|
-
var _a;
|
|
2630
|
-
// When NO child wall carries attribution, the honest event is vendor-less.
|
|
2631
|
-
// Falling back to `lastResolvedProvider` named the parent's vendor — walling
|
|
2632
|
-
// BOTH vendors (the child's via its own tool-loop-end, the parent's via this
|
|
2633
|
-
// one) and deriving `blocked` over headroom that still existed.
|
|
2634
|
-
clearMetaEventRegistry();
|
|
2635
|
-
const events = [];
|
|
2636
|
-
const bus = new AgenticActivityBus();
|
|
2637
|
-
bus.subscribe('tool-loop-end', (d) => events.push(d));
|
|
2638
|
-
// The provider must carry its own `getStatus` — `lastResolvedProvider` is
|
|
2639
|
-
// stamped from the PROVIDER's status, not the registry's, and a status-less
|
|
2640
|
-
// mock leaves it unset, which lets the buggy fallback pass vacuously (there
|
|
2641
|
-
// is no wrong vendor to fall back to).
|
|
2642
|
-
const provider = {
|
|
2643
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2644
|
-
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2645
|
-
var _a;
|
|
2646
|
-
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2647
|
-
if (names.includes('delegate'))
|
|
2648
|
-
return callsTool('delegate', 'd1');
|
|
2649
|
-
// The CHILD's turn: refused by a transport whose label no vendor claims.
|
|
2650
|
-
throw new BudgetExhaustedError('Acme AI'); // no figures either
|
|
2651
|
-
}),
|
|
2652
|
-
};
|
|
2653
|
-
const registry = {
|
|
2654
|
-
get: () => provider,
|
|
2655
|
-
default: () => provider,
|
|
2656
|
-
defaultName: () => 'test',
|
|
2657
|
-
names: () => ['test'],
|
|
2658
|
-
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2659
|
-
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2660
|
-
};
|
|
2661
|
-
const driver = new ChatDriver(registry, {
|
|
2662
|
-
sessionKey: 'outcome-subagent-budget-agnostic',
|
|
2663
|
-
activityBus: bus,
|
|
2664
|
-
});
|
|
2665
|
-
driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
|
|
2666
|
-
yield driver.sendMessage('go');
|
|
2667
|
-
// The PARENT's loop-end is the last one on the bus. It must carry the
|
|
2668
|
-
// failure without naming the parent's (gemini) vendor.
|
|
2669
|
-
const parentEnd = events.at(-1);
|
|
2670
|
-
assert.is(parentEnd === null || parentEnd === void 0 ? void 0 : parentEnd.failureReason, 'budget-exhausted');
|
|
2671
|
-
assert.is(parentEnd === null || parentEnd === void 0 ? void 0 : parentEnd.vendor, undefined, 'no vendor is better than the wrong vendor');
|
|
2672
|
-
// The CHILD's folded-in turn.error legitimately carries its own best-effort
|
|
2673
|
-
// self-attribution (its resolved provider really did make the refused call);
|
|
2674
|
-
// the record this fix governs is the PARENT's short-circuit, tagged
|
|
2675
|
-
// `via: 'sub-agent'`.
|
|
2676
|
-
const err = getMetaEvents('outcome-subagent-budget-agnostic').find((e) => { var _a; return e.type === 'turn.error' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.via) === 'sub-agent'; });
|
|
2677
|
-
assert.ok(err, "the parent's short-circuit recorded the wall");
|
|
2678
|
-
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, undefined, 'and named no vendor rather than the wrong one');
|
|
2679
|
-
}));
|
|
2680
|
-
outcome.run();
|
|
2681
|
-
// ---------------------------------------------------------------------------
|
|
2682
|
-
// recordExternalCost — spend a widget incurs AFTER it has resolved.
|
|
2683
|
-
//
|
|
2684
|
-
// Some widgets resolve EARLY on purpose, so the assistant stops waiting while the
|
|
2685
|
-
// user deliberates, and then keep doing paid work for them. `resolveInteraction`
|
|
2686
|
-
// cannot serve that: its whole body is inside `if (interaction)` and it deletes the
|
|
2687
|
-
// entry, so a second resolution is a silent no-op and the spend never reached the
|
|
2688
|
-
// figure shown to the user.
|
|
2689
|
-
// ---------------------------------------------------------------------------
|
|
2690
|
-
const postResolveCost = createLogicSuite('ChatDriver post-resolve external cost');
|
|
2691
|
-
postResolveCost('adds spend to an interaction that has already resolved', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2692
|
-
var _a;
|
|
2693
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
2694
|
-
const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
|
|
2695
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
2696
|
-
driver.resolveInteraction(id, { status: 'approved', costUsd: 0.5 });
|
|
2697
|
-
yield pending;
|
|
2698
|
-
// The interaction is gone from `pendingInteractions` — this is exactly the state in
|
|
2699
|
-
// which the old path silently did nothing.
|
|
2700
|
-
assert.is(driver.recordExternalCost(id, 0.25), true);
|
|
2701
|
-
assert.is(driver.recordExternalCost(id, 0.25), true);
|
|
2702
|
-
const msg = driver.getHistory().find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
2703
|
-
// ADDITIVE: the resolution's 0.5 plus two 0.25 deltas.
|
|
2704
|
-
assert.is(msg === null || msg === void 0 ? void 0 : msg.externalCostUsd, 1);
|
|
2705
|
-
// …and the recorded resolution is untouched — this is not a second resolution.
|
|
2706
|
-
assert.equal((_a = msg === null || msg === void 0 ? void 0 : msg.interaction) === null || _a === void 0 ? void 0 : _a.resolved, { status: 'approved', costUsd: 0.5 });
|
|
2707
|
-
}));
|
|
2708
|
-
postResolveCost('works on an interaction that resolved with no cost at all', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2709
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
2710
|
-
const pending = driver.requestInteraction('planning-question', { question: 'q' });
|
|
2711
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
2712
|
-
driver.resolveInteraction(id, { status: 'approved' });
|
|
2713
|
-
yield pending;
|
|
2714
|
-
assert.is(driver.recordExternalCost(id, 0.02), true);
|
|
2715
|
-
const msg = driver.getHistory().find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
2716
|
-
assert.is(msg === null || msg === void 0 ? void 0 : msg.externalCostUsd, 0.02);
|
|
2717
|
-
}));
|
|
2718
|
-
postResolveCost('refuses values that are not real spend, and an unknown interaction', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2719
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
2720
|
-
const pending = driver.requestInteraction('planning-question', { question: 'q' });
|
|
2721
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
2722
|
-
driver.resolveInteraction(id, { status: 'approved' });
|
|
2723
|
-
yield pending;
|
|
2724
|
-
// Includes the shapes only an untyped JS caller can produce — which is exactly who the
|
|
2725
|
-
// `typeof` guard is written for, and what the previous `as number` cast asserted nothing about.
|
|
2726
|
-
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '0.5', null, undefined]) {
|
|
2727
|
-
assert.is(driver.recordExternalCost(id, bad), false);
|
|
2728
|
-
}
|
|
2729
|
-
// …and each refusal leaves a trace: the wrapper no longer pre-validates (it made this
|
|
2730
|
-
// unreachable through the event path, a widget's only way in), so this is the ONE validator
|
|
2731
|
-
// and it has to record what it dropped.
|
|
2732
|
-
const dropped = getMetaEvents('').filter((m) => m.type === 'interaction.external-cost-dropped');
|
|
2733
|
-
assert.ok(dropped.length >= 1, 'a refused amount must record why');
|
|
2734
|
-
// An empty id can never match a message, so it is refused before the search.
|
|
2735
|
-
assert.is(driver.recordExternalCost('', 0.1), false);
|
|
2736
|
-
// A message trimmed or condensed out of history is a real outcome, not a throw.
|
|
2737
|
-
assert.is(driver.recordExternalCost('no-such-interaction', 0.1), false);
|
|
2738
|
-
const msg = driver.getHistory().find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
|
|
2739
|
-
assert.is(msg === null || msg === void 0 ? void 0 : msg.externalCostUsd, undefined);
|
|
2740
|
-
}));
|
|
2741
|
-
postResolveCost('announces the change NARROWLY — never on history-updated, which rebuilds the widget', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2742
|
-
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
2743
|
-
const pending = driver.requestInteraction('planning-question', { question: 'q' });
|
|
2744
|
-
const id = driver.getHistory().at(-1).interaction.interactionId;
|
|
2745
|
-
driver.resolveInteraction(id, { status: 'approved' });
|
|
2746
|
-
yield pending;
|
|
2747
|
-
let narrow = 0;
|
|
2748
|
-
let costUsd;
|
|
2749
|
-
let interactionId;
|
|
2750
|
-
let historyUpdated = 0;
|
|
2751
|
-
driver.addEventListener('external-cost-recorded', (e) => {
|
|
2752
|
-
narrow += 1;
|
|
2753
|
-
({ costUsd, interactionId } = e.detail);
|
|
2754
|
-
});
|
|
2755
|
-
driver.addEventListener('history-updated', () => {
|
|
2756
|
-
historyUpdated += 1;
|
|
2757
|
-
});
|
|
2758
|
-
driver.recordExternalCost(id, 0.07);
|
|
2759
|
-
// Announced, or the total is right in history and stale on screen.
|
|
2760
|
-
assert.is(narrow, 1);
|
|
2761
|
-
assert.is(costUsd, 0.07);
|
|
2762
|
-
assert.is(interactionId, id);
|
|
2763
|
-
// REGRESSION (reviewer catch on #2474): `history-updated` makes the host reassign `messages`,
|
|
2764
|
-
// which rebuilds the trailing interaction row — a `recycle: false` repeat that disposes its
|
|
2765
|
-
// views outright. For a RESOLVED interaction there is no `interactionContext` to remount
|
|
2766
|
-
// from, so announcing this way tore down and blanked the very widget that had just reported
|
|
2767
|
-
// its spend. Bookkeeping about a finished turn must not touch what is rendered.
|
|
2768
|
-
assert.is(historyUpdated, 0);
|
|
2769
|
-
}));
|
|
2770
|
-
postResolveCost.run();
|