@moxxy/sdk 0.1.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 (211) hide show
  1. package/dist/agent.d.ts +39 -0
  2. package/dist/agent.d.ts.map +1 -0
  3. package/dist/agent.js +2 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/cache-strategy.d.ts +35 -0
  6. package/dist/cache-strategy.d.ts.map +1 -0
  7. package/dist/cache-strategy.js +2 -0
  8. package/dist/cache-strategy.js.map +1 -0
  9. package/dist/channel.d.ts +146 -0
  10. package/dist/channel.d.ts.map +1 -0
  11. package/dist/channel.js +2 -0
  12. package/dist/channel.js.map +1 -0
  13. package/dist/client-session.d.ts +83 -0
  14. package/dist/client-session.d.ts.map +1 -0
  15. package/dist/client-session.js +2 -0
  16. package/dist/client-session.js.map +1 -0
  17. package/dist/command.d.ts +90 -0
  18. package/dist/command.d.ts.map +1 -0
  19. package/dist/command.js +15 -0
  20. package/dist/command.js.map +1 -0
  21. package/dist/compactor-helpers.d.ts +34 -0
  22. package/dist/compactor-helpers.d.ts.map +1 -0
  23. package/dist/compactor-helpers.js +156 -0
  24. package/dist/compactor-helpers.js.map +1 -0
  25. package/dist/compactor.d.ts +20 -0
  26. package/dist/compactor.d.ts.map +1 -0
  27. package/dist/compactor.js +2 -0
  28. package/dist/compactor.js.map +1 -0
  29. package/dist/define.d.ts +51 -0
  30. package/dist/define.d.ts.map +1 -0
  31. package/dist/define.js +71 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/elision-helpers.d.ts +27 -0
  34. package/dist/elision-helpers.d.ts.map +1 -0
  35. package/dist/elision-helpers.js +109 -0
  36. package/dist/elision-helpers.js.map +1 -0
  37. package/dist/elision-state.d.ts +49 -0
  38. package/dist/elision-state.d.ts.map +1 -0
  39. package/dist/elision-state.js +144 -0
  40. package/dist/elision-state.js.map +1 -0
  41. package/dist/embedding-cache.d.ts +32 -0
  42. package/dist/embedding-cache.d.ts.map +1 -0
  43. package/dist/embedding-cache.js +0 -0
  44. package/dist/embedding-cache.js.map +1 -0
  45. package/dist/embedding.d.ts +21 -0
  46. package/dist/embedding.d.ts.map +1 -0
  47. package/dist/embedding.js +2 -0
  48. package/dist/embedding.js.map +1 -0
  49. package/dist/errors.d.ts +81 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +241 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/events.d.ts +193 -0
  54. package/dist/events.d.ts.map +1 -0
  55. package/dist/events.js +2 -0
  56. package/dist/events.js.map +1 -0
  57. package/dist/hooks.d.ts +52 -0
  58. package/dist/hooks.d.ts.map +1 -0
  59. package/dist/hooks.js +2 -0
  60. package/dist/hooks.js.map +1 -0
  61. package/dist/ids.d.ts +18 -0
  62. package/dist/ids.d.ts.map +1 -0
  63. package/dist/ids.js +7 -0
  64. package/dist/ids.js.map +1 -0
  65. package/dist/index.d.ts +45 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +18 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/install-hints.d.ts +26 -0
  70. package/dist/install-hints.d.ts.map +1 -0
  71. package/dist/install-hints.js +15 -0
  72. package/dist/install-hints.js.map +1 -0
  73. package/dist/isolation.d.ts +125 -0
  74. package/dist/isolation.d.ts.map +1 -0
  75. package/dist/isolation.js +11 -0
  76. package/dist/isolation.js.map +1 -0
  77. package/dist/log.d.ts +11 -0
  78. package/dist/log.d.ts.map +1 -0
  79. package/dist/log.js +2 -0
  80. package/dist/log.js.map +1 -0
  81. package/dist/mode-helpers.d.ts +99 -0
  82. package/dist/mode-helpers.d.ts.map +1 -0
  83. package/dist/mode-helpers.js +368 -0
  84. package/dist/mode-helpers.js.map +1 -0
  85. package/dist/mode.d.ts +133 -0
  86. package/dist/mode.d.ts.map +1 -0
  87. package/dist/mode.js +2 -0
  88. package/dist/mode.js.map +1 -0
  89. package/dist/permission.d.ts +35 -0
  90. package/dist/permission.d.ts.map +1 -0
  91. package/dist/permission.js +2 -0
  92. package/dist/permission.js.map +1 -0
  93. package/dist/plugin.d.ts +88 -0
  94. package/dist/plugin.d.ts.map +1 -0
  95. package/dist/plugin.js +2 -0
  96. package/dist/plugin.js.map +1 -0
  97. package/dist/provider-utils.d.ts +40 -0
  98. package/dist/provider-utils.d.ts.map +1 -0
  99. package/dist/provider-utils.js +163 -0
  100. package/dist/provider-utils.js.map +1 -0
  101. package/dist/provider.d.ts +219 -0
  102. package/dist/provider.d.ts.map +1 -0
  103. package/dist/provider.js +2 -0
  104. package/dist/provider.js.map +1 -0
  105. package/dist/requirements.d.ts +22 -0
  106. package/dist/requirements.d.ts.map +1 -0
  107. package/dist/requirements.js +2 -0
  108. package/dist/requirements.js.map +1 -0
  109. package/dist/schemas.d.ts +228 -0
  110. package/dist/schemas.d.ts.map +1 -0
  111. package/dist/schemas.js +88 -0
  112. package/dist/schemas.js.map +1 -0
  113. package/dist/session-like.d.ts +111 -0
  114. package/dist/session-like.d.ts.map +1 -0
  115. package/dist/session-like.js +2 -0
  116. package/dist/session-like.js.map +1 -0
  117. package/dist/skill.d.ts +30 -0
  118. package/dist/skill.d.ts.map +1 -0
  119. package/dist/skill.js +2 -0
  120. package/dist/skill.js.map +1 -0
  121. package/dist/subagent.d.ts +45 -0
  122. package/dist/subagent.d.ts.map +1 -0
  123. package/dist/subagent.js +11 -0
  124. package/dist/subagent.js.map +1 -0
  125. package/dist/token-accounting.d.ts +73 -0
  126. package/dist/token-accounting.d.ts.map +1 -0
  127. package/dist/token-accounting.js +129 -0
  128. package/dist/token-accounting.js.map +1 -0
  129. package/dist/tool-dispatch.d.ts +19 -0
  130. package/dist/tool-dispatch.d.ts.map +1 -0
  131. package/dist/tool-dispatch.js +118 -0
  132. package/dist/tool-dispatch.js.map +1 -0
  133. package/dist/tool-gating.d.ts +32 -0
  134. package/dist/tool-gating.d.ts.map +1 -0
  135. package/dist/tool-gating.js +83 -0
  136. package/dist/tool-gating.js.map +1 -0
  137. package/dist/tool.d.ts +166 -0
  138. package/dist/tool.d.ts.map +1 -0
  139. package/dist/tool.js +2 -0
  140. package/dist/tool.js.map +1 -0
  141. package/dist/transcriber.d.ts +64 -0
  142. package/dist/transcriber.d.ts.map +1 -0
  143. package/dist/transcriber.js +21 -0
  144. package/dist/transcriber.js.map +1 -0
  145. package/dist/tunnel.d.ts +25 -0
  146. package/dist/tunnel.d.ts.map +1 -0
  147. package/dist/tunnel.js +2 -0
  148. package/dist/tunnel.js.map +1 -0
  149. package/dist/type-contracts.d.ts +2 -0
  150. package/dist/type-contracts.d.ts.map +1 -0
  151. package/dist/type-contracts.js +4 -0
  152. package/dist/type-contracts.js.map +1 -0
  153. package/dist/types.test-d.d.ts +2 -0
  154. package/dist/types.test-d.d.ts.map +1 -0
  155. package/dist/types.test-d.js +22 -0
  156. package/dist/types.test-d.js.map +1 -0
  157. package/dist/view-renderer.d.ts +97 -0
  158. package/dist/view-renderer.d.ts.map +1 -0
  159. package/dist/view-renderer.js +72 -0
  160. package/dist/view-renderer.js.map +1 -0
  161. package/package.json +38 -0
  162. package/src/agent.ts +38 -0
  163. package/src/cache-strategy.ts +39 -0
  164. package/src/channel.ts +158 -0
  165. package/src/client-session.ts +88 -0
  166. package/src/command.ts +86 -0
  167. package/src/compactor-helpers.test.ts +206 -0
  168. package/src/compactor-helpers.ts +163 -0
  169. package/src/compactor.ts +20 -0
  170. package/src/define.test.ts +76 -0
  171. package/src/define.ts +113 -0
  172. package/src/elision-helpers.ts +143 -0
  173. package/src/elision-state.ts +170 -0
  174. package/src/embedding-cache.ts +0 -0
  175. package/src/embedding.ts +22 -0
  176. package/src/errors.test.ts +176 -0
  177. package/src/errors.ts +308 -0
  178. package/src/events.ts +250 -0
  179. package/src/hooks.ts +54 -0
  180. package/src/ids.ts +16 -0
  181. package/src/index.ts +304 -0
  182. package/src/install-hints.ts +43 -0
  183. package/src/isolation.ts +148 -0
  184. package/src/log.ts +11 -0
  185. package/src/loop-helpers.test.ts +78 -0
  186. package/src/mode-helpers.ts +483 -0
  187. package/src/mode.ts +139 -0
  188. package/src/package-root.test.ts +40 -0
  189. package/src/permission.ts +37 -0
  190. package/src/plugin.ts +92 -0
  191. package/src/provider-utils.test.ts +84 -0
  192. package/src/provider-utils.ts +171 -0
  193. package/src/provider.ts +195 -0
  194. package/src/requirements.ts +35 -0
  195. package/src/schemas.test.ts +106 -0
  196. package/src/schemas.ts +97 -0
  197. package/src/session-like.ts +118 -0
  198. package/src/skill.ts +34 -0
  199. package/src/subagent.ts +46 -0
  200. package/src/token-accounting.test.ts +93 -0
  201. package/src/token-accounting.ts +202 -0
  202. package/src/token-efficiency.test.ts +459 -0
  203. package/src/tool-dispatch.ts +137 -0
  204. package/src/tool-gating.ts +107 -0
  205. package/src/tool.ts +175 -0
  206. package/src/transcriber.ts +71 -0
  207. package/src/tunnel.ts +26 -0
  208. package/src/type-contracts.ts +4 -0
  209. package/src/types.test-d.ts +29 -0
  210. package/src/view-renderer.test.ts +90 -0
  211. package/src/view-renderer.ts +156 -0
@@ -0,0 +1,459 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import {
4
+ applyLazyTools,
5
+ asEventId,
6
+ asSessionId,
7
+ asToolCallId,
8
+ asTurnId,
9
+ defineTool,
10
+ estimateContextTokens,
11
+ projectMessages,
12
+ projectMessagesFromLog,
13
+ summarizeSessionTokens,
14
+ type EventLogReader,
15
+ type MoxxyEvent,
16
+ type MoxxyEventOfType,
17
+ type MoxxyEventType,
18
+ type ProviderMessage,
19
+ type TurnId,
20
+ } from './index.js';
21
+
22
+ const sid = asSessionId('s1');
23
+ const t1 = asTurnId('t1');
24
+ const t2 = asTurnId('t2');
25
+
26
+ function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
27
+ return {
28
+ length: events.length,
29
+ at: (seq) => events[seq],
30
+ slice: (from = 0, to = events.length) => events.slice(from, to),
31
+ ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> =>
32
+ events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
33
+ byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
34
+ toJSON: () => events,
35
+ };
36
+ }
37
+
38
+ function event(seq: number, partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>): MoxxyEvent {
39
+ return { id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, ...partial } as MoxxyEvent;
40
+ }
41
+
42
+ const bigOutput = 'X'.repeat(5000);
43
+
44
+ // Log: t1 = old turn (anchor prompt + Read tool call/result + assistant text),
45
+ // elision through seq 3, then t2 = recent turn (kept verbatim).
46
+ function baseEvents(): MoxxyEvent[] {
47
+ return [
48
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'the original task' }),
49
+ event(1, {
50
+ type: 'tool_call_requested',
51
+ turnId: t1,
52
+ source: 'model',
53
+ callId: asToolCallId('c1'),
54
+ name: 'Read',
55
+ input: { file_path: '/a' },
56
+ }),
57
+ event(2, {
58
+ type: 'tool_result',
59
+ turnId: t1,
60
+ source: 'tool',
61
+ callId: asToolCallId('c1'),
62
+ ok: true,
63
+ output: bigOutput,
64
+ }),
65
+ event(3, {
66
+ type: 'assistant_message',
67
+ turnId: t1,
68
+ source: 'model',
69
+ content: 'old detailed answer '.repeat(20),
70
+ stopReason: 'end_turn',
71
+ }),
72
+ event(4, {
73
+ type: 'elision',
74
+ turnId: t2,
75
+ source: 'system',
76
+ elidedThrough: 3,
77
+ stubbedRanges: [[0, 3]],
78
+ elideConversational: true,
79
+ conversationalRecallThreshold: 4,
80
+ maxRecallBytes: 32_768,
81
+ neverElideTools: [],
82
+ tokensSaved: 1200,
83
+ }),
84
+ event(5, { type: 'user_prompt', turnId: t2, source: 'user', text: 'the new task' }),
85
+ ];
86
+ }
87
+
88
+ describe('elision in projectMessagesFromLog', () => {
89
+ it('stubs old tool results but keeps the tool_use pairing intact', () => {
90
+ const msgs = projectMessagesFromLog({ log: reader(baseEvents()) });
91
+ const toolResult = msgs.find((m) => m.role === 'tool_result');
92
+ expect(toolResult).toBeDefined();
93
+ const block = toolResult!.content[0]!;
94
+ expect(block.type === 'tool_result' && block.content).toMatch(/output elided/);
95
+ // The tool_use block must still be present and reference the same callId.
96
+ const assistantToolUse = msgs.find(
97
+ (m) => m.role === 'assistant' && m.content.some((b) => b.type === 'tool_use'),
98
+ );
99
+ expect(assistantToolUse).toBeDefined();
100
+ const useBlock = assistantToolUse!.content.find((b) => b.type === 'tool_use')!;
101
+ const resBlock = toolResult!.content[0]!;
102
+ expect(useBlock.type === 'tool_use' && useBlock.id).toBe(
103
+ resBlock.type === 'tool_result' ? resBlock.toolUseId : 'mismatch',
104
+ );
105
+ });
106
+
107
+ it('keeps the first user_prompt (task anchor) verbatim even when eliding', () => {
108
+ const msgs = projectMessagesFromLog({ log: reader(baseEvents()) });
109
+ const firstUser = msgs.find((m) => m.role === 'user');
110
+ expect(firstUser!.content[0]).toMatchObject({ type: 'text', text: 'the original task' });
111
+ });
112
+
113
+ it('collapses old assistant text to a stub when elideConversational is on', () => {
114
+ const msgs = projectMessagesFromLog({ log: reader(baseEvents()) });
115
+ const stubbed = msgs.find(
116
+ (m) => m.role === 'assistant' && m.content.some((b) => b.type === 'text' && /elided assistant turn/.test(b.text)),
117
+ );
118
+ expect(stubbed).toBeDefined();
119
+ });
120
+
121
+ it('keeps recent (post-HWM) content verbatim', () => {
122
+ const msgs = projectMessagesFromLog({ log: reader(baseEvents()) });
123
+ const recent = msgs.find(
124
+ (m) => m.role === 'user' && m.content.some((b) => b.type === 'text' && b.text === 'the new task'),
125
+ );
126
+ expect(recent).toBeDefined();
127
+ });
128
+
129
+ it('reports the stable-prefix boundary at the elision HWM (the cross-turn cache breakpoint)', () => {
130
+ const { messages, stablePrefixIndex } = projectMessages({ log: reader(baseEvents()) });
131
+ // Messages: anchor user(0), assistant tool_use(1), tool_result stub(2),
132
+ // assistant stub(3) — all from seqs ≤ HWM(3) — then the new task user(4),
133
+ // which is post-HWM and volatile. So the stable prefix ends at index 3.
134
+ expect(stablePrefixIndex).toBe(3);
135
+ expect(messages).toHaveLength(5);
136
+ // The boundary must be strictly before the rolling tail so the strategy
137
+ // actually emits a distinct long-lived breakpoint.
138
+ expect(stablePrefixIndex).toBeLessThan(messages.length - 1);
139
+ });
140
+
141
+ it('reports no stable prefix (-1) when elision is not active', () => {
142
+ const noElision = baseEvents().filter((e) => e.type !== 'elision');
143
+ const { stablePrefixIndex } = projectMessages({ log: reader(noElision) });
144
+ expect(stablePrefixIndex).toBe(-1);
145
+ });
146
+
147
+ it('marks an elided result as "already recalled" once a recall references it', () => {
148
+ const events = baseEvents();
149
+ events.push(
150
+ event(6, {
151
+ type: 'tool_call_requested',
152
+ turnId: t2,
153
+ source: 'model',
154
+ callId: asToolCallId('r1'),
155
+ name: 'recall',
156
+ input: { callId: 'c1' },
157
+ }),
158
+ event(7, {
159
+ type: 'tool_result',
160
+ turnId: t2,
161
+ source: 'tool',
162
+ callId: asToolCallId('r1'),
163
+ ok: true,
164
+ output: 'recalled full content',
165
+ }),
166
+ );
167
+ const msgs = projectMessagesFromLog({ log: reader(events) });
168
+ const stubs = msgs
169
+ .flatMap((m) => m.content)
170
+ .filter((b) => b.type === 'tool_result') as Array<{ type: 'tool_result'; content: string }>;
171
+ expect(stubs.some((b) => /already recalled/.test(b.content))).toBe(true);
172
+ // The recall's own result is pinned (full), never stubbed.
173
+ expect(stubs.some((b) => b.content === 'recalled full content')).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe('estimateContextTokens', () => {
178
+ it('counts an elided tool result as a stub, not its full payload', () => {
179
+ const withElision = estimateContextTokens(reader(baseEvents()));
180
+ const noElision = estimateContextTokens(
181
+ reader(baseEvents().filter((e) => e.type !== 'elision')),
182
+ );
183
+ expect(withElision).toBeLessThan(noElision);
184
+ });
185
+
186
+ it('honors never-elide (estimate matches projection: kept full)', () => {
187
+ // Same log, but the Read tool is on the never-elide list → its big output
188
+ // stays full, so the estimate is materially larger.
189
+ const elideable = estimateContextTokens(reader(baseEvents()));
190
+ const neverElide = baseEvents().map((e) =>
191
+ e.type === 'elision' ? { ...e, neverElideTools: ['Read'] } : e,
192
+ );
193
+ expect(estimateContextTokens(reader(neverElide))).toBeGreaterThan(elideable);
194
+ });
195
+ });
196
+
197
+ describe('adaptive conversational elision', () => {
198
+ // anchor + one long old assistant turn, elided, conversational on, threshold 1
199
+ const evs = (extra: MoxxyEvent[] = []): MoxxyEvent[] => [
200
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'task' }),
201
+ event(1, {
202
+ type: 'assistant_message',
203
+ turnId: t1,
204
+ source: 'model',
205
+ content: 'old answer '.repeat(40),
206
+ stopReason: 'end_turn',
207
+ }),
208
+ event(2, {
209
+ type: 'elision',
210
+ turnId: t2,
211
+ source: 'system',
212
+ elidedThrough: 1,
213
+ stubbedRanges: [[1, 1]],
214
+ elideConversational: true,
215
+ conversationalRecallThreshold: 1,
216
+ maxRecallBytes: 32_768,
217
+ neverElideTools: [],
218
+ tokensSaved: 100,
219
+ }),
220
+ event(3, { type: 'user_prompt', turnId: t2, source: 'user', text: 'next' }),
221
+ ...extra,
222
+ ];
223
+
224
+ const assistantText = (events: MoxxyEvent[]): string =>
225
+ projectMessagesFromLog({ log: reader(events) })
226
+ .filter((m) => m.role === 'assistant')
227
+ .flatMap((m) => m.content)
228
+ .map((b) => (b.type === 'text' ? b.text : ''))
229
+ .join('');
230
+
231
+ it('stubs old text turns before the recall threshold is hit', () => {
232
+ expect(assistantText(evs())).toMatch(/elided assistant turn/);
233
+ });
234
+
235
+ it('reverts old text turns to full once seq-recalls reach the threshold', () => {
236
+ const withRecall = evs([
237
+ event(4, {
238
+ type: 'tool_call_requested',
239
+ turnId: t2,
240
+ source: 'model',
241
+ callId: asToolCallId('rc'),
242
+ name: 'recall',
243
+ input: { seq: 1 },
244
+ }),
245
+ ]);
246
+ const text = assistantText(withRecall);
247
+ expect(text).toMatch(/old answer/);
248
+ expect(text).not.toMatch(/elided assistant turn/);
249
+ });
250
+ });
251
+
252
+ describe('maxRecallBytes cap', () => {
253
+ it('stubs the oldest pinned recalls once the cap is exceeded', () => {
254
+ const events: MoxxyEvent[] = [
255
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'task' }),
256
+ event(1, {
257
+ type: 'tool_call_requested',
258
+ turnId: t1,
259
+ source: 'model',
260
+ callId: asToolCallId('ra'),
261
+ name: 'recall',
262
+ input: { callId: 'x' },
263
+ }),
264
+ event(2, {
265
+ type: 'tool_result',
266
+ turnId: t1,
267
+ source: 'tool',
268
+ callId: asToolCallId('ra'),
269
+ ok: true,
270
+ output: 'A'.repeat(300),
271
+ }),
272
+ event(3, {
273
+ type: 'tool_call_requested',
274
+ turnId: t1,
275
+ source: 'model',
276
+ callId: asToolCallId('rb'),
277
+ name: 'recall',
278
+ input: { callId: 'y' },
279
+ }),
280
+ event(4, {
281
+ type: 'tool_result',
282
+ turnId: t1,
283
+ source: 'tool',
284
+ callId: asToolCallId('rb'),
285
+ ok: true,
286
+ output: 'B'.repeat(300),
287
+ }),
288
+ event(5, {
289
+ type: 'elision',
290
+ turnId: t2,
291
+ source: 'system',
292
+ elidedThrough: 4,
293
+ stubbedRanges: [[0, 4]],
294
+ elideConversational: false,
295
+ conversationalRecallThreshold: 4,
296
+ maxRecallBytes: 400, // fits the newest (rb=300) but not both
297
+ neverElideTools: [],
298
+ tokensSaved: 100,
299
+ }),
300
+ event(6, { type: 'user_prompt', turnId: t2, source: 'user', text: 'next' }),
301
+ ];
302
+ const results = projectMessagesFromLog({ log: reader(events) })
303
+ .flatMap((m) => m.content)
304
+ .filter((b): b is { type: 'tool_result'; content: string } => b.type === 'tool_result');
305
+ const rb = results.find((b) => b.content.includes('B'.repeat(300)));
306
+ const ra = results.find((b) => b.content.includes('recall("ra")'));
307
+ expect(rb).toBeDefined(); // newest pinned, full
308
+ expect(ra).toBeDefined(); // oldest over cap, stubbed
309
+ });
310
+ });
311
+
312
+ describe('summarizeSessionTokens', () => {
313
+ it('aggregates usage and computes cache savings', () => {
314
+ const log = reader([
315
+ event(0, {
316
+ type: 'provider_response',
317
+ turnId: t1,
318
+ source: 'system',
319
+ provider: 'anthropic',
320
+ model: 'm',
321
+ inputTokens: 1000,
322
+ outputTokens: 100,
323
+ }),
324
+ event(1, {
325
+ type: 'provider_response',
326
+ turnId: t2,
327
+ source: 'system',
328
+ provider: 'anthropic',
329
+ model: 'm',
330
+ inputTokens: 200,
331
+ cacheReadTokens: 1800,
332
+ cacheCreationTokens: 100,
333
+ outputTokens: 120,
334
+ }),
335
+ ]);
336
+ const s = summarizeSessionTokens(log);
337
+ expect(s.calls).toBe(2);
338
+ expect(s.totalCacheRead).toBe(1800);
339
+ expect(s.totalPrompt).toBe(1000 + 200 + 1800 + 100);
340
+ // billed = 1200*1.0 + 1800*0.1 + 100*1.25 = 1505; uncached = 3100
341
+ expect(Math.round(s.billedInputEq)).toBe(1505);
342
+ expect(s.savedRatio).toBeGreaterThan(0.5);
343
+ expect(s.cacheEffective).toBe(true);
344
+ });
345
+
346
+ it('flags cache ineffective when writing cache but never reading it', () => {
347
+ const responses = Array.from({ length: 6 }, (_, i) =>
348
+ event(i, {
349
+ type: 'provider_response',
350
+ turnId: t1,
351
+ source: 'system',
352
+ provider: 'anthropic',
353
+ model: 'm',
354
+ inputTokens: 1000,
355
+ cacheCreationTokens: 1000, // writes happening...
356
+ cacheReadTokens: 0, // ...but no reads → unstable prefix
357
+ outputTokens: 50,
358
+ }),
359
+ );
360
+ expect(summarizeSessionTokens(reader(responses)).cacheEffective).toBe(false);
361
+ });
362
+
363
+ it('does not false-alarm when caching is simply off (no writes)', () => {
364
+ const responses = Array.from({ length: 6 }, (_, i) =>
365
+ event(i, {
366
+ type: 'provider_response',
367
+ turnId: t1,
368
+ source: 'system',
369
+ provider: 'anthropic',
370
+ model: 'm',
371
+ inputTokens: 1000,
372
+ outputTokens: 50,
373
+ }),
374
+ );
375
+ expect(summarizeSessionTokens(reader(responses)).cacheEffective).toBe(true);
376
+ });
377
+ });
378
+
379
+ describe('cache prefix stability', () => {
380
+ // The cache only pays off if the projected prefix is byte-identical across
381
+ // the inner iterations of a turn. Guards against anyone introducing per-call
382
+ // nondeterminism (timestamps, reordering) into the projection.
383
+ const turnEvents = (n: number): MoxxyEvent[] => {
384
+ const out: MoxxyEvent[] = [
385
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'task' }),
386
+ ];
387
+ let seq = 1;
388
+ for (let i = 0; i < n; i++) {
389
+ out.push(
390
+ event(seq++, {
391
+ type: 'tool_call_requested',
392
+ turnId: t1,
393
+ source: 'model',
394
+ callId: asToolCallId(`c${i}`),
395
+ name: 'Read',
396
+ input: { file_path: `/f${i}` },
397
+ }),
398
+ );
399
+ out.push(
400
+ event(seq++, {
401
+ type: 'tool_result',
402
+ turnId: t1,
403
+ source: 'tool',
404
+ callId: asToolCallId(`c${i}`),
405
+ ok: true,
406
+ output: `result ${i}`,
407
+ }),
408
+ );
409
+ }
410
+ return out;
411
+ };
412
+
413
+ it('keeps the earlier-message prefix byte-identical when the next iteration appends', () => {
414
+ const p1 = projectMessagesFromLog({ log: reader(turnEvents(2)) }, { systemPrompt: 'sys' });
415
+ const p2 = projectMessagesFromLog({ log: reader(turnEvents(3)) }, { systemPrompt: 'sys' });
416
+ expect(JSON.stringify(p2.slice(0, p1.length))).toBe(JSON.stringify(p1));
417
+ });
418
+ });
419
+
420
+ describe('applyLazyTools', () => {
421
+ const mk = (name: string) =>
422
+ defineTool({ name, description: `desc ${name}`, inputSchema: z.object({}), handler: () => '' });
423
+ const baseMsgs: ProviderMessage[] = [
424
+ { role: 'system', content: [{ type: 'text', text: 'sys' }] },
425
+ { role: 'user', content: [{ type: 'text', text: 'hi' }] },
426
+ ];
427
+
428
+ it('keeps core tools, hides others into a system-prompt index', () => {
429
+ const tools = [mk('Read'), mk('browser_open'), mk('memory_save')];
430
+ const { messages, tools: sent } = applyLazyTools(baseMsgs, tools, reader([]));
431
+ expect(sent.map((t) => t.name)).toEqual(['Read']);
432
+ const sys = messages.find((m) => m.role === 'system')!;
433
+ expect((sys.content[0] as { text: string }).text).toMatch(/Loadable tools/);
434
+ expect((sys.content[0] as { text: string }).text).toMatch(/browser_open/);
435
+ });
436
+
437
+ it('includes a tool once it has been load_tool-ed', () => {
438
+ const tools = [mk('Read'), mk('browser_open')];
439
+ const log = reader([
440
+ event(0, {
441
+ type: 'tool_call_requested',
442
+ turnId: t1,
443
+ source: 'model',
444
+ callId: asToolCallId('l1'),
445
+ name: 'load_tool',
446
+ input: { name: 'browser_open' },
447
+ }),
448
+ ]);
449
+ const { tools: sent } = applyLazyTools(baseMsgs, tools, log);
450
+ expect(sent.map((t) => t.name).sort()).toEqual(['Read', 'browser_open']);
451
+ });
452
+
453
+ it('is a no-op (stable system prompt) when all tools are core', () => {
454
+ const tools = [mk('Read'), mk('Bash')];
455
+ const { messages, tools: sent } = applyLazyTools(baseMsgs, tools, reader([]));
456
+ expect(sent).toHaveLength(2);
457
+ expect(messages).toBe(baseMsgs); // same reference → byte-stable
458
+ });
459
+ });
@@ -0,0 +1,137 @@
1
+ import { asToolCallId } from './ids.js';
2
+ import type { MoxxyEvent } from './events.js';
3
+ import type { ModeContext } from './mode.js';
4
+ import type { ToolCallVerdict } from './hooks.js';
5
+ import type { CollectedToolUse } from './mode-helpers.js';
6
+
7
+ /**
8
+ * Execute a single tool-use end-to-end: dispatch `dispatchToolCall` hooks, run
9
+ * the permission check, invoke the tool, and emit the approved/denied/result
10
+ * events. An async generator so a loop strategy can `yield*` it; callers that
11
+ * don't need the events can drain it (`for await (const _ of …) {}`) — either
12
+ * way the events reach the log via `ctx.emit`.
13
+ *
14
+ * Shared by every loop strategy (tool-use, plan-execute, developer, bmad) so
15
+ * the defensive outer try/catch below lives in ONE place. Without it, a throw
16
+ * from a hook handler / permission resolver / the emit itself escapes the
17
+ * generator and leaves this (and any later) call as an orphan
18
+ * `tool_call_requested` with no matching `tool_result` — which the provider
19
+ * then rejects on the next turn.
20
+ */
21
+ export async function* dispatchToolCall(
22
+ ctx: ModeContext,
23
+ t: CollectedToolUse,
24
+ iteration: number,
25
+ ): AsyncGenerator<MoxxyEvent, void, unknown> {
26
+ try {
27
+ const verdict = await ctx.hooks.dispatchToolCall({
28
+ sessionId: ctx.sessionId,
29
+ cwd: '',
30
+ log: ctx.log,
31
+ env: {},
32
+ turnId: ctx.turnId,
33
+ iteration,
34
+ call: { callId: asToolCallId(t.id), name: t.name, input: t.input },
35
+ });
36
+ const actualInput = verdict.action === 'rewrite' ? verdict.input : t.input;
37
+
38
+ const denyReason = hookDeny(verdict);
39
+ if (denyReason) {
40
+ yield* emitDenied(ctx, t, denyReason, 'hook');
41
+ return;
42
+ }
43
+
44
+ const decision = await ctx.permissions.check(
45
+ { callId: asToolCallId(t.id), name: t.name, input: actualInput },
46
+ { sessionId: String(ctx.sessionId), toolDescription: ctx.tools.get(t.name)?.description },
47
+ );
48
+ if (decision.mode === 'deny') {
49
+ yield* emitDenied(ctx, t, decision.reason ?? 'denied by resolver', 'resolver');
50
+ return;
51
+ }
52
+ yield await ctx.emit({
53
+ type: 'tool_call_approved',
54
+ sessionId: ctx.sessionId,
55
+ turnId: ctx.turnId,
56
+ source: 'system',
57
+ callId: asToolCallId(t.id),
58
+ decidedBy: 'resolver',
59
+ mode: decision.mode,
60
+ });
61
+
62
+ try {
63
+ const output = await ctx.tools.execute(t.name, actualInput, ctx.signal, {
64
+ callId: t.id,
65
+ sessionId: String(ctx.sessionId),
66
+ turnId: String(ctx.turnId),
67
+ log: ctx.log,
68
+ ...(ctx.subagents ? { subagents: ctx.subagents } : {}),
69
+ });
70
+ yield await ctx.emit({
71
+ type: 'tool_result',
72
+ sessionId: ctx.sessionId,
73
+ turnId: ctx.turnId,
74
+ source: 'tool',
75
+ callId: asToolCallId(t.id),
76
+ ok: true,
77
+ output,
78
+ });
79
+ } catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ const kind: 'aborted' | 'threw' = ctx.signal.aborted ? 'aborted' : 'threw';
82
+ yield await ctx.emit({
83
+ type: 'tool_result',
84
+ sessionId: ctx.sessionId,
85
+ turnId: ctx.turnId,
86
+ source: 'tool',
87
+ callId: asToolCallId(t.id),
88
+ ok: false,
89
+ error: { kind, message },
90
+ });
91
+ }
92
+ } catch (err) {
93
+ // Defensive: a hook handler, permission resolver, or the emit itself threw
94
+ // before we could produce a tool_result. Synthesize a failed result so the
95
+ // event log stays well-formed (no orphan tool_call_requested).
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ yield await ctx.emit({
98
+ type: 'tool_result',
99
+ sessionId: ctx.sessionId,
100
+ turnId: ctx.turnId,
101
+ source: 'tool',
102
+ callId: asToolCallId(t.id),
103
+ ok: false,
104
+ error: { kind: 'threw', message: `pre-execute failure: ${message}` },
105
+ });
106
+ }
107
+ }
108
+
109
+ function hookDeny(verdict: ToolCallVerdict): string | null {
110
+ return verdict.action === 'deny' ? verdict.reason : null;
111
+ }
112
+
113
+ async function* emitDenied(
114
+ ctx: ModeContext,
115
+ t: CollectedToolUse,
116
+ reason: string,
117
+ by: 'hook' | 'resolver' | 'policy',
118
+ ): AsyncGenerator<MoxxyEvent, void, unknown> {
119
+ yield await ctx.emit({
120
+ type: 'tool_call_denied',
121
+ sessionId: ctx.sessionId,
122
+ turnId: ctx.turnId,
123
+ source: 'system',
124
+ callId: asToolCallId(t.id),
125
+ decidedBy: by,
126
+ reason,
127
+ });
128
+ yield await ctx.emit({
129
+ type: 'tool_result',
130
+ sessionId: ctx.sessionId,
131
+ turnId: ctx.turnId,
132
+ source: 'tool',
133
+ callId: asToolCallId(t.id),
134
+ ok: false,
135
+ error: { kind: 'denied', message: reason },
136
+ });
137
+ }
@@ -0,0 +1,107 @@
1
+ import type { EventLogReader } from './log.js';
2
+ import type { ProviderMessage } from './provider.js';
3
+ import type { ToolDef } from './tool.js';
4
+
5
+ /**
6
+ * Lazy tool loading (request-scoped tool selection). Mirrors the skill
7
+ * lazy-load idiom: instead of sending every tool schema on every call, send a
8
+ * small always-on core plus whatever the model has explicitly loaded, and put
9
+ * a compact one-line index of the rest in the system prompt. The model calls
10
+ * `load_tool({ name })` to pull a schema in before using it.
11
+ *
12
+ * "Loaded" state is derived from the log (the `load_tool` calls), not a
13
+ * separate mutable store — so projection stays a pure function of the log, the
14
+ * same property that makes elision and caching deterministic.
15
+ */
16
+
17
+ /** Core tools always sent in full — the agent's baseline capability + the
18
+ * loaders themselves. Everything else is lazy-loadable when gating is on. */
19
+ export const ALWAYS_ON_TOOLS: ReadonlySet<string> = new Set([
20
+ 'Read',
21
+ 'Write',
22
+ 'Edit',
23
+ 'Bash',
24
+ 'Grep',
25
+ 'Glob',
26
+ 'recall',
27
+ 'load_skill',
28
+ 'load_tool',
29
+ 'dispatch_agent',
30
+ ]);
31
+
32
+ /** Tool names the model has loaded this session (from `load_tool` calls). */
33
+ export function loadedToolNames(log: EventLogReader): ReadonlySet<string> {
34
+ const names = new Set<string>();
35
+ for (const e of log.ofType('tool_call_requested')) {
36
+ if (e.name !== 'load_tool') continue;
37
+ const input = e.input as { name?: unknown } | null | undefined;
38
+ if (input && typeof input === 'object' && typeof input.name === 'string') {
39
+ names.add(input.name);
40
+ }
41
+ }
42
+ return names;
43
+ }
44
+
45
+ function oneLine(s: string): string {
46
+ return s.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
47
+ }
48
+
49
+ function truncate(s: string, n: number): string {
50
+ return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
51
+ }
52
+
53
+ /** Compact index (name + 1-line description) of not-yet-loaded tools. */
54
+ export function buildToolIndex(hidden: ReadonlyArray<ToolDef>): string {
55
+ const lines = hidden
56
+ .map((t) => `- **${t.name}** — ${truncate(oneLine(t.description ?? ''), 100)}`)
57
+ .join('\n');
58
+ return (
59
+ `## Loadable tools\n\n` +
60
+ `These tools exist but their full schemas are not loaded right now. When a ` +
61
+ `task needs one, call \`load_tool({ name: "<tool-name>" })\` first, then call ` +
62
+ `the tool on the next turn.\n\n${lines}`
63
+ );
64
+ }
65
+
66
+ function injectIntoSystem(
67
+ messages: ReadonlyArray<ProviderMessage>,
68
+ index: string,
69
+ ): ProviderMessage[] {
70
+ const out = messages.map((m) => m);
71
+ const sysIdx = out.findIndex((m) => m.role === 'system');
72
+ if (sysIdx >= 0) {
73
+ const sys = out[sysIdx]!;
74
+ const content = sys.content.map((b) =>
75
+ b.type === 'text' ? { ...b, text: `${b.text}\n\n${index}` } : b,
76
+ );
77
+ // If there was no text block, append one.
78
+ if (!sys.content.some((b) => b.type === 'text')) {
79
+ content.push({ type: 'text', text: index });
80
+ }
81
+ out[sysIdx] = { role: 'system', content };
82
+ return out;
83
+ }
84
+ return [{ role: 'system', content: [{ type: 'text', text: index }] }, ...out];
85
+ }
86
+
87
+ export interface GatedTools {
88
+ readonly messages: ReadonlyArray<ProviderMessage>;
89
+ readonly tools: ReadonlyArray<ToolDef>;
90
+ }
91
+
92
+ /**
93
+ * Apply lazy tool gating: keep always-on + loaded tools in the request, move
94
+ * the rest into a system-prompt index. No-op (returns inputs) when nothing is
95
+ * gated, so the system prompt stays byte-stable on turns that load nothing.
96
+ */
97
+ export function applyLazyTools(
98
+ messages: ReadonlyArray<ProviderMessage>,
99
+ tools: ReadonlyArray<ToolDef>,
100
+ log: EventLogReader,
101
+ ): GatedTools {
102
+ const loaded = loadedToolNames(log);
103
+ const hidden = tools.filter((t) => !ALWAYS_ON_TOOLS.has(t.name) && !loaded.has(t.name));
104
+ if (hidden.length === 0) return { messages, tools };
105
+ const visible = tools.filter((t) => ALWAYS_ON_TOOLS.has(t.name) || loaded.has(t.name));
106
+ return { messages: injectIntoSystem(messages, buildToolIndex(hidden)), tools: visible };
107
+ }