@cspeach/cli 1.1.16 → 1.1.17

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.
@@ -37,7 +37,7 @@ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
37
37
  import { renderStandardsBlock } from '../standards/standards-file.js';
38
38
  import { composeSessionContextBlock } from './session-context.js';
39
39
  import { objectKeyFromInput } from './retry-key.js';
40
- import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
40
+ import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError } from './repair-partial.js';
41
41
  import { drainSteering } from './steering-queue.js';
42
42
  import { TurnStreamWriter } from './turn-stream.js';
43
43
  import { applyToolResultCheckpoint } from './skill-checkpoint.js';
@@ -565,6 +565,14 @@ ${imageExpansion.error}`));
565
565
  // the single chokepoint every request passes through, retries included —
566
566
  // makes the 400 categorically impossible. Idempotent and O(messages).
567
567
  healSessionMessagesInPlace(session.messages);
568
+ // 2026-09-24 — same chokepoint: a tool call cut off before its
569
+ // result was recorded 400s every later request (a customer's
570
+ // `continue` could never succeed). Give each orphan an error result
571
+ // so the model sees the call did not complete and redoes it.
572
+ const healedOrphans = healOrphanToolUses(session.messages);
573
+ if (healedOrphans > 0) {
574
+ emit(chalk.dim(`(repaired ${healedOrphans} tool call(s) cut off by an interruption — the model will redo them)`));
575
+ }
568
576
  const streamParams = {
569
577
  // A4 — per-turn override (plan model tiering) wins over the
570
578
  // configured default; absent on every non-plan turn. model-governance
@@ -116,3 +116,54 @@ export function isPartialJsonApiError(err) {
116
116
  && s.includes('partial_json')
117
117
  && /not permitted|Extra inputs/i.test(s));
118
118
  }
119
+ /** Result text for a tool call whose real result was lost to an interruption. */
120
+ export const INTERRUPTED_TOOL_RESULT = 'This tool call did not complete — the turn was interrupted before its result was recorded. ' +
121
+ 'Its effect is unknown: check before relying on it, and re-run it if needed.';
122
+ /**
123
+ * 2026-09-24 — heal tool_use blocks that have no tool_result after them.
124
+ *
125
+ * A turn cut after the model streamed a tool_use but before the result was
126
+ * recorded leaves a history the API rejects on EVERY later request
127
+ * ("messages.N: `tool_use` ids were found without `tool_result` blocks
128
+ * immediately after") — a customer's `continue` could never succeed. Each
129
+ * orphan gets a synthetic error result, placed where the API requires it: at
130
+ * the front of the next user message (created if the next message is not a
131
+ * user message). Mutates `messages`; returns the number of results added.
132
+ * Idempotent; a no-op on a healthy conversation.
133
+ */
134
+ export function healOrphanToolUses(messages) {
135
+ if (!messages)
136
+ return 0;
137
+ let added = 0;
138
+ for (let i = 0; i < messages.length; i++) {
139
+ const msg = messages[i];
140
+ if (msg?.role !== 'assistant' || !Array.isArray(msg.content))
141
+ continue;
142
+ const ids = msg.content
143
+ .filter((b) => b?.type === 'tool_use' && typeof b.id === 'string')
144
+ .map((b) => b.id);
145
+ if (ids.length === 0)
146
+ continue;
147
+ let next = messages[i + 1];
148
+ if (next?.role !== 'user') {
149
+ next = { role: 'user', content: [] };
150
+ messages.splice(i + 1, 0, next);
151
+ }
152
+ else if (!Array.isArray(next.content)) {
153
+ next.content = [{ type: 'text', text: String(next.content ?? '') }];
154
+ }
155
+ const have = new Set(next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id));
156
+ const missing = ids
157
+ .filter((id) => !have.has(id))
158
+ .map((id) => ({ type: 'tool_result', tool_use_id: id, is_error: true, content: INTERRUPTED_TOOL_RESULT }));
159
+ if (missing.length === 0)
160
+ continue;
161
+ // tool_result blocks must lead the message: existing results, then the
162
+ // synthetic ones, then everything else (e.g. the user's "continue").
163
+ const results = next.content.filter((b) => b?.type === 'tool_result');
164
+ const rest = next.content.filter((b) => b?.type !== 'tool_result');
165
+ next.content = [...results, ...missing, ...rest];
166
+ added += missing.length;
167
+ }
168
+ return added;
169
+ }
@@ -39,6 +39,11 @@ export function describeError(err) {
39
39
  }
40
40
  if (msg.includes('ECONNRESET'))
41
41
  return 'network connection reset';
42
+ // 2026-09-24 — the other shapes a dropped connection takes (our service
43
+ // restarting, a network blip): say it plainly instead of the raw text.
44
+ if (/fetch failed|socket hang up|other side closed|UND_ERR_SOCKET|ECONNREFUSED/i.test(msg)) {
45
+ return 'connection to CSPeach dropped';
46
+ }
42
47
  if (msg.includes('ETIMEDOUT'))
43
48
  return 'network timeout';
44
49
  if (msg.toLowerCase().includes('aborterror'))
@@ -111,6 +116,9 @@ export async function renderTurnError(input) {
111
116
  const reason = describeError(input.err);
112
117
  const logPath = await writeErrorLog(input.session.id, input.err);
113
118
  print(chalk.red(`⚠ Turn interrupted: ${reason}`));
119
+ if (input.inSession) {
120
+ print(chalk.yellow(` Your work so far is saved. Type continue to pick up where it stopped.`));
121
+ }
114
122
  print(chalk.gray(` Session: ${input.session.id}`));
115
123
  if (input.streamFilePath) {
116
124
  print(chalk.gray(` Stream: ${input.streamFilePath}`));
package/dist/repl.js CHANGED
@@ -1380,11 +1380,18 @@ export async function runRepl(opts) {
1380
1380
  });
1381
1381
  }
1382
1382
  catch (err) {
1383
- const { emitError } = await import('./ui/error-emitter.js');
1384
- emitError({
1385
- code: 'RUN_TURN_ERROR',
1386
- title: 'Turn failed',
1387
- body: err.message,
1383
+ // 2026-09-24 — print the turn-error block INTO the conversation (it
1384
+ // stays on screen) instead of the overlay box that vanished after 5 s
1385
+ // — a customer's dropped turn left no trace of what happened or what
1386
+ // to do. Same block classic mode prints; in-session it leads with
1387
+ // "type continue" (the partial turn is already saved).
1388
+ const { renderTurnError } = await import('./agent/turn-error-ux.js');
1389
+ await renderTurnError({
1390
+ err,
1391
+ session,
1392
+ userInterrupted: currentTurnAbort?.signal.aborted ?? false,
1393
+ inSession: true,
1394
+ print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1388
1395
  });
1389
1396
  }
1390
1397
  finally {
@@ -2942,6 +2949,7 @@ export async function runRepl(opts) {
2942
2949
  err,
2943
2950
  session,
2944
2951
  userInterrupted: interruptedByUser,
2952
+ inSession: true,
2945
2953
  print: (line) => console.error(line),
2946
2954
  });
2947
2955
  }
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE — bundled skills for local mode.
2
2
  // Source: https://manifest.cspeach.dev/v1.json
3
- // Generated: 2026-09-24T14:23:23.324Z
3
+ // Generated: 2026-09-24T16:31:52.103Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-atc-fix": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.16",
3
+ "version": "1.1.17",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",