@genesislcap/ai-assistant 15.6.2 → 15.7.0

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