groove-dev 0.27.179 → 0.27.181

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.
@@ -1,7 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { randomUUID } from 'node:crypto';
4
- import { CHUNK_SIZE } from '../shared/constants.js';
4
+ import { CHUNK_SIZE, MAX_STEP_CONTENT_CHARS, MAX_TOKEN_COUNT } from '../shared/constants.js';
5
+
6
+ function estimateTokens(text) {
7
+ if (!text) return 0;
8
+ return Math.ceil(text.length / 4);
9
+ }
5
10
 
6
11
  export class EnvelopeBuilder {
7
12
  constructor(sessionId, contributorId, metadata) {
@@ -13,11 +18,23 @@ export class EnvelopeBuilder {
13
18
  }
14
19
 
15
20
  addStep(step) {
16
- if (step.content && typeof step.content === 'string' && step.content.length > 10_000) {
17
- step.content = step.content.slice(0, 10_000);
21
+ // Last-resort trim so the envelope passes ingest validation (an oversized
22
+ // step.content is rejected, which drops the whole session). Parsers already
23
+ // truncate observations to OBSERVATION_TOKEN_LIMIT and flag it; this only
24
+ // fires for content that slipped past them. When it does fire it MUST record
25
+ // the loss — a silent trim looks like complete data to downstream training.
26
+ if (step.content && typeof step.content === 'string' && step.content.length > MAX_STEP_CONTENT_CHARS) {
27
+ const originalTokens = estimateTokens(step.content);
28
+ step.content = step.content.slice(0, MAX_STEP_CONTENT_CHARS);
29
+ step.truncated = true;
30
+ // Preserve the parser's count if it already trimmed — that one reflects
31
+ // the true original size, ours only sees what survived the first pass.
32
+ if (typeof step.original_token_count !== 'number') {
33
+ step.original_token_count = Math.min(originalTokens, MAX_TOKEN_COUNT);
34
+ }
18
35
  }
19
- if (typeof step.token_count === 'number' && step.token_count > 100_000) {
20
- step.token_count = 100_000;
36
+ if (typeof step.token_count === 'number' && step.token_count > MAX_TOKEN_COUNT) {
37
+ step.token_count = MAX_TOKEN_COUNT;
21
38
  }
22
39
  this._buffer.push(step);
23
40
  if (this._buffer.length >= CHUNK_SIZE) {
@@ -28,7 +28,17 @@ export const QUALITY_MULTIPLIERS = {
28
28
  highQuality: 1.5,
29
29
  };
30
30
 
31
- export const OBSERVATION_TOKEN_LIMIT = 4096;
31
+ // Hard ceiling on step.content, shared by the client trimmer (envelope-builder)
32
+ // and the ingest validator (envelope-schema). These MUST stay equal: the
33
+ // validator rejects the whole envelope when exceeded, which drops the entire
34
+ // session — so the client must trim to exactly this before building.
35
+ export const MAX_STEP_CONTENT_CHARS = 100_000;
36
+ export const MAX_TOKEN_COUNT = 100_000;
37
+
38
+ // Per-observation budget, in estimated tokens (~4 chars/token). Kept safely
39
+ // under MAX_STEP_CONTENT_CHARS so a parser-truncated observation plus its
40
+ // "[TRUNCATED …]" suffix still fits without a second trim downstream.
41
+ export const OBSERVATION_TOKEN_LIMIT = 24_000;
32
42
  export const TIER_A_MIN_QUALITY = 70;
33
43
  export const TIER_B_MIN_QUALITY = 50;
34
44
  export const TRAINING_MIN_STEPS = 5;
@@ -1,6 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
- import { SUPPORTED_PROVIDERS, MODEL_TIERS, TRAINING_EXCLUSION_REASONS } from './constants.js';
3
+ import {
4
+ SUPPORTED_PROVIDERS,
5
+ MODEL_TIERS,
6
+ TRAINING_EXCLUSION_REASONS,
7
+ MAX_STEP_CONTENT_CHARS,
8
+ MAX_TOKEN_COUNT,
9
+ } from './constants.js';
4
10
 
5
11
  export const STEP_TYPES = ['thought', 'action', 'observation', 'correction', 'resolution', 'error', 'coordination', 'edit', 'instruction', 'clarification', 'approval', 'delegate', 'yield'];
6
12
  const VALID_QUALITY_TIERS = ['TIER_A', 'TIER_B', 'TIER_C'];
@@ -10,8 +16,10 @@ const VALID_MODEL_ENGINES = Object.keys(MODEL_TIERS);
10
16
  const VALID_COMPLEXITIES = ['light', 'medium', 'heavy'];
11
17
  const VALID_OUTCOME_STATUSES = ['SUCCESS', 'CRASH', 'KILLED'];
12
18
  const MAX_STEPS_PER_ENVELOPE = 500;
13
- const MAX_STEP_CONTENT_LENGTH = 10_000;
14
- const MAX_TOKEN_COUNT = 100_000;
19
+ // Sourced from shared constants — the client trims to the same value before
20
+ // building envelopes. Keeping one definition prevents the drift that silently
21
+ // truncated observations at 10k while the validator allowed more.
22
+ const MAX_STEP_CONTENT_LENGTH = MAX_STEP_CONTENT_CHARS;
15
23
  const MAX_STEP_NUMBER = 50_000;
16
24
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
17
25
  const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { EnvelopeBuilder } from '../../client/envelope-builder.js';
6
- import { CHUNK_SIZE } from '../../shared/constants.js';
6
+ import { CHUNK_SIZE, MAX_STEP_CONTENT_CHARS } from '../../shared/constants.js';
7
7
 
8
8
  const metadata = {
9
9
  model_engine: 'claude-opus-4-6',
@@ -74,12 +74,48 @@ describe('EnvelopeBuilder', () => {
74
74
  assert.deepEqual(close.outcome, outcome);
75
75
  });
76
76
 
77
- it('truncates step content at 10000 characters', () => {
77
+ it('leaves content under the ingest ceiling untouched', () => {
78
78
  const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
79
- const longContent = 'x'.repeat(15_000);
80
- builder.addStep({ step: 1, type: 'thought', timestamp: 123, content: longContent });
81
- const envelope = builder.flush();
82
- assert.equal(envelope.trajectory_log[0].content.length, 10_000);
79
+ builder.addStep({ step: 1, type: 'thought', timestamp: 123, content: 'x'.repeat(15_000) });
80
+ const step = builder.flush().trajectory_log[0];
81
+ assert.equal(step.content.length, 15_000);
82
+ assert.ok(!step.truncated);
83
+ });
84
+
85
+ it('truncates step content at MAX_STEP_CONTENT_CHARS', () => {
86
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
87
+ builder.addStep({ step: 1, type: 'thought', timestamp: 123, content: 'x'.repeat(150_000) });
88
+ const step = builder.flush().trajectory_log[0];
89
+ assert.equal(step.content.length, MAX_STEP_CONTENT_CHARS);
90
+ });
91
+
92
+ // Regression: a trim that doesn't flag itself looks like complete data to
93
+ // downstream training and silently corrupts the corpus.
94
+ it('never truncates silently — always sets truncated + original_token_count', () => {
95
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
96
+ builder.addStep({ step: 1, type: 'observation', timestamp: 123, content: 'x'.repeat(150_000) });
97
+ const step = builder.flush().trajectory_log[0];
98
+ assert.equal(step.truncated, true);
99
+ assert.equal(step.original_token_count, 37_500); // 150k chars / 4
100
+ });
101
+
102
+ it('preserves a parser-supplied original_token_count when trimming again', () => {
103
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
104
+ builder.addStep({
105
+ step: 1, type: 'observation', timestamp: 123,
106
+ content: 'x'.repeat(150_000), truncated: true, original_token_count: 99_000,
107
+ });
108
+ const step = builder.flush().trajectory_log[0];
109
+ // The parser saw the true original; our count only sees what survived it.
110
+ assert.equal(step.original_token_count, 99_000);
111
+ });
112
+
113
+ it('output always satisfies the ingest validator', () => {
114
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
115
+ builder.addStep({ step: 1, type: 'observation', timestamp: 123, content: 'x'.repeat(500_000) });
116
+ const step = builder.flush().trajectory_log[0];
117
+ assert.ok(step.content.length <= MAX_STEP_CONTENT_CHARS,
118
+ 'client must trim to the validator ceiling or the whole session is rejected');
83
119
  });
84
120
 
85
121
  it('caps token_count at 100000', () => {
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { validateEnvelope, STEP_TYPES } from '../../shared/envelope-schema.js';
6
- import { TRAINING_EXCLUSION_REASONS } from '../../shared/constants.js';
6
+ import { TRAINING_EXCLUSION_REASONS, MAX_STEP_CONTENT_CHARS } from '../../shared/constants.js';
7
7
 
8
8
  const VALID_HMAC = 'a'.repeat(64);
9
9
  const VALID_APP_HASH = 'b'.repeat(64);
@@ -113,12 +113,19 @@ describe('envelope-schema', () => {
113
113
  assert.ok(result.errors.some(e => e.includes('500')));
114
114
  });
115
115
 
116
- it('rejects step with content > 10KB', () => {
116
+ it('accepts step content up to MAX_STEP_CONTENT_CHARS', () => {
117
117
  const env = validEnvelope();
118
- env.trajectory_log[0].content = 'x'.repeat(10_001);
118
+ env.trajectory_log[0].content = 'x'.repeat(MAX_STEP_CONTENT_CHARS);
119
+ const result = validateEnvelope(env);
120
+ assert.ok(!result.errors.some(e => e.includes('step.content exceeds')));
121
+ });
122
+
123
+ it('rejects step content over MAX_STEP_CONTENT_CHARS', () => {
124
+ const env = validEnvelope();
125
+ env.trajectory_log[0].content = 'x'.repeat(MAX_STEP_CONTENT_CHARS + 1);
119
126
  const result = validateEnvelope(env);
120
127
  assert.equal(result.valid, false);
121
- assert.ok(result.errors.some(e => e.includes('10000')));
128
+ assert.ok(result.errors.some(e => e.includes(String(MAX_STEP_CONTENT_CHARS))));
122
129
  });
123
130
 
124
131
  it('rejects step with token_count > 100,000', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -363,6 +363,14 @@ export class ProcessManager {
363
363
  this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
364
364
  this._exitHandled = new Set();
365
365
  this._resultReceived = new Set();
366
+ // In-flight user messages, kept until a turn actually completes so a
367
+ // dropped turn can be re-issued instead of losing the user's message.
368
+ // agentId -> { message, attempts }
369
+ this._pendingUserMessage = new Map();
370
+ // Retry count carried across the agent-id change that resume() performs.
371
+ // Kept separate so teardown of the old agent can't reset it to 0 and
372
+ // turn a bounded retry into an infinite respawn loop. oldAgentId -> attempts
373
+ this._retryAttemptCarry = new Map();
366
374
  this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
367
375
  this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
368
376
  this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
@@ -1559,6 +1567,18 @@ For normal file edits within your scope, proceed without review.
1559
1567
 
1560
1568
  // Session result data (cost, duration, turns)
1561
1569
  if (output.type === 'result') {
1570
+ // Phantom-interrupt abort: on --resume with an orphaned background shell
1571
+ // task, the CLI injects a synthetic "[Request interrupted by user]" that
1572
+ // aborts the turn before the model is ever called. Fingerprint is an
1573
+ // error result that never reached the API (apiDurationMs === 0, so zero
1574
+ // cost). Retrying is free and succeeds ~94% of the time — treating it as
1575
+ // a normal result is what made user messages vanish silently.
1576
+ if (output.isError && output.apiDurationMs === 0) {
1577
+ if (this._retryDroppedTurn(agentId, agent, output)) return;
1578
+ }
1579
+ // A turn completed for real — the user's message is safely delivered.
1580
+ this._pendingUserMessage.delete(agentId);
1581
+
1562
1582
  tokens.recordResult(agentId, {
1563
1583
  costUsd: output.cost, durationMs: output.duration, turns: output.turns,
1564
1584
  });
@@ -2510,6 +2530,59 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2510
2530
  * spawns fresh with the full conversation thread instead of --resume.
2511
2531
  * This avoids degraded context from internal compaction.
2512
2532
  */
2533
+ /**
2534
+ * Re-issue a user message whose turn was aborted before reaching the model.
2535
+ *
2536
+ * Returns true if a retry was scheduled (caller should skip normal result
2537
+ * handling), false if the turn should be treated as a real result.
2538
+ */
2539
+ _retryDroppedTurn(agentId, agent, output) {
2540
+ const pending = this._pendingUserMessage.get(agentId);
2541
+ if (!pending) return false; // nothing to re-issue — let it fall through
2542
+
2543
+ const MAX_RETRIES = 2;
2544
+ if (pending.attempts >= MAX_RETRIES) {
2545
+ this._pendingUserMessage.delete(agentId);
2546
+ this.daemon.broadcast({
2547
+ type: 'agent:output',
2548
+ agentId,
2549
+ data: {
2550
+ type: 'activity',
2551
+ subtype: 'error',
2552
+ data: `Message could not be delivered after ${MAX_RETRIES + 1} attempts `
2553
+ + `(${output.terminalReason || output.subtype || 'turn aborted'}). Please re-send.`,
2554
+ },
2555
+ });
2556
+ return false;
2557
+ }
2558
+
2559
+ pending.attempts += 1;
2560
+ this._retryAttemptCarry.set(agentId, pending.attempts);
2561
+ this.daemon.broadcast({
2562
+ type: 'agent:output',
2563
+ agentId,
2564
+ data: {
2565
+ type: 'activity',
2566
+ subtype: 'info',
2567
+ data: `Turn aborted before reaching the model — retrying (${pending.attempts}/${MAX_RETRIES})…`,
2568
+ },
2569
+ });
2570
+
2571
+ // Let the aborted process finish tearing down before re-spawning; resume()
2572
+ // kills the current handle and re-registers under a new agent id.
2573
+ setTimeout(() => {
2574
+ this.resume(agentId, pending.message).catch((err) => {
2575
+ this.daemon.broadcast({
2576
+ type: 'agent:output',
2577
+ agentId,
2578
+ data: { type: 'activity', subtype: 'error', data: `Retry failed: ${err.message}` },
2579
+ });
2580
+ });
2581
+ }, 500);
2582
+
2583
+ return true;
2584
+ }
2585
+
2513
2586
  async resume(agentId, message) {
2514
2587
  const { registry, locks } = this.daemon;
2515
2588
  const agent = registry.get(agentId);
@@ -2577,8 +2650,19 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2577
2650
  workingDir: config.workingDir || this.daemon.config?.defaultWorkingDir || undefined,
2578
2651
  name: config.name,
2579
2652
  teamId: config.teamId,
2653
+ authMode: config.authMode,
2580
2654
  });
2581
2655
 
2656
+ // Hold the message until a turn actually completes, so a turn aborted
2657
+ // before reaching the model can be re-issued rather than lost. Carries the
2658
+ // attempt count forward from the agent id we're replacing.
2659
+ if (message) {
2660
+ const carried = this._retryAttemptCarry.get(agentId);
2661
+ this._retryAttemptCarry.delete(agentId);
2662
+ this._pendingUserMessage.delete(agentId);
2663
+ this._pendingUserMessage.set(newAgent.id, { message, attempts: carried || 0 });
2664
+ }
2665
+
2582
2666
  // Carry cumulative tokens
2583
2667
  if (config.tokensUsed > 0) {
2584
2668
  registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
@@ -2959,6 +3043,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2959
3043
  if (throttle?.timer) clearTimeout(throttle.timer);
2960
3044
  this._streamThrottle.delete(agentId);
2961
3045
  this.pendingMessages.delete(agentId);
3046
+ // Safe during a retry: resume() re-registers the message under the new
3047
+ // agent id from its own `message` argument, and the attempt count lives in
3048
+ // _retryAttemptCarry (which kill() must not touch).
3049
+ this._pendingUserMessage.delete(agentId);
2962
3050
 
2963
3051
  // Unregister ambassador if this agent was one
2964
3052
  const agent = this.daemon.registry.get(agentId);
@@ -257,6 +257,13 @@ export class ClaudeCodeProvider extends Provider {
257
257
  cost: data.total_cost_usd,
258
258
  duration: data.duration_ms,
259
259
  turns: data.num_turns,
260
+ // Error/abort signals. Without these an `error_during_execution`
261
+ // result looks identical to a successful one and the user's
262
+ // message is silently dropped.
263
+ subtype: data.subtype,
264
+ isError: data.is_error === true || data.subtype === 'error_during_execution',
265
+ apiDurationMs: data.duration_api_ms,
266
+ terminalReason: data.terminal_reason,
260
267
  });
261
268
  }
262
269
  } catch {
@@ -0,0 +1,76 @@
1
+ // GROOVE — Claude Code Provider Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { ClaudeCodeProvider } from '../src/providers/claude-code.js';
7
+
8
+ const provider = new ClaudeCodeProvider();
9
+
10
+ describe('ClaudeCodeProvider result parsing', () => {
11
+ // Regression: the "phantom interrupt" bug. On --resume with an orphaned
12
+ // background shell task, the CLI aborts the turn before calling the model.
13
+ // These fields were previously dropped, so the abort was indistinguishable
14
+ // from a successful turn and the user's message vanished with no UI signal.
15
+ it('surfaces error signals from an aborted turn', () => {
16
+ const out = provider.parseOutput(JSON.stringify({
17
+ type: 'result',
18
+ subtype: 'error_during_execution',
19
+ is_error: true,
20
+ duration_ms: 5126,
21
+ duration_api_ms: 0,
22
+ num_turns: 2,
23
+ total_cost_usd: 0,
24
+ terminal_reason: 'aborted_streaming',
25
+ }));
26
+
27
+ assert.equal(out.type, 'result');
28
+ assert.equal(out.isError, true);
29
+ assert.equal(out.apiDurationMs, 0, 'api duration 0 proves the model was never reached');
30
+ assert.equal(out.terminalReason, 'aborted_streaming');
31
+ });
32
+
33
+ it('treats an error_during_execution subtype as an error even without is_error', () => {
34
+ const out = provider.parseOutput(JSON.stringify({
35
+ type: 'result', subtype: 'error_during_execution', duration_api_ms: 0,
36
+ }));
37
+ assert.equal(out.isError, true);
38
+ });
39
+
40
+ it('does not flag a successful turn as an error', () => {
41
+ const out = provider.parseOutput(JSON.stringify({
42
+ type: 'result',
43
+ subtype: 'success',
44
+ is_error: false,
45
+ duration_ms: 17850,
46
+ duration_api_ms: 17672,
47
+ num_turns: 3,
48
+ total_cost_usd: 0.42,
49
+ terminal_reason: 'completed',
50
+ }));
51
+
52
+ assert.equal(out.isError, false);
53
+ assert.equal(out.apiDurationMs, 17672);
54
+ assert.equal(out.cost, 0.42);
55
+ });
56
+
57
+ // The retry gate is (isError && apiDurationMs === 0). A turn that failed
58
+ // *after* reaching the model has real cost and must not be auto-retried.
59
+ it('distinguishes a pre-model abort from a post-model failure', () => {
60
+ const preModel = provider.parseOutput(JSON.stringify({
61
+ type: 'result', subtype: 'error_during_execution', is_error: true, duration_api_ms: 0,
62
+ }));
63
+ const postModel = provider.parseOutput(JSON.stringify({
64
+ type: 'result', subtype: 'error_during_execution', is_error: true, duration_api_ms: 8400,
65
+ }));
66
+
67
+ assert.equal(preModel.isError && preModel.apiDurationMs === 0, true, 'free to retry');
68
+ assert.equal(postModel.isError && postModel.apiDurationMs === 0, false, 'already billed — do not retry');
69
+ });
70
+ });
71
+
72
+ describe('ClaudeCodeProvider models', () => {
73
+ it('offers Fable 5', () => {
74
+ assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5'));
75
+ });
76
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,7 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { randomUUID } from 'node:crypto';
4
- import { CHUNK_SIZE } from '../shared/constants.js';
4
+ import { CHUNK_SIZE, MAX_STEP_CONTENT_CHARS, MAX_TOKEN_COUNT } from '../shared/constants.js';
5
+
6
+ function estimateTokens(text) {
7
+ if (!text) return 0;
8
+ return Math.ceil(text.length / 4);
9
+ }
5
10
 
6
11
  export class EnvelopeBuilder {
7
12
  constructor(sessionId, contributorId, metadata) {
@@ -13,11 +18,23 @@ export class EnvelopeBuilder {
13
18
  }
14
19
 
15
20
  addStep(step) {
16
- if (step.content && typeof step.content === 'string' && step.content.length > 10_000) {
17
- step.content = step.content.slice(0, 10_000);
21
+ // Last-resort trim so the envelope passes ingest validation (an oversized
22
+ // step.content is rejected, which drops the whole session). Parsers already
23
+ // truncate observations to OBSERVATION_TOKEN_LIMIT and flag it; this only
24
+ // fires for content that slipped past them. When it does fire it MUST record
25
+ // the loss — a silent trim looks like complete data to downstream training.
26
+ if (step.content && typeof step.content === 'string' && step.content.length > MAX_STEP_CONTENT_CHARS) {
27
+ const originalTokens = estimateTokens(step.content);
28
+ step.content = step.content.slice(0, MAX_STEP_CONTENT_CHARS);
29
+ step.truncated = true;
30
+ // Preserve the parser's count if it already trimmed — that one reflects
31
+ // the true original size, ours only sees what survived the first pass.
32
+ if (typeof step.original_token_count !== 'number') {
33
+ step.original_token_count = Math.min(originalTokens, MAX_TOKEN_COUNT);
34
+ }
18
35
  }
19
- if (typeof step.token_count === 'number' && step.token_count > 100_000) {
20
- step.token_count = 100_000;
36
+ if (typeof step.token_count === 'number' && step.token_count > MAX_TOKEN_COUNT) {
37
+ step.token_count = MAX_TOKEN_COUNT;
21
38
  }
22
39
  this._buffer.push(step);
23
40
  if (this._buffer.length >= CHUNK_SIZE) {
@@ -28,7 +28,17 @@ export const QUALITY_MULTIPLIERS = {
28
28
  highQuality: 1.5,
29
29
  };
30
30
 
31
- export const OBSERVATION_TOKEN_LIMIT = 4096;
31
+ // Hard ceiling on step.content, shared by the client trimmer (envelope-builder)
32
+ // and the ingest validator (envelope-schema). These MUST stay equal: the
33
+ // validator rejects the whole envelope when exceeded, which drops the entire
34
+ // session — so the client must trim to exactly this before building.
35
+ export const MAX_STEP_CONTENT_CHARS = 100_000;
36
+ export const MAX_TOKEN_COUNT = 100_000;
37
+
38
+ // Per-observation budget, in estimated tokens (~4 chars/token). Kept safely
39
+ // under MAX_STEP_CONTENT_CHARS so a parser-truncated observation plus its
40
+ // "[TRUNCATED …]" suffix still fits without a second trim downstream.
41
+ export const OBSERVATION_TOKEN_LIMIT = 24_000;
32
42
  export const TIER_A_MIN_QUALITY = 70;
33
43
  export const TIER_B_MIN_QUALITY = 50;
34
44
  export const TRAINING_MIN_STEPS = 5;
@@ -1,6 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
- import { SUPPORTED_PROVIDERS, MODEL_TIERS, TRAINING_EXCLUSION_REASONS } from './constants.js';
3
+ import {
4
+ SUPPORTED_PROVIDERS,
5
+ MODEL_TIERS,
6
+ TRAINING_EXCLUSION_REASONS,
7
+ MAX_STEP_CONTENT_CHARS,
8
+ MAX_TOKEN_COUNT,
9
+ } from './constants.js';
4
10
 
5
11
  export const STEP_TYPES = ['thought', 'action', 'observation', 'correction', 'resolution', 'error', 'coordination', 'edit', 'instruction', 'clarification', 'approval', 'delegate', 'yield'];
6
12
  const VALID_QUALITY_TIERS = ['TIER_A', 'TIER_B', 'TIER_C'];
@@ -10,8 +16,10 @@ const VALID_MODEL_ENGINES = Object.keys(MODEL_TIERS);
10
16
  const VALID_COMPLEXITIES = ['light', 'medium', 'heavy'];
11
17
  const VALID_OUTCOME_STATUSES = ['SUCCESS', 'CRASH', 'KILLED'];
12
18
  const MAX_STEPS_PER_ENVELOPE = 500;
13
- const MAX_STEP_CONTENT_LENGTH = 10_000;
14
- const MAX_TOKEN_COUNT = 100_000;
19
+ // Sourced from shared constants — the client trims to the same value before
20
+ // building envelopes. Keeping one definition prevents the drift that silently
21
+ // truncated observations at 10k while the validator allowed more.
22
+ const MAX_STEP_CONTENT_LENGTH = MAX_STEP_CONTENT_CHARS;
15
23
  const MAX_STEP_NUMBER = 50_000;
16
24
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
17
25
  const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { EnvelopeBuilder } from '../../client/envelope-builder.js';
6
- import { CHUNK_SIZE } from '../../shared/constants.js';
6
+ import { CHUNK_SIZE, MAX_STEP_CONTENT_CHARS } from '../../shared/constants.js';
7
7
 
8
8
  const metadata = {
9
9
  model_engine: 'claude-opus-4-6',
@@ -74,12 +74,48 @@ describe('EnvelopeBuilder', () => {
74
74
  assert.deepEqual(close.outcome, outcome);
75
75
  });
76
76
 
77
- it('truncates step content at 10000 characters', () => {
77
+ it('leaves content under the ingest ceiling untouched', () => {
78
78
  const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
79
- const longContent = 'x'.repeat(15_000);
80
- builder.addStep({ step: 1, type: 'thought', timestamp: 123, content: longContent });
81
- const envelope = builder.flush();
82
- assert.equal(envelope.trajectory_log[0].content.length, 10_000);
79
+ builder.addStep({ step: 1, type: 'thought', timestamp: 123, content: 'x'.repeat(15_000) });
80
+ const step = builder.flush().trajectory_log[0];
81
+ assert.equal(step.content.length, 15_000);
82
+ assert.ok(!step.truncated);
83
+ });
84
+
85
+ it('truncates step content at MAX_STEP_CONTENT_CHARS', () => {
86
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
87
+ builder.addStep({ step: 1, type: 'thought', timestamp: 123, content: 'x'.repeat(150_000) });
88
+ const step = builder.flush().trajectory_log[0];
89
+ assert.equal(step.content.length, MAX_STEP_CONTENT_CHARS);
90
+ });
91
+
92
+ // Regression: a trim that doesn't flag itself looks like complete data to
93
+ // downstream training and silently corrupts the corpus.
94
+ it('never truncates silently — always sets truncated + original_token_count', () => {
95
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
96
+ builder.addStep({ step: 1, type: 'observation', timestamp: 123, content: 'x'.repeat(150_000) });
97
+ const step = builder.flush().trajectory_log[0];
98
+ assert.equal(step.truncated, true);
99
+ assert.equal(step.original_token_count, 37_500); // 150k chars / 4
100
+ });
101
+
102
+ it('preserves a parser-supplied original_token_count when trimming again', () => {
103
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
104
+ builder.addStep({
105
+ step: 1, type: 'observation', timestamp: 123,
106
+ content: 'x'.repeat(150_000), truncated: true, original_token_count: 99_000,
107
+ });
108
+ const step = builder.flush().trajectory_log[0];
109
+ // The parser saw the true original; our count only sees what survived it.
110
+ assert.equal(step.original_token_count, 99_000);
111
+ });
112
+
113
+ it('output always satisfies the ingest validator', () => {
114
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
115
+ builder.addStep({ step: 1, type: 'observation', timestamp: 123, content: 'x'.repeat(500_000) });
116
+ const step = builder.flush().trajectory_log[0];
117
+ assert.ok(step.content.length <= MAX_STEP_CONTENT_CHARS,
118
+ 'client must trim to the validator ceiling or the whole session is rejected');
83
119
  });
84
120
 
85
121
  it('caps token_count at 100000', () => {
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { validateEnvelope, STEP_TYPES } from '../../shared/envelope-schema.js';
6
- import { TRAINING_EXCLUSION_REASONS } from '../../shared/constants.js';
6
+ import { TRAINING_EXCLUSION_REASONS, MAX_STEP_CONTENT_CHARS } from '../../shared/constants.js';
7
7
 
8
8
  const VALID_HMAC = 'a'.repeat(64);
9
9
  const VALID_APP_HASH = 'b'.repeat(64);
@@ -113,12 +113,19 @@ describe('envelope-schema', () => {
113
113
  assert.ok(result.errors.some(e => e.includes('500')));
114
114
  });
115
115
 
116
- it('rejects step with content > 10KB', () => {
116
+ it('accepts step content up to MAX_STEP_CONTENT_CHARS', () => {
117
117
  const env = validEnvelope();
118
- env.trajectory_log[0].content = 'x'.repeat(10_001);
118
+ env.trajectory_log[0].content = 'x'.repeat(MAX_STEP_CONTENT_CHARS);
119
+ const result = validateEnvelope(env);
120
+ assert.ok(!result.errors.some(e => e.includes('step.content exceeds')));
121
+ });
122
+
123
+ it('rejects step content over MAX_STEP_CONTENT_CHARS', () => {
124
+ const env = validEnvelope();
125
+ env.trajectory_log[0].content = 'x'.repeat(MAX_STEP_CONTENT_CHARS + 1);
119
126
  const result = validateEnvelope(env);
120
127
  assert.equal(result.valid, false);
121
- assert.ok(result.errors.some(e => e.includes('10000')));
128
+ assert.ok(result.errors.some(e => e.includes(String(MAX_STEP_CONTENT_CHARS))));
122
129
  });
123
130
 
124
131
  it('rejects step with token_count > 100,000', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -363,6 +363,14 @@ export class ProcessManager {
363
363
  this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
364
364
  this._exitHandled = new Set();
365
365
  this._resultReceived = new Set();
366
+ // In-flight user messages, kept until a turn actually completes so a
367
+ // dropped turn can be re-issued instead of losing the user's message.
368
+ // agentId -> { message, attempts }
369
+ this._pendingUserMessage = new Map();
370
+ // Retry count carried across the agent-id change that resume() performs.
371
+ // Kept separate so teardown of the old agent can't reset it to 0 and
372
+ // turn a bounded retry into an infinite respawn loop. oldAgentId -> attempts
373
+ this._retryAttemptCarry = new Map();
366
374
  this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
367
375
  this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
368
376
  this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
@@ -1559,6 +1567,18 @@ For normal file edits within your scope, proceed without review.
1559
1567
 
1560
1568
  // Session result data (cost, duration, turns)
1561
1569
  if (output.type === 'result') {
1570
+ // Phantom-interrupt abort: on --resume with an orphaned background shell
1571
+ // task, the CLI injects a synthetic "[Request interrupted by user]" that
1572
+ // aborts the turn before the model is ever called. Fingerprint is an
1573
+ // error result that never reached the API (apiDurationMs === 0, so zero
1574
+ // cost). Retrying is free and succeeds ~94% of the time — treating it as
1575
+ // a normal result is what made user messages vanish silently.
1576
+ if (output.isError && output.apiDurationMs === 0) {
1577
+ if (this._retryDroppedTurn(agentId, agent, output)) return;
1578
+ }
1579
+ // A turn completed for real — the user's message is safely delivered.
1580
+ this._pendingUserMessage.delete(agentId);
1581
+
1562
1582
  tokens.recordResult(agentId, {
1563
1583
  costUsd: output.cost, durationMs: output.duration, turns: output.turns,
1564
1584
  });
@@ -2510,6 +2530,59 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2510
2530
  * spawns fresh with the full conversation thread instead of --resume.
2511
2531
  * This avoids degraded context from internal compaction.
2512
2532
  */
2533
+ /**
2534
+ * Re-issue a user message whose turn was aborted before reaching the model.
2535
+ *
2536
+ * Returns true if a retry was scheduled (caller should skip normal result
2537
+ * handling), false if the turn should be treated as a real result.
2538
+ */
2539
+ _retryDroppedTurn(agentId, agent, output) {
2540
+ const pending = this._pendingUserMessage.get(agentId);
2541
+ if (!pending) return false; // nothing to re-issue — let it fall through
2542
+
2543
+ const MAX_RETRIES = 2;
2544
+ if (pending.attempts >= MAX_RETRIES) {
2545
+ this._pendingUserMessage.delete(agentId);
2546
+ this.daemon.broadcast({
2547
+ type: 'agent:output',
2548
+ agentId,
2549
+ data: {
2550
+ type: 'activity',
2551
+ subtype: 'error',
2552
+ data: `Message could not be delivered after ${MAX_RETRIES + 1} attempts `
2553
+ + `(${output.terminalReason || output.subtype || 'turn aborted'}). Please re-send.`,
2554
+ },
2555
+ });
2556
+ return false;
2557
+ }
2558
+
2559
+ pending.attempts += 1;
2560
+ this._retryAttemptCarry.set(agentId, pending.attempts);
2561
+ this.daemon.broadcast({
2562
+ type: 'agent:output',
2563
+ agentId,
2564
+ data: {
2565
+ type: 'activity',
2566
+ subtype: 'info',
2567
+ data: `Turn aborted before reaching the model — retrying (${pending.attempts}/${MAX_RETRIES})…`,
2568
+ },
2569
+ });
2570
+
2571
+ // Let the aborted process finish tearing down before re-spawning; resume()
2572
+ // kills the current handle and re-registers under a new agent id.
2573
+ setTimeout(() => {
2574
+ this.resume(agentId, pending.message).catch((err) => {
2575
+ this.daemon.broadcast({
2576
+ type: 'agent:output',
2577
+ agentId,
2578
+ data: { type: 'activity', subtype: 'error', data: `Retry failed: ${err.message}` },
2579
+ });
2580
+ });
2581
+ }, 500);
2582
+
2583
+ return true;
2584
+ }
2585
+
2513
2586
  async resume(agentId, message) {
2514
2587
  const { registry, locks } = this.daemon;
2515
2588
  const agent = registry.get(agentId);
@@ -2577,8 +2650,19 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2577
2650
  workingDir: config.workingDir || this.daemon.config?.defaultWorkingDir || undefined,
2578
2651
  name: config.name,
2579
2652
  teamId: config.teamId,
2653
+ authMode: config.authMode,
2580
2654
  });
2581
2655
 
2656
+ // Hold the message until a turn actually completes, so a turn aborted
2657
+ // before reaching the model can be re-issued rather than lost. Carries the
2658
+ // attempt count forward from the agent id we're replacing.
2659
+ if (message) {
2660
+ const carried = this._retryAttemptCarry.get(agentId);
2661
+ this._retryAttemptCarry.delete(agentId);
2662
+ this._pendingUserMessage.delete(agentId);
2663
+ this._pendingUserMessage.set(newAgent.id, { message, attempts: carried || 0 });
2664
+ }
2665
+
2582
2666
  // Carry cumulative tokens
2583
2667
  if (config.tokensUsed > 0) {
2584
2668
  registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
@@ -2959,6 +3043,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2959
3043
  if (throttle?.timer) clearTimeout(throttle.timer);
2960
3044
  this._streamThrottle.delete(agentId);
2961
3045
  this.pendingMessages.delete(agentId);
3046
+ // Safe during a retry: resume() re-registers the message under the new
3047
+ // agent id from its own `message` argument, and the attempt count lives in
3048
+ // _retryAttemptCarry (which kill() must not touch).
3049
+ this._pendingUserMessage.delete(agentId);
2962
3050
 
2963
3051
  // Unregister ambassador if this agent was one
2964
3052
  const agent = this.daemon.registry.get(agentId);
@@ -257,6 +257,13 @@ export class ClaudeCodeProvider extends Provider {
257
257
  cost: data.total_cost_usd,
258
258
  duration: data.duration_ms,
259
259
  turns: data.num_turns,
260
+ // Error/abort signals. Without these an `error_during_execution`
261
+ // result looks identical to a successful one and the user's
262
+ // message is silently dropped.
263
+ subtype: data.subtype,
264
+ isError: data.is_error === true || data.subtype === 'error_during_execution',
265
+ apiDurationMs: data.duration_api_ms,
266
+ terminalReason: data.terminal_reason,
260
267
  });
261
268
  }
262
269
  } catch {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.179",
3
+ "version": "0.27.181",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",