@genesislcap/ai-assistant 15.6.2 → 15.7.0-ts7.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.
Files changed (100) hide show
  1. package/dist/ai-assistant.api.json +392 -6
  2. package/dist/ai-assistant.d.ts +616 -24
  3. package/dist/chat-driver.cjs +315 -46
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +315 -46
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +254 -10
  8. package/dist/dts/channel/ai-activity-bus.d.ts.map +1 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  10. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  11. package/dist/dts/components/activity-halo/activity-halo.d.ts.map +1 -1
  12. package/dist/dts/components/agent-picker/agent-picker.d.ts.map +1 -1
  13. package/dist/dts/components/agent-picker/agent-picker.template.d.ts.map +1 -1
  14. package/dist/dts/components/chat-bubble/chat-bubble.d.ts.map +1 -1
  15. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  16. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  17. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  18. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
  19. package/dist/dts/components/chat-markdown/chat-markdown.d.ts.map +1 -1
  20. package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -1
  21. package/dist/dts/components/halo-overlay.d.ts.map +1 -1
  22. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  23. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  24. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  25. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  26. package/dist/dts/components/popout-manager/popout-manager.d.ts.map +1 -1
  27. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  28. package/dist/dts/components/waves-indicator.d.ts.map +1 -1
  29. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  30. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  31. package/dist/dts/main/main.d.ts +394 -6
  32. package/dist/dts/main/main.d.ts.map +1 -1
  33. package/dist/dts/main/main.styles.d.ts.map +1 -1
  34. package/dist/dts/main/main.styles.test.d.ts +2 -0
  35. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  36. package/dist/dts/main/main.template.d.ts +53 -0
  37. package/dist/dts/main/main.template.d.ts.map +1 -1
  38. package/dist/dts/main/main.types.d.ts +2 -2
  39. package/dist/dts/main/main.types.d.ts.map +1 -1
  40. package/dist/dts/state/ai-assistant-slice.d.ts +163 -7
  41. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  42. package/dist/dts/state/debug-event-log.d.ts +6 -1
  43. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  44. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  45. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  46. package/dist/dts/state/session-store.d.ts +12 -1
  47. package/dist/dts/state/session-store.d.ts.map +1 -1
  48. package/dist/dts/suggestions/chat-suggestions.d.ts.map +1 -1
  49. package/dist/dts/utils/animated-panel-toggle.d.ts.map +1 -1
  50. package/dist/dts/utils/message-partition.d.ts.map +1 -1
  51. package/dist/esm/components/agent-picker/agent-picker.js +2 -1
  52. package/dist/esm/components/agent-picker/agent-picker.template.js +2 -2
  53. package/dist/esm/components/chat-bubble/chat-bubble.js +2 -1
  54. package/dist/esm/components/chat-driver/align-event-globals.js +1 -0
  55. package/dist/esm/components/chat-driver/chat-driver.js +281 -38
  56. package/dist/esm/components/chat-driver/chat-driver.test.js +471 -14
  57. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  58. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +94 -9
  59. package/dist/esm/components/popout-manager/popout-manager.js +4 -1
  60. package/dist/esm/components/settings-modal/settings-modal.template.js +5 -8
  61. package/dist/esm/main/blocked-state.test.js +969 -0
  62. package/dist/esm/main/main.js +834 -104
  63. package/dist/esm/main/main.styles.js +47 -0
  64. package/dist/esm/main/main.styles.test.js +87 -0
  65. package/dist/esm/main/main.template.js +131 -20
  66. package/dist/esm/state/ai-assistant-slice.js +145 -7
  67. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  68. package/dist/esm/state/debug-event-log.js +9 -3
  69. package/dist/esm/state/debug-event-log.test.js +49 -1
  70. package/dist/esm/state/persistence/session-persister.js +13 -10
  71. package/dist/esm/state/persistence/session-snapshot.js +3 -2
  72. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  73. package/dist/esm/utils/collect-session-models.test.js +2 -1
  74. package/dist/esm/utils/condense-history.js +6 -4
  75. package/dist/esm/utils/condense-history.test.js +15 -21
  76. package/dist/esm/utils/flatten-sub-agent-messages.js +4 -3
  77. package/dist/esm/utils/history-transform.js +2 -1
  78. package/dist/esm/utils/resolve-cost-history-config.js +4 -3
  79. package/dist/esm/utils/resolve-preference-baseline.js +2 -1
  80. package/dist/esm/utils/sum-usage.js +7 -6
  81. package/dist/tsconfig.tsbuildinfo +1 -1
  82. package/docs/migration-GENC-1464.md +562 -0
  83. package/docs/sub_agent.md +20 -3
  84. package/package.json +17 -17
  85. package/src/channel/ai-activity-channel.ts +56 -2
  86. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  87. package/src/components/chat-driver/chat-driver.ts +324 -14
  88. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  89. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  90. package/src/main/blocked-state.test.ts +1316 -0
  91. package/src/main/main.styles.test.ts +103 -0
  92. package/src/main/main.styles.ts +47 -0
  93. package/src/main/main.template.ts +131 -4
  94. package/src/main/main.ts +704 -10
  95. package/src/state/ai-assistant-slice.test.ts +215 -0
  96. package/src/state/ai-assistant-slice.ts +218 -8
  97. package/src/state/debug-event-log.test.ts +63 -0
  98. package/src/state/debug-event-log.ts +7 -2
  99. package/src/state/persistence/session-snapshot.test.ts +22 -0
  100. package/tsconfig.json +1 -0
@@ -1,5 +1,5 @@
1
1
  import { __awaiter } from "tslib";
2
- import { isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
2
+ import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
3
3
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
4
4
  import { AgenticActivityBus } from '../../channel/ai-activity-bus';
5
5
  import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
@@ -580,12 +580,9 @@ subagent('resolves { ok: false, reason } and records telemetry when the sub-agen
580
580
  // The failure surfaces as a high-importance `subagent.failed` meta event,
581
581
  // recorded under the PARENT driver's session so it lands on the user-visible
582
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');
583
+ assert.ok(getMetaEvents(sessionKey).some((e) => { var _a, _b; return e.type === 'subagent.failed' &&
584
+ ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.agent) === 'worker' &&
585
+ ((_b = e.detail) === null || _b === void 0 ? void 0 : _b.reason) === 'unknown_tool_limit'; }), 'a subagent.failed meta event should be recorded under the parent session');
589
586
  assert.not.ok(getMetaEvents('').some((e) => e.type === 'subagent.failed'), 'the failure must not be orphaned in the child default session bucket');
590
587
  }));
591
588
  subagent("folds the sub-agent's high-value meta events into the parent session, breadcrumbed", () => __awaiter(void 0, void 0, void 0, function* () {
@@ -802,12 +799,9 @@ subagent('fails the sub-agent with reason "timeout" (no apology text) on a trans
802
799
  // The parent gets a typed timeout — not the wrong max_iterations default it
803
800
  // used to see when the child answered with the user-facing apology instead.
804
801
  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');
802
+ assert.ok(getMetaEvents(sessionKey).some((e) => { var _a, _b; return e.type === 'subagent.failed' &&
803
+ ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.agent) === 'worker' &&
804
+ ((_b = e.detail) === null || _b === void 0 ? void 0 : _b.reason) === 'timeout'; }), 'a subagent.failed meta event with reason "timeout" should be recorded');
811
805
  // The child must not speak to the user.
812
806
  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
807
  }));
@@ -1504,7 +1498,7 @@ modelAttr('attributes a tool-calling assistant message (and so its tool calls) t
1504
1498
  });
1505
1499
  const driver = makeDriver(config, provider);
1506
1500
  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; });
1501
+ const toolCallMsg = driver.getHistory().find((m) => { var _a; var _b; return ((_b = (_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0; });
1508
1502
  assert.ok(toolCallMsg, 'an assistant message with tool calls is present');
1509
1503
  assert.is(toolCallMsg.model, 'claude-haiku-4-5-20251001');
1510
1504
  assert.is(toolCallMsg.provider, 'gemini');
@@ -1854,6 +1848,12 @@ const truncatedProvider = () => ({
1854
1848
  throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
1855
1849
  }),
1856
1850
  });
1851
+ /** A provider refused for budget (deterministic, terminal — must not retry). */
1852
+ const budgetExhaustedProvider = () => ({
1853
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1854
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
1855
+ }),
1856
+ });
1857
1857
  /** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
1858
1858
  const throwingProvider = () => ({
1859
1859
  chat: () => __awaiter(void 0, void 0, void 0, function* () {
@@ -1943,4 +1943,461 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
1943
1943
  assert.not.ok(getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'), 'a clean turn records no turn.error');
1944
1944
  cap.stop();
1945
1945
  }));
1946
+ // ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
1947
+ outcome('budget-exhausted surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
1948
+ yield assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
1949
+ }));
1950
+ outcome('a budget wall is terminal — no transient retry, and actionable copy', () => __awaiter(void 0, void 0, void 0, function* () {
1951
+ var _a, _b, _c, _d;
1952
+ clearMetaEventRegistry();
1953
+ let calls = 0;
1954
+ const provider = {
1955
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
1956
+ calls += 1;
1957
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
1958
+ }),
1959
+ // Resolvable vendor, so the per-vendor fields below are exercised.
1960
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'anthropic', model: 'test-model' }); }),
1961
+ };
1962
+ const config = agent({
1963
+ name: 'Static',
1964
+ toolDefinitions: [def('noop')],
1965
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
1966
+ });
1967
+ const driver = makeDriver(config, provider, 'outcome-budget-once', outcomeBus);
1968
+ const cap = captureLoopEnd();
1969
+ const result = yield driver.sendMessage('go');
1970
+ // The whole point of the typed branch: it sits BEFORE the transient-retry
1971
+ // catch, so the driver must not burn MAX_SETUP_TRANSPORT_RETRIES attempts
1972
+ // against a wall that no retry can clear.
1973
+ assert.is(calls, 1, 'a budget wall is not retried — exactly one model call');
1974
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted', 'the turn ends with the budget-exhausted failure reason');
1975
+ // The figures now reach the CALLER, not just the debug log — this is what lets
1976
+ // the element render "$25.40 of $25" in its banner without catching the
1977
+ // transport error itself.
1978
+ const budget = result.reason === 'done' ? result.budget : undefined;
1979
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.budgetUsd, 25);
1980
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.spentUsd, 25.4);
1981
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.vendorLabel, 'Anthropic');
1982
+ // The bus detail carries the resolved vendor beside the reason.
1983
+ assert.is(cap.detail().failureReason, 'budget-exhausted');
1984
+ assert.is(cap.detail().vendor, 'anthropic');
1985
+ cap.stop();
1986
+ const last = driver.getHistory().at(-1);
1987
+ assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
1988
+ // Identity, not substrings: the transcript bubble and the element's blocked
1989
+ // banner now read from one shared const, and this is what pins them together.
1990
+ assert.is(last.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
1991
+ assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic apology');
1992
+ // The budget figures ride the debug-log entry so a support ticket can show them.
1993
+ const err = getMetaEvents('outcome-budget-once').find((e) => e.type === 'turn.error');
1994
+ assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.budgetUsd, 25);
1995
+ assert.is((_b = err.detail) === null || _b === void 0 ? void 0 : _b.spentUsd, 25.4);
1996
+ // The registry ALIAS and the VENDOR are different things; record both.
1997
+ assert.is((_c = err.detail) === null || _c === void 0 ? void 0 : _c.provider, 'test');
1998
+ assert.is((_d = err.detail) === null || _d === void 0 ? void 0 : _d.vendor, 'anthropic');
1999
+ }));
2000
+ outcome('a non-budget failure emits the historical detail shape, with no vendor', () => __awaiter(void 0, void 0, void 0, function* () {
2001
+ // Back-compat for subscribers that structurally compare the `tool-loop-end`
2002
+ // detail: only a budget failure grew a field.
2003
+ clearMetaEventRegistry();
2004
+ const config = agent({
2005
+ name: 'Static',
2006
+ toolDefinitions: [def('noop')],
2007
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
2008
+ });
2009
+ const driver = makeDriver(config, throwingProvider(), 'outcome-vendor-compat', outcomeBus);
2010
+ const cap = captureLoopEnd();
2011
+ yield driver.sendMessage('go');
2012
+ assert.equal(cap.detail(), { failureReason: 'exception' }, 'no vendor key was added');
2013
+ cap.stop();
2014
+ }));
2015
+ outcome('a 402 with no figures still carries the vendor that refused it', () => __awaiter(void 0, void 0, void 0, function* () {
2016
+ var _a;
2017
+ // The regression this pins: the driver used to drop the ENTIRE budget object
2018
+ // when both figures were absent, and `vendorLabel` — the authoritative
2019
+ // attribution source — went with it. The element then fell back to the
2020
+ // driver's last-resolved provider, i.e. the PREVIOUS turn's vendor, and walled
2021
+ // the wrong one. Three live shapes arrive here with no figures: a bare gateway
2022
+ // 402, a code-only body, and the framed err frame whose figures are `null`.
2023
+ clearMetaEventRegistry();
2024
+ const provider = {
2025
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
2026
+ throw new BudgetExhaustedError('Anthropic');
2027
+ }),
2028
+ };
2029
+ const config = agent({
2030
+ name: 'Static',
2031
+ toolDefinitions: [def('noop')],
2032
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
2033
+ });
2034
+ const driver = makeDriver(config, provider, 'outcome-budget-nofigures', outcomeBus);
2035
+ const cap = captureLoopEnd();
2036
+ const result = yield driver.sendMessage('go');
2037
+ const budget = result.reason === 'done' ? result.budget : undefined;
2038
+ assert.ok(budget, 'a figure-less wall still reports who refused it');
2039
+ assert.is(budget.vendorLabel, 'Anthropic');
2040
+ assert.is(budget.vendor, 'anthropic');
2041
+ // Still no figures — the element reads their ABSENCE as "leave any host-set
2042
+ // banner copy alone", so they must not be invented.
2043
+ assert.is(budget.budgetUsd, undefined);
2044
+ assert.is(budget.spentUsd, undefined);
2045
+ // Both seams, since the bus one lands first and wins the latch.
2046
+ assert.is((_a = cap.detail().budget) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic');
2047
+ cap.stop();
2048
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2049
+ }));
2050
+ outcome('a wall with nothing to report at all still omits the budget key', () => __awaiter(void 0, void 0, void 0, function* () {
2051
+ // The other direction: `budget` is present when there is something to say and
2052
+ // absent when there is not, so presence keeps meaning something. No figures AND
2053
+ // an unclaimed label leaves nothing to carry — and the element's fail-safe
2054
+ // (block everything) is the correct outcome for an unattributable wall.
2055
+ clearMetaEventRegistry();
2056
+ const provider = {
2057
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
2058
+ throw new BudgetExhaustedError('Acme AI');
2059
+ }),
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-nothing', outcomeBus);
2067
+ const result = yield driver.sendMessage('go');
2068
+ assert.not.ok('budget' in result, 'nothing reportable → no budget key');
2069
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2070
+ }));
2071
+ outcome('a figure-less wall attributes to the refusing transport, not the last turn', () => __awaiter(void 0, void 0, void 0, function* () {
2072
+ var _a;
2073
+ // The exact failure: turn 1 runs on Gemini, turn 2's request is refused by
2074
+ // Anthropic with a figure-less 402. `lastResolvedProvider` still says `gemini`,
2075
+ // so the pre-fix driver reported Gemini and the user was told to switch to the
2076
+ // vendor that had actually run out.
2077
+ clearMetaEventRegistry();
2078
+ let walled = false;
2079
+ const provider = {
2080
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
2081
+ if (!walled) {
2082
+ walled = true;
2083
+ return { role: 'assistant', content: 'first turn, on Gemini' };
2084
+ }
2085
+ throw new BudgetExhaustedError('Anthropic');
2086
+ }),
2087
+ };
2088
+ // The registry resolves to Gemini, so `lastResolvedProvider` is `gemini` by the
2089
+ // time the Anthropic wall lands — the disagreement has to be seeded to prove
2090
+ // which source is believed.
2091
+ const registry = {
2092
+ get: () => provider,
2093
+ default: () => provider,
2094
+ defaultName: () => 'test',
2095
+ names: () => ['test'],
2096
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
2097
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
2098
+ };
2099
+ const driver = new ChatDriver(registry, {
2100
+ sessionKey: 'outcome-budget-stale-vendor',
2101
+ activityBus: outcomeBus,
2102
+ });
2103
+ driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
2104
+ yield driver.sendMessage('one');
2105
+ const result = yield driver.sendMessage('two');
2106
+ const budget = result.reason === 'done' ? result.budget : undefined;
2107
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', 'the transport that said no, not the one before it');
2108
+ const err = getMetaEvents('outcome-budget-stale-vendor').find((e) => e.type === 'turn.error');
2109
+ assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic', 'and the debug log agrees');
2110
+ }));
2111
+ outcome("the proxy's own `vendor` rescues an unclaimed transport label", () => __awaiter(void 0, void 0, void 0, function* () {
2112
+ // A multiplexing or white-labelled gateway fronts several upstreams behind one
2113
+ // transport, so its static label names none of them. The 402's `vendor` is then
2114
+ // the only thing that knows which pot was actually booked against — second
2115
+ // opinion, never first: the transport's own label still wins where it resolves.
2116
+ clearMetaEventRegistry();
2117
+ const provider = {
2118
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
2119
+ throw new BudgetExhaustedError('Acme AI', undefined, undefined, undefined, {
2120
+ serverVendor: 'Gemini',
2121
+ });
2122
+ }),
2123
+ };
2124
+ const config = agent({
2125
+ name: 'Static',
2126
+ toolDefinitions: [def('noop')],
2127
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
2128
+ });
2129
+ const driver = makeDriver(config, provider, 'outcome-budget-server-vendor', outcomeBus);
2130
+ const result = yield driver.sendMessage('go');
2131
+ const budget = result.reason === 'done' ? result.budget : undefined;
2132
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.vendorLabel, 'Acme AI', 'the transport label is reported verbatim');
2133
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'gemini', 'and attribution still lands');
2134
+ }));
2135
+ outcome("the proxy's `otherVendorAvailable` verdict reaches both seams", () => __awaiter(void 0, void 0, void 0, function* () {
2136
+ var _a, _b;
2137
+ // The one fact the element cannot re-derive: registry membership says which
2138
+ // vendors EXIST, never which still have budget. It has to survive the trip.
2139
+ clearMetaEventRegistry();
2140
+ const provider = {
2141
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
2142
+ throw new BudgetExhaustedError('Anthropic', 25, 25, undefined, {
2143
+ otherVendorAvailable: false,
2144
+ });
2145
+ }),
2146
+ };
2147
+ const config = agent({
2148
+ name: 'Static',
2149
+ toolDefinitions: [def('noop')],
2150
+ toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
2151
+ });
2152
+ const driver = makeDriver(config, provider, 'outcome-budget-other-vendor', outcomeBus);
2153
+ const cap = captureLoopEnd();
2154
+ const result = yield driver.sendMessage('go');
2155
+ assert.is(result.reason === 'done' ? (_a = result.budget) === null || _a === void 0 ? void 0 : _a.otherVendorAvailable : undefined, false, 'the return-value seam');
2156
+ 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');
2157
+ cap.stop();
2158
+ }));
2159
+ outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', () => __awaiter(void 0, void 0, void 0, function* () {
2160
+ clearMetaEventRegistry();
2161
+ const provider = {
2162
+ chat: () => __awaiter(void 0, void 0, void 0, function* () {
2163
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2164
+ }),
2165
+ };
2166
+ const driver = new ChatDriver(makeRegistry(provider), {
2167
+ sessionKey: 'outcome-budget-custom',
2168
+ activityBus: outcomeBus,
2169
+ budgetExhaustedMessage: 'Contact ops@acme.com',
2170
+ });
2171
+ driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
2172
+ yield driver.sendMessage('go');
2173
+ // The banner was already overridable; without this seam a white-labelled host
2174
+ // got its own copy in the banner and 'contact Genesis' directly below it.
2175
+ assert.is(driver.getHistory().at(-1).content, 'Contact ops@acme.com');
2176
+ }));
2177
+ // ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
2178
+ // `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
2179
+ // parent too". Nothing parent-side read it, so the parent's tool loop appended
2180
+ // the failed outcome as a tool result and called the model again — into the same
2181
+ // wall. With N batched delegations that was N doomed children AND a doomed parent
2182
+ // call, and the element's `failureReason === 'budget-exhausted'` latch never fired.
2183
+ /** A sub-agent whose own turn is refused for budget; the parent's turns are not. */
2184
+ const parentOkChildWalled = () => {
2185
+ let parentCalls = 0;
2186
+ return {
2187
+ parentCalls: () => parentCalls,
2188
+ provider: {
2189
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2190
+ var _a;
2191
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2192
+ if (names.includes('delegate')) {
2193
+ parentCalls += 1;
2194
+ return callsTool('delegate', `d${parentCalls}`);
2195
+ }
2196
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2197
+ }),
2198
+ },
2199
+ };
2200
+ };
2201
+ const walledWorker = () => agent({
2202
+ name: 'worker',
2203
+ toolDefinitions: [def('finish')],
2204
+ toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
2205
+ });
2206
+ outcome('a sub-agent budget wall ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
2207
+ clearMetaEventRegistry();
2208
+ const { parentCalls, provider } = parentOkChildWalled();
2209
+ const parent = delegatingParent(walledWorker(), () => undefined);
2210
+ const driver = makeDriver(parent, provider, 'outcome-subagent-budget', outcomeBus);
2211
+ const result = yield driver.sendMessage('go');
2212
+ assert.is(parentCalls(), 1, 'the parent never calls the model again after the wall');
2213
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2214
+ assert.is(driver.getHistory().at(-1).content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
2215
+ }));
2216
+ 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* () {
2217
+ var _a;
2218
+ // A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
2219
+ // `config.provider`). The wall reaches the parent only as a tool result, so
2220
+ // while the parent carried nothing but a boolean it reported
2221
+ // `lastResolvedProvider` — its own vendor, i.e. precisely the one that did NOT
2222
+ // refuse. Under a two-vendor registry that walls Gemini because an Anthropic
2223
+ // child 402'd, and the host then blocks a composer that still had headroom.
2224
+ clearMetaEventRegistry();
2225
+ const { provider } = parentOkChildWalled();
2226
+ // Seeded disagreement: the registry resolves Gemini, so the parent's
2227
+ // `lastResolvedProvider` is `gemini` while the child is refused by Anthropic.
2228
+ const registry = {
2229
+ get: () => provider,
2230
+ default: () => provider,
2231
+ defaultName: () => 'test',
2232
+ names: () => ['test'],
2233
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
2234
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
2235
+ };
2236
+ const driver = new ChatDriver(registry, {
2237
+ sessionKey: 'outcome-subagent-budget-vendor',
2238
+ activityBus: outcomeBus,
2239
+ });
2240
+ driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
2241
+ const result = yield driver.sendMessage('go');
2242
+ const budget = result.reason === 'done' ? result.budget : undefined;
2243
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', "the child's refusing vendor survives the hop");
2244
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.budgetUsd, 25, "and so do the child's figures");
2245
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.spentUsd, 25.4);
2246
+ const err = getMetaEvents('outcome-subagent-budget-vendor').find((e) => e.type === 'turn.error');
2247
+ assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic', 'and the debug log agrees');
2248
+ }));
2249
+ outcome('N batched sub-agent calls still produce at most one doomed parent call', () => __awaiter(void 0, void 0, void 0, function* () {
2250
+ clearMetaEventRegistry();
2251
+ let parentCalls = 0;
2252
+ const provider = {
2253
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2254
+ var _a;
2255
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2256
+ if (names.includes('delegate')) {
2257
+ parentCalls += 1;
2258
+ return {
2259
+ role: 'assistant',
2260
+ content: '',
2261
+ toolCalls: [
2262
+ { id: 'd1', name: 'delegate', args: {} },
2263
+ { id: 'd2', name: 'delegate', args: {} },
2264
+ ],
2265
+ };
2266
+ }
2267
+ throw new BudgetExhaustedError('Anthropic', 25, 25.4);
2268
+ }),
2269
+ };
2270
+ const parent = delegatingParent(walledWorker(), () => undefined);
2271
+ const driver = makeDriver(parent, provider, 'outcome-subagent-budget-batched', outcomeBus);
2272
+ const result = yield driver.sendMessage('go');
2273
+ assert.is(parentCalls, 1);
2274
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
2275
+ }));
2276
+ outcome('a non-terminal sub-agent failure still lets the parent continue', () => __awaiter(void 0, void 0, void 0, function* () {
2277
+ // The short-circuit is scoped strictly to the budget reason — every other
2278
+ // SubAgentFailureReason is something the parent can legitimately recover from.
2279
+ clearMetaEventRegistry();
2280
+ let parentCalls = 0;
2281
+ const provider = {
2282
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2283
+ var _a;
2284
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2285
+ if (names.includes('delegate')) {
2286
+ parentCalls += 1;
2287
+ return parentCalls === 1
2288
+ ? callsTool('delegate', 'd1')
2289
+ : { role: 'assistant', content: 'carried on regardless' };
2290
+ }
2291
+ // The child answers in free text without calling its completion tool →
2292
+ // `max_iterations`, a recoverable failure.
2293
+ return { role: 'assistant', content: 'no structured result' };
2294
+ }),
2295
+ };
2296
+ let captured;
2297
+ const parent = delegatingParent(walledWorker(), (o) => {
2298
+ captured = o;
2299
+ });
2300
+ const driver = makeDriver(parent, provider, 'outcome-subagent-nonterminal', outcomeBus);
2301
+ const result = yield driver.sendMessage('go');
2302
+ assert.equal(captured, { ok: false, reason: 'max_iterations' });
2303
+ assert.is(parentCalls, 2, 'the parent loop kept running');
2304
+ assert.is(result.reason === 'done' ? result.failureReason : undefined, undefined);
2305
+ }));
2306
+ outcome("a later unattributable child wall does not erase the first child's attribution", () => __awaiter(void 0, void 0, void 0, function* () {
2307
+ // Batched delegations: child A is refused by Anthropic WITH attribution,
2308
+ // child B by a transport whose label no vendor claims (figure-less 402 → no
2309
+ // BudgetDetail at all). A plain assignment let B's `undefined` clobber A's
2310
+ // attribution, and the fallback then reported the PARENT's vendor — under a
2311
+ // Gemini-resolving registry, precisely the vendor that did not refuse.
2312
+ clearMetaEventRegistry();
2313
+ let childCalls = 0;
2314
+ const provider = {
2315
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2316
+ var _a;
2317
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2318
+ if (names.includes('delegate')) {
2319
+ return {
2320
+ role: 'assistant',
2321
+ content: '',
2322
+ toolCalls: [
2323
+ { id: 'd1', name: 'delegate', args: {} },
2324
+ { id: 'd2', name: 'delegate', args: {} },
2325
+ ],
2326
+ };
2327
+ }
2328
+ childCalls += 1;
2329
+ throw childCalls === 1
2330
+ ? new BudgetExhaustedError('Anthropic', 25, 25.4)
2331
+ : new BudgetExhaustedError('Acme AI'); // unclaimed label, no figures
2332
+ }),
2333
+ };
2334
+ const registry = {
2335
+ get: () => provider,
2336
+ default: () => provider,
2337
+ defaultName: () => 'test',
2338
+ names: () => ['test'],
2339
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
2340
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
2341
+ };
2342
+ const driver = new ChatDriver(registry, {
2343
+ sessionKey: 'outcome-subagent-budget-clobber',
2344
+ activityBus: outcomeBus,
2345
+ });
2346
+ driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
2347
+ const result = yield driver.sendMessage('one');
2348
+ const budget = result.reason === 'done' ? result.budget : undefined;
2349
+ assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', "child A's attribution survives child B's blank wall");
2350
+ }));
2351
+ outcome('a fully unattributable child wall degrades vendor-agnostic, never to the parent', () => __awaiter(void 0, void 0, void 0, function* () {
2352
+ var _a;
2353
+ // When NO child wall carries attribution, the honest event is vendor-less.
2354
+ // Falling back to `lastResolvedProvider` named the parent's vendor — walling
2355
+ // BOTH vendors (the child's via its own tool-loop-end, the parent's via this
2356
+ // one) and deriving `blocked` over headroom that still existed.
2357
+ clearMetaEventRegistry();
2358
+ const events = [];
2359
+ const bus = new AgenticActivityBus();
2360
+ bus.subscribe('tool-loop-end', (d) => events.push(d));
2361
+ // The provider must carry its own `getStatus` — `lastResolvedProvider` is
2362
+ // stamped from the PROVIDER's status, not the registry's, and a status-less
2363
+ // mock leaves it unset, which lets the buggy fallback pass vacuously (there
2364
+ // is no wrong vendor to fall back to).
2365
+ const provider = {
2366
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
2367
+ chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
2368
+ var _a;
2369
+ const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
2370
+ if (names.includes('delegate'))
2371
+ return callsTool('delegate', 'd1');
2372
+ // The CHILD's turn: refused by a transport whose label no vendor claims.
2373
+ throw new BudgetExhaustedError('Acme AI'); // no figures either
2374
+ }),
2375
+ };
2376
+ const registry = {
2377
+ get: () => provider,
2378
+ default: () => provider,
2379
+ defaultName: () => 'test',
2380
+ names: () => ['test'],
2381
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
2382
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
2383
+ };
2384
+ const driver = new ChatDriver(registry, {
2385
+ sessionKey: 'outcome-subagent-budget-agnostic',
2386
+ activityBus: bus,
2387
+ });
2388
+ driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
2389
+ yield driver.sendMessage('go');
2390
+ // The PARENT's loop-end is the last one on the bus. It must carry the
2391
+ // failure without naming the parent's (gemini) vendor.
2392
+ const parentEnd = events.at(-1);
2393
+ assert.is(parentEnd === null || parentEnd === void 0 ? void 0 : parentEnd.failureReason, 'budget-exhausted');
2394
+ assert.is(parentEnd === null || parentEnd === void 0 ? void 0 : parentEnd.vendor, undefined, 'no vendor is better than the wrong vendor');
2395
+ // The CHILD's folded-in turn.error legitimately carries its own best-effort
2396
+ // self-attribution (its resolved provider really did make the refused call);
2397
+ // the record this fix governs is the PARENT's short-circuit, tagged
2398
+ // `via: 'sub-agent'`.
2399
+ 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'; });
2400
+ assert.ok(err, "the parent's short-circuit recorded the wall");
2401
+ assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, undefined, 'and named no vendor rather than the wrong one');
2402
+ }));
1946
2403
  outcome.run();