@moxxy/mode-goal 0.26.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.
@@ -0,0 +1,577 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { defineTool, type ProviderEvent } from '@moxxy/sdk';
4
+ import { collectTurn } from '@moxxy/core';
5
+ import { FakeProvider, createFakeSession, textReply, toolUseReply } from '@moxxy/testing';
6
+
7
+ import { goalModePlugin, GOAL_MODE_NAME } from './index.js';
8
+ import { __setRetrySleepForTests } from './goal-loop.js';
9
+
10
+ /** A scripted provider reply that surfaces a retryable error mid-stream. */
11
+ function retryableErrorReply(message = 'overloaded'): ProviderEvent[] {
12
+ return [
13
+ { type: 'message_start', model: 'fake' },
14
+ { type: 'error', message, retryable: true },
15
+ ];
16
+ }
17
+
18
+ describe('goalMode end-to-end', () => {
19
+ it('stops with goal_completed when the model calls goal_complete', async () => {
20
+ const provider = new FakeProvider({
21
+ script: [
22
+ toolUseReply('goal_complete', { summary: 'Refactored the parser', evidence: ['tests pass'] }, 'gc1'),
23
+ ],
24
+ });
25
+ const session = createFakeSession({ provider });
26
+ session.pluginHost.registerStatic(goalModePlugin);
27
+ session.modes.setActive(GOAL_MODE_NAME);
28
+
29
+ const events = await collectTurn(session, 'refactor the parser');
30
+
31
+ // The run announced it started, then completed (and nothing after).
32
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_started')).toBe(true);
33
+ const completed = events.find((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed');
34
+ expect(completed).toBeDefined();
35
+ if (completed?.type !== 'plugin_event') throw new Error('expected goal_completed');
36
+ expect((completed.payload as { summary: string }).summary).toBe('Refactored the parser');
37
+
38
+ // Final system message surfaces the summary to the user.
39
+ const finalMsg = events
40
+ .filter((e) => e.type === 'assistant_message' && e.source === 'system')
41
+ .pop();
42
+ if (finalMsg?.type !== 'assistant_message') throw new Error('expected final system message');
43
+ expect(finalMsg.content).toContain('Refactored the parser');
44
+
45
+ // The goal tool actually ran and was auto-approved (no permission prompt).
46
+ expect(
47
+ events.some((e) => e.type === 'tool_call_approved' && e.mode === 'allow'),
48
+ ).toBe(true);
49
+ });
50
+
51
+ it('auto-approves a normal tool call mid-run (full autonomy), then completes', async () => {
52
+ const provider = new FakeProvider({
53
+ script: [
54
+ // First the model does real work via a tool…
55
+ toolUseReply('list_dir', { path: '.' }, 'work1'),
56
+ // …then declares done.
57
+ toolUseReply('goal_complete', { summary: 'listed files' }, 'gc2'),
58
+ ],
59
+ });
60
+ const session = createFakeSession({ provider });
61
+ session.pluginHost.registerStatic(goalModePlugin);
62
+ session.modes.setActive(GOAL_MODE_NAME);
63
+
64
+ const events = await collectTurn(session, 'list the files then finish');
65
+ // The work tool was auto-approved without any ask/permission round-trip.
66
+ const approvals = events.filter((e) => e.type === 'tool_call_approved');
67
+ expect(approvals.length).toBeGreaterThanOrEqual(2); // work tool + goal_complete
68
+ expect(approvals.every((e) => e.type === 'tool_call_approved' && e.mode === 'allow')).toBe(true);
69
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(true);
70
+ });
71
+
72
+ it('honours a user deny rule (policy) while still auto-approving other tools', async () => {
73
+ let dangerousRan = false;
74
+ const provider = new FakeProvider({
75
+ script: [
76
+ // The model tries the denied tool first…
77
+ toolUseReply('dangerous', { target: 'prod' }, 'd1'),
78
+ // …then a permitted one, then declares done.
79
+ toolUseReply('safe', {}, 's1'),
80
+ toolUseReply('goal_complete', { summary: 'finished without the denied tool' }, 'gc3'),
81
+ ],
82
+ });
83
+ const session = createFakeSession({ provider });
84
+ session.pluginHost.registerStatic(goalModePlugin);
85
+ session.modes.setActive(GOAL_MODE_NAME);
86
+ session.tools.register(
87
+ defineTool({
88
+ name: 'dangerous',
89
+ description: '',
90
+ inputSchema: z.object({ target: z.string() }),
91
+ handler: () => {
92
+ dangerousRan = true;
93
+ return 'boom';
94
+ },
95
+ }),
96
+ );
97
+ session.tools.register(
98
+ defineTool({ name: 'safe', description: '', inputSchema: z.object({}), handler: () => 'ok' }),
99
+ );
100
+ // Same persistent policy engine that backs ~/.moxxy/permissions.json.
101
+ await session.permissions.addDeny({ name: 'dangerous', reason: 'user denied this tool' });
102
+ // Tripwire: goal mode must never fall through to the interactive prompt
103
+ // path. If it did, dispatchToolCall would surface a pre-execute failure.
104
+ session.setPermissionResolver({
105
+ name: 'tripwire-prompt',
106
+ check: async () => {
107
+ throw new Error('interactive prompt fired in goal mode');
108
+ },
109
+ });
110
+
111
+ const events = await collectTurn(session, 'do the thing');
112
+
113
+ // The deny rule held, with the user's reason…
114
+ const denied = events.find((e) => e.type === 'tool_call_denied');
115
+ if (denied?.type !== 'tool_call_denied') throw new Error('expected a tool_call_denied event');
116
+ expect(denied.decidedBy).toBe('resolver');
117
+ expect(denied.reason).toContain('user denied this tool');
118
+ // …the denied call still produced a well-formed failed tool_result…
119
+ const deniedResult = events.find(
120
+ (e) => e.type === 'tool_result' && e.callId === denied.callId,
121
+ );
122
+ if (deniedResult?.type !== 'tool_result') throw new Error('expected a tool_result for the denial');
123
+ expect(deniedResult.ok).toBe(false);
124
+ // …and the handler never executed.
125
+ expect(dangerousRan).toBe(false);
126
+
127
+ // Everything else still auto-approves without prompting (the tripwire
128
+ // would have failed those calls) and the run completes.
129
+ const approvals = events.filter((e) => e.type === 'tool_call_approved');
130
+ expect(approvals.length).toBeGreaterThanOrEqual(2); // safe + goal_complete
131
+ expect(approvals.every((e) => e.type === 'tool_call_approved' && e.mode === 'allow')).toBe(true);
132
+ expect(
133
+ events.some(
134
+ (e) => e.type === 'tool_result' && !e.ok && e.error.message.includes('pre-execute failure'),
135
+ ),
136
+ ).toBe(false);
137
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(true);
138
+ });
139
+
140
+ it('stalls (goal_stalled) when the model keeps idling without completing', async () => {
141
+ // GOAL_MAX_NOOP_ITERATIONS idle (no-tool) replies → the loop gives up.
142
+ const provider = new FakeProvider({
143
+ script: [
144
+ textReply('Thinking about it...'),
145
+ textReply('Still working through it...'),
146
+ textReply('I believe this is fine.'),
147
+ ],
148
+ });
149
+ const session = createFakeSession({ provider });
150
+ session.pluginHost.registerStatic(goalModePlugin);
151
+ session.modes.setActive(GOAL_MODE_NAME);
152
+
153
+ const events = await collectTurn(session, 'do something vague');
154
+
155
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stalled')).toBe(true);
156
+ // It did NOT falsely report completion.
157
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(false);
158
+ });
159
+
160
+ it('emits a paired result for every request even when the stuck loop trips', async () => {
161
+ // The model hammers the same (name, input) until the detector trips. The
162
+ // stuck trip ends the turn before executeToolUses runs the final request —
163
+ // without synthesizing a result that request is orphaned (renders as a tool
164
+ // stuck "running" forever, flips to error only on the next user_prompt).
165
+ const provider = new FakeProvider({
166
+ script: Array.from({ length: 20 }, (_, i) => toolUseReply('loop', {}, `c${i}`)),
167
+ });
168
+ const session = createFakeSession({ provider });
169
+ session.pluginHost.registerStatic(goalModePlugin);
170
+ session.modes.setActive(GOAL_MODE_NAME);
171
+ session.tools.register(
172
+ defineTool({
173
+ name: 'loop',
174
+ description: '',
175
+ inputSchema: z.object({}),
176
+ handler: () => 'ok',
177
+ }),
178
+ );
179
+
180
+ const events = await collectTurn(session, 'spin');
181
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stuck')).toBe(true);
182
+
183
+ const requestedIds = new Set(
184
+ events.filter((e) => e.type === 'tool_call_requested').map((e) => e.callId),
185
+ );
186
+ const resolvedIds = new Set(
187
+ events.filter((e) => e.type === 'tool_result').map((e) => e.callId),
188
+ );
189
+ const orphans = [...requestedIds].filter((id) => !resolvedIds.has(id));
190
+ expect(orphans).toEqual([]);
191
+ });
192
+
193
+ // u67-2: the cumulative token-budget backstop must stop the run (the exact
194
+ // unbounded-run failure the guard exists to prevent).
195
+ it('stops with goal_budget_exhausted when cumulative usage exceeds the budget', () => {
196
+ // A single reply whose usage blows past GOAL_TOKEN_BUDGET (4M). The budget
197
+ // check runs right after the provider call, before any tool work, so this
198
+ // trips on iteration 1.
199
+ const hugeUsage: ProviderEvent[] = [
200
+ { type: 'message_start', model: 'fake' },
201
+ { type: 'text_delta', delta: 'working...' },
202
+ {
203
+ type: 'message_end',
204
+ stopReason: 'end_turn',
205
+ usage: { inputTokens: 5_000_000, outputTokens: 0 },
206
+ },
207
+ ];
208
+ const provider = new FakeProvider({ script: [hugeUsage] });
209
+ const session = createFakeSession({ provider });
210
+ session.pluginHost.registerStatic(goalModePlugin);
211
+ session.modes.setActive(GOAL_MODE_NAME);
212
+
213
+ return collectTurn(session, 'do an expensive thing').then((events) => {
214
+ const exhausted = events.find(
215
+ (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
216
+ );
217
+ expect(exhausted).toBeDefined();
218
+ if (exhausted?.type !== 'plugin_event') throw new Error('expected goal_budget_exhausted');
219
+ expect((exhausted.payload as { budget: number }).budget).toBe(4_000_000);
220
+ // It did NOT falsely report completion, and a final system message tells
221
+ // the user how to continue.
222
+ expect(
223
+ events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed'),
224
+ ).toBe(false);
225
+ const finalMsg = events
226
+ .filter((e) => e.type === 'assistant_message' && e.source === 'system')
227
+ .pop();
228
+ if (finalMsg?.type !== 'assistant_message') throw new Error('expected final system message');
229
+ expect(finalMsg.content).toContain('token budget exhausted');
230
+ });
231
+ });
232
+
233
+ // u67-3: the budget-exhausting call's reasoning must still be persisted to the
234
+ // log before the budget exit, matching every other exit path (otherwise the
235
+ // final call's reasoning is silently dropped).
236
+ it('persists the budget-exhausting call reasoning before goal_budget_exhausted', () => {
237
+ const hugeUsageWithReasoning: ProviderEvent[] = [
238
+ { type: 'message_start', model: 'fake' },
239
+ { type: 'reasoning_delta', delta: 'I should think hard about this expensive task.' },
240
+ { type: 'reasoning_signature', signature: 'sig-1' },
241
+ { type: 'text_delta', delta: 'working...' },
242
+ {
243
+ type: 'message_end',
244
+ stopReason: 'end_turn',
245
+ usage: { inputTokens: 5_000_000, outputTokens: 0 },
246
+ },
247
+ ];
248
+ const provider = new FakeProvider({ script: [hugeUsageWithReasoning] });
249
+ const session = createFakeSession({ provider });
250
+ session.pluginHost.registerStatic(goalModePlugin);
251
+ session.modes.setActive(GOAL_MODE_NAME);
252
+
253
+ return collectTurn(session, 'do an expensive thing').then((events) => {
254
+ const reasoningIdx = events.findIndex((e) => e.type === 'reasoning_message');
255
+ const exhaustedIdx = events.findIndex(
256
+ (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
257
+ );
258
+ expect(reasoningIdx).toBeGreaterThanOrEqual(0);
259
+ expect(exhaustedIdx).toBeGreaterThanOrEqual(0);
260
+ // The reasoning_message lands BEFORE the budget exit (the bug dropped it).
261
+ expect(reasoningIdx).toBeLessThan(exhaustedIdx);
262
+ const reasoning = events[reasoningIdx];
263
+ if (reasoning?.type !== 'reasoning_message') throw new Error('expected reasoning_message');
264
+ expect(reasoning.content).toContain('think hard');
265
+ expect(reasoning.signature).toBe('sig-1');
266
+ });
267
+ });
268
+
269
+ // u67-2: the hard iteration cap must end the run with goal_max_iterations + a
270
+ // fatal error when the model never calls goal_complete.
271
+ it('stops with goal_max_iterations when the cap is reached without completing', async () => {
272
+ // The model keeps doing distinct work (varied inputs dodge the stuck-loop
273
+ // detector) and never declares done. ctx.maxIterations=2 bounds the run.
274
+ const provider = new FakeProvider({
275
+ script: [
276
+ toolUseReply('work', { step: 1 }, 'w1'),
277
+ toolUseReply('work', { step: 2 }, 'w2'),
278
+ toolUseReply('work', { step: 3 }, 'w3'),
279
+ ],
280
+ });
281
+ const session = createFakeSession({ provider });
282
+ session.pluginHost.registerStatic(goalModePlugin);
283
+ session.modes.setActive(GOAL_MODE_NAME);
284
+ session.tools.register(
285
+ defineTool({
286
+ name: 'work',
287
+ description: '',
288
+ inputSchema: z.object({ step: z.number() }),
289
+ handler: () => 'ok',
290
+ }),
291
+ );
292
+
293
+ const events = await collectTurn(session, 'keep working forever', { maxIterations: 2 });
294
+
295
+ const cap = events.find(
296
+ (e) => e.type === 'plugin_event' && e.subtype === 'goal_max_iterations',
297
+ );
298
+ expect(cap).toBeDefined();
299
+ if (cap?.type !== 'plugin_event') throw new Error('expected goal_max_iterations');
300
+ expect((cap.payload as { maxIterations: number }).maxIterations).toBe(2);
301
+ // A fatal error closes the run; it did not falsely complete.
302
+ expect(
303
+ events.some((e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('iteration cap')),
304
+ ).toBe(true);
305
+ expect(
306
+ events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed'),
307
+ ).toBe(false);
308
+ });
309
+
310
+ // A degenerate caller-supplied bound (0 / negative / NaN / fractional) bypasses
311
+ // the config schema (programmatic callers: subagents/workflows forward it raw).
312
+ // Without the clamp the for-loop never runs and control falls straight to the
313
+ // misleading "iteration cap reached (0)" fatal — work was never done, yet the
314
+ // message blames the model. The clamp must run at least one real iteration.
315
+ describe('degenerate maxIterations is clamped, not fatal-with-zero-work', () => {
316
+ for (const bad of [0, -5, Number.NaN, 1.9]) {
317
+ it(`runs ≥1 iteration and completes when maxIterations=${String(bad)}`, async () => {
318
+ const provider = new FakeProvider({
319
+ script: [toolUseReply('goal_complete', { summary: 'did one step' }, 'gc-clamp')],
320
+ });
321
+ const session = createFakeSession({ provider });
322
+ session.pluginHost.registerStatic(goalModePlugin);
323
+ session.modes.setActive(GOAL_MODE_NAME);
324
+
325
+ const events = await collectTurn(session, 'do it', { maxIterations: bad });
326
+
327
+ // It actually drove the model (real work happened) and completed —
328
+ // it did NOT fall through to the zero-work iteration-cap fatal.
329
+ expect(
330
+ events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed'),
331
+ ).toBe(true);
332
+ expect(
333
+ events.some(
334
+ (e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('iteration cap'),
335
+ ),
336
+ ).toBe(false);
337
+ // The provider was actually called (the loop body ran at least once).
338
+ expect(provider.received.length).toBeGreaterThanOrEqual(1);
339
+ });
340
+ }
341
+
342
+ it('reports the clamped cap (not the raw degenerate value) when it later runs out', async () => {
343
+ // maxIterations=0 clamps to 1; the model never completes, so the run hits
344
+ // the cap after a single iteration and the surfaced cap is the clamped 1.
345
+ const provider = new FakeProvider({
346
+ script: Array.from({ length: 3 }, (_, i) => toolUseReply('work', { step: i }, `c${i}`)),
347
+ });
348
+ const session = createFakeSession({ provider });
349
+ session.pluginHost.registerStatic(goalModePlugin);
350
+ session.modes.setActive(GOAL_MODE_NAME);
351
+ session.tools.register(
352
+ defineTool({
353
+ name: 'work',
354
+ description: '',
355
+ inputSchema: z.object({ step: z.number() }),
356
+ handler: () => 'ok',
357
+ }),
358
+ );
359
+
360
+ const events = await collectTurn(session, 'never finish', { maxIterations: 0 });
361
+
362
+ const cap = events.find(
363
+ (e) => e.type === 'plugin_event' && e.subtype === 'goal_max_iterations',
364
+ );
365
+ if (cap?.type !== 'plugin_event') throw new Error('expected goal_max_iterations');
366
+ // The reported cap is the clamped 1, never the misleading 0.
367
+ expect((cap.payload as { maxIterations: number }).maxIterations).toBe(1);
368
+ // …and one real iteration's worth of work actually ran first.
369
+ expect(provider.received.length).toBe(1);
370
+ });
371
+ });
372
+
373
+ describe('retryable provider errors (busy-loop guard)', () => {
374
+ // Make the back-off instant + deterministic so the bounded-retry path runs
375
+ // without real timers. Restore after every test (the seam is a module
376
+ // singleton shared process-wide).
377
+ let restore: (() => void) | undefined;
378
+ afterEach(() => {
379
+ restore?.();
380
+ restore = undefined;
381
+ });
382
+
383
+ it('bails with a fatal error after MAX_CONSECUTIVE_RETRIES instead of busy-looping the provider', async () => {
384
+ let sleeps = 0;
385
+ restore = __setRetrySleepForTests(async () => {
386
+ sleeps += 1;
387
+ });
388
+ // The provider is stuck returning retryable errors forever. Without the
389
+ // cap this would re-hit the provider up to maxIterations (150) times with
390
+ // zero spacing — exactly the unattended busy-loop the guard prevents.
391
+ const provider = new FakeProvider({
392
+ script: Array.from({ length: 50 }, () => retryableErrorReply('rate limited')),
393
+ });
394
+ const session = createFakeSession({ provider });
395
+ session.pluginHost.registerStatic(goalModePlugin);
396
+ session.modes.setActive(GOAL_MODE_NAME);
397
+
398
+ const events = await collectTurn(session, 'do the thing');
399
+
400
+ // It gave up with a fatal error mentioning the repeated retryable failure…
401
+ const fatal = events.find(
402
+ (e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('retryable error'),
403
+ );
404
+ expect(fatal).toBeDefined();
405
+ // …and it did NOT consume the whole 150-iteration cap — only the bounded
406
+ // retry budget of provider calls happened (6), so 6 errors were surfaced
407
+ // and the loop stopped well before maxIterations.
408
+ const retryableErrors = events.filter(
409
+ (e) => e.type === 'error' && e.kind === 'retryable',
410
+ );
411
+ expect(retryableErrors.length).toBe(6);
412
+ // Every retry but the last backed off (abort-aware sleep), so the provider
413
+ // was never hammered back-to-back.
414
+ expect(sleeps).toBe(5);
415
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
416
+ false,
417
+ );
418
+ });
419
+
420
+ it('resets the retry counter after a clean call, recovering from a transient blip', async () => {
421
+ let sleeps = 0;
422
+ restore = __setRetrySleepForTests(async () => {
423
+ sleeps += 1;
424
+ });
425
+ // A few retryable blips, then the provider recovers and the model finishes.
426
+ const provider = new FakeProvider({
427
+ script: [
428
+ retryableErrorReply(),
429
+ retryableErrorReply(),
430
+ toolUseReply('goal_complete', { summary: 'recovered after a blip' }, 'gc-r'),
431
+ ],
432
+ });
433
+ const session = createFakeSession({ provider });
434
+ session.pluginHost.registerStatic(goalModePlugin);
435
+ session.modes.setActive(GOAL_MODE_NAME);
436
+
437
+ const events = await collectTurn(session, 'finish despite blips');
438
+
439
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
440
+ true,
441
+ );
442
+ // It backed off on each of the two blips (2 sleeps) and never bailed fatal.
443
+ expect(sleeps).toBe(2);
444
+ expect(
445
+ events.some(
446
+ (e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('retryable'),
447
+ ),
448
+ ).toBe(false);
449
+ });
450
+
451
+ it('aborts cleanly mid back-off when the signal fires', async () => {
452
+ const ctrl = new AbortController();
453
+ // The fake sleep aborts the turn the moment the back-off begins, then
454
+ // resolves — mirroring a user cancellation while a retry was pending.
455
+ restore = __setRetrySleepForTests(async (_ms, signal) => {
456
+ ctrl.abort();
457
+ expect(signal.aborted).toBe(true);
458
+ });
459
+ const provider = new FakeProvider({
460
+ script: [retryableErrorReply(), toolUseReply('goal_complete', { summary: 'x' }, 'gc-a')],
461
+ });
462
+ const session = createFakeSession({ provider });
463
+ session.pluginHost.registerStatic(goalModePlugin);
464
+ session.modes.setActive(GOAL_MODE_NAME);
465
+
466
+ const events = await collectTurn(session, 'cancel me', { signal: ctrl.signal });
467
+
468
+ // The run stopped at an abort (not a fatal) and never completed.
469
+ expect(events.some((e) => e.type === 'abort' && e.reason.includes('back-off'))).toBe(true);
470
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
471
+ false,
472
+ );
473
+ });
474
+
475
+ it('treats an un-compactable context overflow marked retryable as fatal (no re-send loop)', async () => {
476
+ let sleeps = 0;
477
+ restore = __setRetrySleepForTests(async () => {
478
+ sleeps += 1;
479
+ });
480
+ // A context-overflow error the provider marked retryable. There is nothing
481
+ // older to compact (the fresh session's tail is the overflow), so a retry
482
+ // would just re-send the identical over-budget prompt forever. The guard
483
+ // must bail fatal instead of looping.
484
+ const provider = new FakeProvider({
485
+ script: Array.from({ length: 10 }, () => [
486
+ { type: 'message_start', model: 'fake' },
487
+ {
488
+ type: 'error',
489
+ message: 'prompt is too long: 250000 tokens > 200000 maximum context length',
490
+ retryable: true,
491
+ },
492
+ ] as ProviderEvent[]),
493
+ });
494
+ const session = createFakeSession({ provider });
495
+ session.pluginHost.registerStatic(goalModePlugin);
496
+ session.modes.setActive(GOAL_MODE_NAME);
497
+
498
+ const events = await collectTurn(session, 'overflow');
499
+
500
+ // It ended fatal without ever entering the retry back-off path.
501
+ expect(events.some((e) => e.type === 'error' && e.kind === 'fatal')).toBe(true);
502
+ expect(sleeps).toBe(0);
503
+ // The provider was hit at most twice (initial + one reactive-compaction
504
+ // retry that found nothing to compact), never the 10-deep script.
505
+ expect(provider.received.length).toBeLessThanOrEqual(2);
506
+ });
507
+ });
508
+
509
+ it('persists the budget-exhausting call assistant text before stopping', () => {
510
+ // The model produces real text on the call that blows the budget. That text
511
+ // must land in the log (source: 'model') so a resume keeps the context —
512
+ // it was silently dropped before the fix.
513
+ const hugeUsageWithText: ProviderEvent[] = [
514
+ { type: 'message_start', model: 'fake' },
515
+ { type: 'text_delta', delta: 'Here is my final analysis before stopping.' },
516
+ {
517
+ type: 'message_end',
518
+ stopReason: 'end_turn',
519
+ usage: { inputTokens: 5_000_000, outputTokens: 0 },
520
+ },
521
+ ];
522
+ const provider = new FakeProvider({ script: [hugeUsageWithText] });
523
+ const session = createFakeSession({ provider });
524
+ session.pluginHost.registerStatic(goalModePlugin);
525
+ session.modes.setActive(GOAL_MODE_NAME);
526
+
527
+ return collectTurn(session, 'expensive').then((events) => {
528
+ // The model's own text was persisted…
529
+ const modelMsg = events.find(
530
+ (e) => e.type === 'assistant_message' && e.source === 'model',
531
+ );
532
+ if (modelMsg?.type !== 'assistant_message') throw new Error('expected a model assistant_message');
533
+ expect(modelMsg.content).toContain('final analysis');
534
+ // …before the budget plugin_event (matching the reasoning ordering rule).
535
+ const modelIdx = events.indexOf(modelMsg);
536
+ const exhaustedIdx = events.findIndex(
537
+ (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
538
+ );
539
+ expect(exhaustedIdx).toBeGreaterThanOrEqual(0);
540
+ expect(modelIdx).toBeLessThan(exhaustedIdx);
541
+ });
542
+ });
543
+
544
+ it('counts cached prompt tokens toward the budget so the runaway guard trips', () => {
545
+ // Most of the prompt is served from cache (cacheRead) with a tiny live
546
+ // input. Counting input+output alone would leave totalTokens far under the
547
+ // 4M budget; including the cache fields trips it on iteration 1.
548
+ const cachedHeavyUsage: ProviderEvent[] = [
549
+ { type: 'message_start', model: 'fake' },
550
+ { type: 'text_delta', delta: 'working...' },
551
+ {
552
+ type: 'message_end',
553
+ stopReason: 'end_turn',
554
+ usage: {
555
+ inputTokens: 100,
556
+ outputTokens: 100,
557
+ cacheReadTokens: 5_000_000,
558
+ cacheCreationTokens: 0,
559
+ },
560
+ },
561
+ ];
562
+ const provider = new FakeProvider({ script: [cachedHeavyUsage] });
563
+ const session = createFakeSession({ provider });
564
+ session.pluginHost.registerStatic(goalModePlugin);
565
+ session.modes.setActive(GOAL_MODE_NAME);
566
+
567
+ return collectTurn(session, 'cached and expensive').then((events) => {
568
+ const exhausted = events.find(
569
+ (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
570
+ );
571
+ expect(exhausted).toBeDefined();
572
+ if (exhausted?.type !== 'plugin_event') throw new Error('expected goal_budget_exhausted');
573
+ // totalTokens reflects the FULL prompt (input + cacheRead + output).
574
+ expect((exhausted.payload as { totalTokens: number }).totalTokens).toBe(5_000_200);
575
+ });
576
+ });
577
+ });
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { defineMode, definePlugin } from '@moxxy/sdk';
2
+
3
+ import { GOAL_MODE_NAME } from './constants.js';
4
+ import { goalTools } from './goal-tools.js';
5
+ import { runGoalMode } from './goal-loop.js';
6
+
7
+ export { GOAL_MODE_NAME } from './constants.js';
8
+
9
+ export const goalMode = defineMode({
10
+ name: GOAL_MODE_NAME,
11
+ description: 'Autonomous goal loop: works across many turns until it calls goal_complete (tools auto-approved)',
12
+ // Goal mode auto-approves tools and keeps working unattended, so channels
13
+ // surface a persistent accent badge while it's active — the user must always
14
+ // know the agent is driving itself.
15
+ badge: { label: 'GOAL', tone: 'attention' },
16
+ run: runGoalMode,
17
+ });
18
+
19
+ export const goalModePlugin = definePlugin({
20
+ name: '@moxxy/mode-goal',
21
+ version: '0.0.0',
22
+ // The mode AND its control tools ship together: the loop watches for the
23
+ // tools' results to terminate, so they're useless apart.
24
+ modes: [goalMode],
25
+ tools: goalTools,
26
+ });
27
+
28
+ export default goalModePlugin;