@moxxy/mode-default 0.1.0 → 0.1.2

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.
@@ -13,7 +13,13 @@ export declare const DEFAULT_MODE_NAME = "default";
13
13
  * budget and is handled before this.)
14
14
  */
15
15
  export declare const MAX_CONSECUTIVE_RETRIES = 6;
16
- /** Override the retry back-off sleep (test seam). Returns a restore fn. */
16
+ /**
17
+ * Override the retry back-off sleep (test seam). Returns a restore fn that
18
+ * callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
19
+ * singleton shared process-wide, so a leaked override bleeds the fake sleep
20
+ * into every other turn/test running in the same worker (parallel subagent
21
+ * fan-out, multiple Sessions in one host). Test-only; never call from prod.
22
+ */
17
23
  export declare function __setRetrySleepForTests(fn: (ms: number, signal: AbortSignal) => Promise<void>): () => void;
18
24
  export declare function runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
19
25
  //# sourceMappingURL=turn-iterator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"turn-iterator.d.ts","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAC;AA0BzC,2EAA2E;AAC3E,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,GACrD,MAAM,IAAI,CAMZ;AAED,wBAAuB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAqNjF"}
1
+ {"version":3,"file":"turn-iterator.d.ts","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EAaL,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAYzC;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,GACrD,MAAM,IAAI,CAMZ;AAED,wBAAuB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAkQjF"}
@@ -1,4 +1,4 @@
1
- import { buildSystemPromptWithSkills, collectProviderStream, createStuckLoopDetector, emitRequestsAndDetectStuck, executeToolUses, isContextOverflowError, projectMessages, runCompactionIfNeeded, runElisionIfNeeded, usageEventFields, } from '@moxxy/sdk';
1
+ import { buildSystemPromptWithSkills, collectProviderStream, createStuckLoopDetector, emitRequestsAndDetectStuck, executeToolUses, isContextOverflowError, nextBackoffMs, projectMessages, runCompactionIfNeeded, runElisionIfNeeded, sleepWithAbort, usageEventFields, } from '@moxxy/sdk';
2
2
  export const DEFAULT_MODE_NAME = 'default';
3
3
  /**
4
4
  * Bounded back-off for a *retryable* provider error (rate-limit/429,
@@ -13,29 +13,21 @@ export const DEFAULT_MODE_NAME = 'default';
13
13
  * budget and is handled before this.)
14
14
  */
15
15
  export const MAX_CONSECUTIVE_RETRIES = 6;
16
- /** Exponential schedule, capped base*2^(attempt-1), attempt is 1-based. */
17
- function retryBackoffMs(attempt) {
18
- const base = 500;
19
- const cap = 30_000;
20
- return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
21
- }
16
+ /** Exponential back-off base/cap for the retry schedule (attempt is 1-based). */
17
+ const RETRY_BACKOFF_BASE_MS = 500;
18
+ const RETRY_BACKOFF_CAP_MS = 30_000;
22
19
  // Abort-aware sleep, injectable for tests so the back-off path runs instantly
23
- // and deterministically. Production uses a real timer that clears on abort so a
24
- // pending back-off never outlives a cancelled turn.
25
- let sleepImpl = (ms, signal) => new Promise((resolve) => {
26
- if (signal.aborted)
27
- return resolve();
28
- const timer = setTimeout(() => {
29
- signal.removeEventListener('abort', onAbort);
30
- resolve();
31
- }, ms);
32
- const onAbort = () => {
33
- clearTimeout(timer);
34
- resolve();
35
- };
36
- signal.addEventListener('abort', onAbort, { once: true });
37
- });
38
- /** Override the retry back-off sleep (test seam). Returns a restore fn. */
20
+ // and deterministically. Production delegates to the SDK's sleepWithAbort: a
21
+ // real timer that clears (and drops its abort listener) when the signal fires,
22
+ // so a pending back-off never outlives a cancelled turn.
23
+ let sleepImpl = (ms, signal) => sleepWithAbort(ms, signal);
24
+ /**
25
+ * Override the retry back-off sleep (test seam). Returns a restore fn that
26
+ * callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
27
+ * singleton shared process-wide, so a leaked override bleeds the fake sleep
28
+ * into every other turn/test running in the same worker (parallel subagent
29
+ * fan-out, multiple Sessions in one host). Test-only; never call from prod.
30
+ */
39
31
  export function __setRetrySleepForTests(fn) {
40
32
  const prev = sleepImpl;
41
33
  sleepImpl = fn;
@@ -48,7 +40,14 @@ export async function* runDefaultMode(ctx) {
48
40
  // glitch causing an infinite retry, bad prompt, etc.) — primary
49
41
  // termination signal is the stuck-loop detector, which catches the
50
42
  // common "model keeps calling the same tool" case ~10 iterations in.
51
- const maxIterations = ctx.maxIterations ?? 500;
43
+ // Coerce a caller/config-supplied bound to a positive integer; a degenerate
44
+ // value (0, negative, NaN, fractional) would otherwise make the loop never
45
+ // run and emit a misleading "exceeded maxIterations" fatal. Treat anything
46
+ // un-coercible (NaN) as the default rather than failing the turn.
47
+ const requestedMaxIterations = ctx.maxIterations;
48
+ const maxIterations = typeof requestedMaxIterations === 'number' && Number.isFinite(requestedMaxIterations)
49
+ ? Math.max(1, Math.floor(requestedMaxIterations))
50
+ : 500;
52
51
  const detector = createStuckLoopDetector();
53
52
  // Reactive-compaction budget per overflow episode. If the provider keeps
54
53
  // rejecting for context size even after compacting this many times, give up
@@ -101,6 +100,21 @@ export async function* runDefaultMode(ctx) {
101
100
  iteration,
102
101
  stablePrefixIndex,
103
102
  });
103
+ // A user cancellation WHILE the provider stream was being consumed surfaces
104
+ // as a non-retryable provider `error` ("The operation was aborted") rather
105
+ // than a clean abort — collectProviderStream catches the fetch AbortError
106
+ // and classifies it as fatal. Treat it as the cancellation it is so
107
+ // downstream channels render a 'stopped' turn, not a failed/error turn.
108
+ if (ctx.signal.aborted) {
109
+ yield await ctx.emit({
110
+ type: 'abort',
111
+ sessionId: ctx.sessionId,
112
+ turnId: ctx.turnId,
113
+ source: 'system',
114
+ reason: 'signal aborted during provider stream',
115
+ });
116
+ return;
117
+ }
104
118
  yield await ctx.emit({
105
119
  type: 'provider_response',
106
120
  sessionId: ctx.sessionId,
@@ -117,9 +131,12 @@ export async function* runDefaultMode(ctx) {
117
131
  // the auto-compact-on-overflow path.
118
132
  if (isContextOverflowError(error.message) &&
119
133
  reactiveCompactions < MAX_REACTIVE_COMPACTIONS) {
120
- reactiveCompactions += 1;
121
134
  const compacted = await runCompactionIfNeeded(ctx, { force: true });
122
135
  if (compacted) {
136
+ // Only count an attempt that actually compacted against the budget —
137
+ // a no-op (overflow lives in the un-compactable recent tail) must not
138
+ // deny a later, genuinely compactable overflow its retry.
139
+ reactiveCompactions += 1;
123
140
  yield await ctx.emit({
124
141
  type: 'error',
125
142
  sessionId: ctx.sessionId,
@@ -166,7 +183,7 @@ export async function* runDefaultMode(ctx) {
166
183
  });
167
184
  return;
168
185
  }
169
- await sleepImpl(retryBackoffMs(consecutiveRetries), ctx.signal);
186
+ await sleepImpl(nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS), ctx.signal);
170
187
  if (ctx.signal.aborted) {
171
188
  yield await ctx.emit({
172
189
  type: 'abort',
@@ -208,6 +225,21 @@ export async function* runDefaultMode(ctx) {
208
225
  if (stuck)
209
226
  return;
210
227
  if (text || stopReason === 'end_turn' || toolUses.length === 0) {
228
+ // A completion with no text, no tool uses, and a non-natural stop (e.g.
229
+ // 'max_tokens' truncated to nothing) yields a blank assistant bubble that
230
+ // silently swallows the truncation signal. Surface a retryable note so the
231
+ // user sees why the turn produced nothing, alongside the (preserved)
232
+ // empty assistant_message.
233
+ if (!text && toolUses.length === 0 && stopReason !== 'end_turn') {
234
+ yield await ctx.emit({
235
+ type: 'error',
236
+ sessionId: ctx.sessionId,
237
+ turnId: ctx.turnId,
238
+ source: 'system',
239
+ kind: 'retryable',
240
+ message: `provider returned an empty completion (stopReason: ${stopReason ?? 'unknown'})`,
241
+ });
242
+ }
211
243
  yield await ctx.emit({
212
244
  type: 'assistant_message',
213
245
  sessionId: ctx.sessionId,
@@ -1 +1 @@
1
- {"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/mode-default",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Default loop strategy (mode) for moxxy — a Claude Code-style ReAct tool-use loop. Register it into an @moxxy/core Session to give your agent its turn-by-turn reasoning + tool-calling behaviour.",
5
5
  "keywords": [
6
6
  "moxxy",
@@ -44,15 +44,15 @@
44
44
  }
45
45
  },
46
46
  "dependencies": {
47
- "@moxxy/sdk": "0.15.0"
47
+ "@moxxy/sdk": "0.15.2"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^22.10.0",
51
51
  "typescript": "^5.7.3",
52
52
  "vitest": "^2.1.8",
53
53
  "zod": "^3.24.0",
54
- "@moxxy/core": "0.3.0",
55
- "@moxxy/testing": "0.0.25",
54
+ "@moxxy/core": "0.5.2",
55
+ "@moxxy/testing": "0.0.29",
56
56
  "@moxxy/tsconfig": "0.0.0",
57
57
  "@moxxy/vitest-preset": "0.0.0"
58
58
  },
package/src/index.test.ts CHANGED
@@ -13,6 +13,23 @@ const retryableErrorReply = (message = 'rate limited (429)'): ReadonlyArray<Prov
13
13
  { type: 'message_end', stopReason: 'end_turn' },
14
14
  ];
15
15
 
16
+ /**
17
+ * A scripted reply mimicking the real provider stack when the user aborts WHILE
18
+ * the stream is being consumed: the fetch AbortError is caught and classified
19
+ * non-retryable with the canonical "operation was aborted" message.
20
+ */
21
+ const abortDuringStreamReply = (): ReadonlyArray<ProviderEvent> => [
22
+ { type: 'message_start', model: 'fake' },
23
+ { type: 'error', message: 'The operation was aborted', retryable: false },
24
+ { type: 'message_end', stopReason: 'end_turn' },
25
+ ];
26
+
27
+ /** A reply with no text, no tools, and a non-natural stop (truncated to empty). */
28
+ const emptyMaxTokensReply = (): ReadonlyArray<ProviderEvent> => [
29
+ { type: 'message_start', model: 'fake' },
30
+ { type: 'message_end', stopReason: 'max_tokens' },
31
+ ];
32
+
16
33
  const sessionWith = (provider: FakeProvider): Session => {
17
34
  const session = createFakeSession({ provider });
18
35
  session.pluginHost.registerStatic(defaultModePlugin);
@@ -357,6 +374,72 @@ describe('defaultMode end-to-end', () => {
357
374
  }
358
375
  });
359
376
 
377
+ it('emits a clean abort (not a fatal error) when aborted during the provider stream', async () => {
378
+ // Regression: a cancellation WHILE collectProviderStream is consuming the
379
+ // stream surfaces as a non-retryable provider error ("operation was
380
+ // aborted"). The loop must recognize the set abort signal and emit a clean
381
+ // `abort` event, NOT a `kind: 'fatal'` error that renders as a failed turn.
382
+ // Abort synchronously at the start of the provider call so the signal is set
383
+ // by the time collectProviderStream returns the abort-classified error.
384
+ let abort: () => void = () => {};
385
+ const provider = new FakeProvider({
386
+ script: [abortDuringStreamReply()],
387
+ onRequest: () => abort(),
388
+ });
389
+ const session = sessionWith(provider);
390
+ abort = () => session.abort('test abort mid-stream');
391
+
392
+ const events = await collectTurn(session, 'hi');
393
+ const aborted = events.find((e) => e.type === 'abort');
394
+ expect(aborted).toBeDefined();
395
+ if (aborted?.type !== 'abort') throw new Error('expected abort');
396
+ expect(aborted.reason).toMatch(/provider stream/);
397
+ // No fatal error event leaked the raw abort message.
398
+ const fatal = events.filter((e) => e.type === 'error' && e.kind === 'fatal');
399
+ expect(fatal).toHaveLength(0);
400
+ // And the raw "operation was aborted" message was never surfaced as an error.
401
+ const rawAbortError = events.filter(
402
+ (e) => e.type === 'error' && /operation was aborted/i.test(e.message),
403
+ );
404
+ expect(rawAbortError).toHaveLength(0);
405
+ });
406
+
407
+ it('coerces a degenerate maxIterations (0) to at least one iteration instead of an instant fatal', async () => {
408
+ // Regression: an unvalidated maxIterations of 0 made the loop body never run
409
+ // and emit a misleading "exceeded maxIterations" fatal. A coerced bound runs
410
+ // the turn normally.
411
+ const provider = new FakeProvider({ script: [textReply('hello')] });
412
+ const session = sessionWith(provider);
413
+
414
+ const events = await collectTurn(session, 'hi', { maxIterations: 0 });
415
+ // The single iteration ran and produced a normal assistant message.
416
+ const last = events[events.length - 1];
417
+ if (last.type !== 'assistant_message') throw new Error('expected assistant_message last');
418
+ expect(last.content).toBe('hello');
419
+ // No fatal "exceeded maxIterations" fired on the very first turn.
420
+ const fatal = events.filter((e) => e.type === 'error' && e.kind === 'fatal');
421
+ expect(fatal).toHaveLength(0);
422
+ });
423
+
424
+ it('surfaces an empty-completion note when a turn truncates to no text and no tools', async () => {
425
+ // Regression: a max_tokens completion truncated to nothing emitted only a
426
+ // blank assistant bubble. Now a retryable note explains why the turn was
427
+ // empty, alongside the (preserved) empty assistant_message.
428
+ const provider = new FakeProvider({ script: [emptyMaxTokensReply()] });
429
+ const session = sessionWith(provider);
430
+
431
+ const events = await collectTurn(session, 'hi');
432
+ const note = events.filter(
433
+ (e) => e.type === 'error' && e.kind === 'retryable' && /empty completion/i.test(e.message),
434
+ );
435
+ expect(note).toHaveLength(1);
436
+ // The empty assistant_message is still emitted (existing behavior preserved).
437
+ const assistant = events.filter((e) => e.type === 'assistant_message');
438
+ expect(assistant).toHaveLength(1);
439
+ if (assistant[0]?.type !== 'assistant_message') throw new Error('expected assistant_message');
440
+ expect(assistant[0].content).toBe('');
441
+ });
442
+
360
443
  it('gives up with a fatal error after the bounded retry count, not maxIterations', async () => {
361
444
  const delays: number[] = [];
362
445
  const restore = __setRetrySleepForTests(async (ms) => {
@@ -5,9 +5,11 @@ import {
5
5
  emitRequestsAndDetectStuck,
6
6
  executeToolUses,
7
7
  isContextOverflowError,
8
+ nextBackoffMs,
8
9
  projectMessages,
9
10
  runCompactionIfNeeded,
10
11
  runElisionIfNeeded,
12
+ sleepWithAbort,
11
13
  usageEventFields,
12
14
  type ModeContext,
13
15
  type MoxxyEvent,
@@ -30,31 +32,23 @@ export const DEFAULT_MODE_NAME = 'default';
30
32
  */
31
33
  export const MAX_CONSECUTIVE_RETRIES = 6;
32
34
 
33
- /** Exponential schedule, capped base*2^(attempt-1), attempt is 1-based. */
34
- function retryBackoffMs(attempt: number): number {
35
- const base = 500;
36
- const cap = 30_000;
37
- return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
38
- }
35
+ /** Exponential back-off base/cap for the retry schedule (attempt is 1-based). */
36
+ const RETRY_BACKOFF_BASE_MS = 500;
37
+ const RETRY_BACKOFF_CAP_MS = 30_000;
39
38
 
40
39
  // Abort-aware sleep, injectable for tests so the back-off path runs instantly
41
- // and deterministically. Production uses a real timer that clears on abort so a
42
- // pending back-off never outlives a cancelled turn.
43
- let sleepImpl = (ms: number, signal: AbortSignal): Promise<void> =>
44
- new Promise<void>((resolve) => {
45
- if (signal.aborted) return resolve();
46
- const timer = setTimeout(() => {
47
- signal.removeEventListener('abort', onAbort);
48
- resolve();
49
- }, ms);
50
- const onAbort = (): void => {
51
- clearTimeout(timer);
52
- resolve();
53
- };
54
- signal.addEventListener('abort', onAbort, { once: true });
55
- });
40
+ // and deterministically. Production delegates to the SDK's sleepWithAbort: a
41
+ // real timer that clears (and drops its abort listener) when the signal fires,
42
+ // so a pending back-off never outlives a cancelled turn.
43
+ let sleepImpl = (ms: number, signal: AbortSignal): Promise<void> => sleepWithAbort(ms, signal);
56
44
 
57
- /** Override the retry back-off sleep (test seam). Returns a restore fn. */
45
+ /**
46
+ * Override the retry back-off sleep (test seam). Returns a restore fn that
47
+ * callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
48
+ * singleton shared process-wide, so a leaked override bleeds the fake sleep
49
+ * into every other turn/test running in the same worker (parallel subagent
50
+ * fan-out, multiple Sessions in one host). Test-only; never call from prod.
51
+ */
58
52
  export function __setRetrySleepForTests(
59
53
  fn: (ms: number, signal: AbortSignal) => Promise<void>,
60
54
  ): () => void {
@@ -70,7 +64,15 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
70
64
  // glitch causing an infinite retry, bad prompt, etc.) — primary
71
65
  // termination signal is the stuck-loop detector, which catches the
72
66
  // common "model keeps calling the same tool" case ~10 iterations in.
73
- const maxIterations = ctx.maxIterations ?? 500;
67
+ // Coerce a caller/config-supplied bound to a positive integer; a degenerate
68
+ // value (0, negative, NaN, fractional) would otherwise make the loop never
69
+ // run and emit a misleading "exceeded maxIterations" fatal. Treat anything
70
+ // un-coercible (NaN) as the default rather than failing the turn.
71
+ const requestedMaxIterations = ctx.maxIterations;
72
+ const maxIterations =
73
+ typeof requestedMaxIterations === 'number' && Number.isFinite(requestedMaxIterations)
74
+ ? Math.max(1, Math.floor(requestedMaxIterations))
75
+ : 500;
74
76
  const detector = createStuckLoopDetector();
75
77
  // Reactive-compaction budget per overflow episode. If the provider keeps
76
78
  // rejecting for context size even after compacting this many times, give up
@@ -133,6 +135,22 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
133
135
  },
134
136
  );
135
137
 
138
+ // A user cancellation WHILE the provider stream was being consumed surfaces
139
+ // as a non-retryable provider `error` ("The operation was aborted") rather
140
+ // than a clean abort — collectProviderStream catches the fetch AbortError
141
+ // and classifies it as fatal. Treat it as the cancellation it is so
142
+ // downstream channels render a 'stopped' turn, not a failed/error turn.
143
+ if (ctx.signal.aborted) {
144
+ yield await ctx.emit({
145
+ type: 'abort',
146
+ sessionId: ctx.sessionId,
147
+ turnId: ctx.turnId,
148
+ source: 'system',
149
+ reason: 'signal aborted during provider stream',
150
+ });
151
+ return;
152
+ }
153
+
136
154
  yield await ctx.emit({
137
155
  type: 'provider_response',
138
156
  sessionId: ctx.sessionId,
@@ -152,9 +170,12 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
152
170
  isContextOverflowError(error.message) &&
153
171
  reactiveCompactions < MAX_REACTIVE_COMPACTIONS
154
172
  ) {
155
- reactiveCompactions += 1;
156
173
  const compacted = await runCompactionIfNeeded(ctx, { force: true });
157
174
  if (compacted) {
175
+ // Only count an attempt that actually compacted against the budget —
176
+ // a no-op (overflow lives in the un-compactable recent tail) must not
177
+ // deny a later, genuinely compactable overflow its retry.
178
+ reactiveCompactions += 1;
158
179
  yield await ctx.emit({
159
180
  type: 'error',
160
181
  sessionId: ctx.sessionId,
@@ -202,7 +223,10 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
202
223
  });
203
224
  return;
204
225
  }
205
- await sleepImpl(retryBackoffMs(consecutiveRetries), ctx.signal);
226
+ await sleepImpl(
227
+ nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS),
228
+ ctx.signal,
229
+ );
206
230
  if (ctx.signal.aborted) {
207
231
  yield await ctx.emit({
208
232
  type: 'abort',
@@ -247,6 +271,21 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
247
271
  if (stuck) return;
248
272
 
249
273
  if (text || stopReason === 'end_turn' || toolUses.length === 0) {
274
+ // A completion with no text, no tool uses, and a non-natural stop (e.g.
275
+ // 'max_tokens' truncated to nothing) yields a blank assistant bubble that
276
+ // silently swallows the truncation signal. Surface a retryable note so the
277
+ // user sees why the turn produced nothing, alongside the (preserved)
278
+ // empty assistant_message.
279
+ if (!text && toolUses.length === 0 && stopReason !== 'end_turn') {
280
+ yield await ctx.emit({
281
+ type: 'error',
282
+ sessionId: ctx.sessionId,
283
+ turnId: ctx.turnId,
284
+ source: 'system',
285
+ kind: 'retryable',
286
+ message: `provider returned an empty completion (stopReason: ${stopReason ?? 'unknown'})`,
287
+ });
288
+ }
250
289
  yield await ctx.emit({
251
290
  type: 'assistant_message',
252
291
  sessionId: ctx.sessionId,