@cspeach/cli 1.1.17 → 1.1.19
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.
- package/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +80 -22
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/signing-public-key.js +1 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
package/README.md
CHANGED
|
@@ -79,12 +79,11 @@ On `/exit` you get a session summary + the `cspeach --resume <id>` command in pl
|
|
|
79
79
|
|
|
80
80
|
## UI modes
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
An interactive terminal gets the standard display (the Ink frame). `/ui auto` (the default) picks it for you; `/ui ink` asks for it explicitly. Both save to `~/.cspeach/config.toml` under `[ui]` and apply after a relaunch.
|
|
83
83
|
|
|
84
|
-
-
|
|
85
|
-
- **`ink`** — React-based TUI. **PREVIEW.** Some rendering quirks remain (occasional phantom cursor, picker fossil in scrollback). Functional for daily use but not yet polished enough to recommend as default. Promoted to default in a later release.
|
|
84
|
+
One-shot runs (`cspeach "<prompt>"`), piped input or output, and CI get plain line output automatically.
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
The classic display is retiring. A saved `ui.rendering = "classic"` is no longer used: the next interactive start switches it to `auto` and says so once. If you need classic for one launch, set the environment variable `CSPEACH_UI=classic` before starting `cspeach`.
|
|
88
87
|
|
|
89
88
|
## Configuration
|
|
90
89
|
|
package/dist/agent/loop.js
CHANGED
|
@@ -8,9 +8,14 @@ import { loadConfig } from '../config/loader.js';
|
|
|
8
8
|
import { resolveModelRole } from '../models/resolve.js';
|
|
9
9
|
import { retryWithBackoff } from './retry.js';
|
|
10
10
|
import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
|
|
11
|
-
import {
|
|
11
|
+
import { nextWritingActivity } from '../renderer/fence-state.js';
|
|
12
|
+
import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner, isWidgetSuppressedTool, toolActivityText, toolHeartbeatLabel } from '../renderer/tool-widget.js';
|
|
12
13
|
import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
|
|
13
14
|
import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
|
|
15
|
+
import { isDeclaredOneShot } from '../renderer/tty.js';
|
|
16
|
+
import { USER_STOP_REASON } from '../session/interrupt-reason.js';
|
|
17
|
+
import { writeStdout, stdoutFlow } from '../renderer/transcript-flow.js';
|
|
18
|
+
import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
|
|
14
19
|
// 2026-06-07 — status-row narration. Safe to import in every mode: the
|
|
15
20
|
// emitter has zero listeners outside Ink (classic / one-shot / subagent),
|
|
16
21
|
// so activity() calls are no-ops there.
|
|
@@ -25,6 +30,7 @@ import { globalStore } from '../ui/sap-state-store.js';
|
|
|
25
30
|
import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
|
|
26
31
|
import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
27
32
|
import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
|
|
33
|
+
import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
|
|
28
34
|
import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
|
|
29
35
|
import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
|
|
30
36
|
import { input } from '@inquirer/prompts';
|
|
@@ -37,7 +43,7 @@ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
|
|
|
37
43
|
import { renderStandardsBlock } from '../standards/standards-file.js';
|
|
38
44
|
import { composeSessionContextBlock } from './session-context.js';
|
|
39
45
|
import { objectKeyFromInput } from './retry-key.js';
|
|
40
|
-
import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError } from './repair-partial.js';
|
|
46
|
+
import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly } from './repair-partial.js';
|
|
41
47
|
import { drainSteering } from './steering-queue.js';
|
|
42
48
|
import { TurnStreamWriter } from './turn-stream.js';
|
|
43
49
|
import { applyToolResultCheckpoint } from './skill-checkpoint.js';
|
|
@@ -155,9 +161,52 @@ export async function inkAwarePrompt(q) {
|
|
|
155
161
|
}
|
|
156
162
|
return withInquirer(() => input({ message: q }));
|
|
157
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Piece 2 / Q3 (spec §3 "[y/N] prompts") — the Yes/No question for the six
|
|
166
|
+
* [y/N] sites (via projects/yes-no.ts askYesNo). Returns 'y' | 'n' | ''.
|
|
167
|
+
*
|
|
168
|
+
* - headless → '' (never blocks), same log line as inkAwarePrompt. The save
|
|
169
|
+
* hook keeps its blanket 'y' by NOT passing this in headless (maybeOfferSave).
|
|
170
|
+
* - Ink → an AskCard Yes/No (v1 choice 'y'/'n') under the card-slot focus
|
|
171
|
+
* claim: cursor on the default, marked "(default)"; y/n keys answer at
|
|
172
|
+
* once; Esc / no listener / anything else → '' (today's
|
|
173
|
+
* `result.answer ?? ''`). Leaves one decision trace in scrollback.
|
|
174
|
+
* - classic → inquirer input with today's exact "<q> [y/N]: " text.
|
|
175
|
+
*/
|
|
176
|
+
export async function inkAwareConfirm(q, opts) {
|
|
177
|
+
const { shouldUseInk, isHeadless } = await import('../renderer/tty.js');
|
|
178
|
+
if (isHeadless()) {
|
|
179
|
+
console.error(chalk.dim(`headless: skipped prompt '${q.trim()}' → '' (default)`));
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
if (shouldUseInk()) {
|
|
183
|
+
const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
|
|
184
|
+
const { withFocusClaim } = await import('../ui/card-slot.js');
|
|
185
|
+
const { emitTrace, formatDecisionTrace } = await import('../renderer/trace.js');
|
|
186
|
+
const { uiWidth } = await import('../renderer/ui-width.js');
|
|
187
|
+
const res = await withFocusClaim(() => askQuestionEmitter.request({
|
|
188
|
+
id: 'agent-confirm',
|
|
189
|
+
question: q,
|
|
190
|
+
kind: 'choice',
|
|
191
|
+
choices: [{ value: 'y', label: 'Yes' }, { value: 'n', label: 'No' }],
|
|
192
|
+
defaultValue: opts.default,
|
|
193
|
+
keys: { y: 'y', n: 'n' },
|
|
194
|
+
}));
|
|
195
|
+
const answer = res.answer === 'y' || res.answer === 'n' ? res.answer : '';
|
|
196
|
+
emitTrace(formatDecisionTrace(q, answer === 'y' ? 'Yes' : answer === 'n' ? 'No' : 'cancelled', uiWidth()));
|
|
197
|
+
return answer;
|
|
198
|
+
}
|
|
199
|
+
const hint = opts.default === 'y' ? '[Y/n]' : '[y/N]';
|
|
200
|
+
const raw = (await withInquirer(() => input({ message: `${q} ${hint}: ` }))).trim().toLowerCase();
|
|
201
|
+
if (raw === 'y' || raw === 'yes')
|
|
202
|
+
return 'y';
|
|
203
|
+
if (raw === '')
|
|
204
|
+
return opts.default;
|
|
205
|
+
return 'n';
|
|
206
|
+
}
|
|
158
207
|
/**
|
|
159
208
|
* Choices picker for an answer-blocker that carries `options`. Renders the
|
|
160
|
-
* native Ink
|
|
209
|
+
* native Ink AskCard (the same CC-style picker the ask_question tool
|
|
161
210
|
* uses) with the candidate answers plus "Write my own" and "Skip" escapes.
|
|
162
211
|
*
|
|
163
212
|
* - headless → skip (never blocks on stdin)
|
|
@@ -220,7 +269,7 @@ export async function maybeOfferSave(p) {
|
|
|
220
269
|
if (!detected)
|
|
221
270
|
return;
|
|
222
271
|
saveSkill = detected;
|
|
223
|
-
p.emit(
|
|
272
|
+
p.emit(formatInfo(`${detected} output found in this turn (routed as ${p.skill}) · offering to save it`).trimEnd());
|
|
224
273
|
}
|
|
225
274
|
// B5 (2026-06-11) — defect D2: in headless runs the artifact IS the point
|
|
226
275
|
// of the run, so the "Save as project file? [y/N]" prompt defaults YES
|
|
@@ -249,6 +298,9 @@ export async function maybeOfferSave(p) {
|
|
|
249
298
|
author: getAuthorIdentity(),
|
|
250
299
|
cwd: process.cwd(),
|
|
251
300
|
prompt,
|
|
301
|
+
// Piece 2 / Q3 — interactive saves ask a Yes/No card; headless passes
|
|
302
|
+
// no confirm so the blanket-'y' prompt above still answers (B5 contract).
|
|
303
|
+
confirm: isHeadless() ? undefined : inkAwareConfirm,
|
|
252
304
|
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
253
305
|
promotedFrom: p.promotedFrom ?? null,
|
|
254
306
|
});
|
|
@@ -262,17 +314,36 @@ export async function maybeOfferSave(p) {
|
|
|
262
314
|
path: savedPath,
|
|
263
315
|
identity: getAuthorIdentity(),
|
|
264
316
|
prompt: inkAwarePrompt,
|
|
317
|
+
confirm: inkAwareConfirm,
|
|
265
318
|
choose: inkAwareChoose,
|
|
266
319
|
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
267
320
|
});
|
|
268
321
|
}
|
|
269
322
|
}
|
|
270
323
|
catch (err) {
|
|
271
|
-
p.emit(
|
|
324
|
+
p.emit('\n' + formatAttention({ text: 'Not saved as a project file', fact: String(err?.message ?? err) }).trimEnd());
|
|
272
325
|
}
|
|
273
326
|
}
|
|
274
327
|
export async function runTurn(params) {
|
|
275
328
|
resetRule8State(); // Rule 8 — fresh batch counter per LLM turn (= per user prompt)
|
|
329
|
+
// Piece 2 / P2 — fresh "plan changed this turn" flag per TOP-LEVEL turn.
|
|
330
|
+
// Not every path ends in pushPostTurnStatus (the Ink /reroute turn does
|
|
331
|
+
// not), so a mark left by such a turn must not earn the NEXT turn a close
|
|
332
|
+
// line. Subagent ctxs omit todoEmitter: a child turn keeps the parent's mark.
|
|
333
|
+
if (params.ctx.todoEmitter)
|
|
334
|
+
consumeTodoSetThisTurn();
|
|
335
|
+
// Panels spacing (polish P1): the REPL printed the prompt etc. outside the
|
|
336
|
+
// stdout transcript flow, so a stdout turn starts from "fresh line".
|
|
337
|
+
if (!params.chunkEmitter)
|
|
338
|
+
stdoutFlow.reset();
|
|
339
|
+
// Fix round 7 item 2 — one-shot passes its answer emitter (stdout, trimmed)
|
|
340
|
+
// as chunkEmitter. Liveness (spinners, heartbeats) must never enter the
|
|
341
|
+
// answer: in one-shot it keeps its own sinks (stderr heartbeat, in-place
|
|
342
|
+
// spinner only on a real terminal) exactly as before.
|
|
343
|
+
const livenessEmitter = isDeclaredOneShot() ? undefined : params.chunkEmitter;
|
|
344
|
+
// Owner ruling (r3 M11) — in one-shot the tool rows go with the tool
|
|
345
|
+
// notices (ctx.chunkEmitter → stderr), never into the answer on stdout.
|
|
346
|
+
const toolRowEmitter = isDeclaredOneShot() ? (params.ctx.chunkEmitter ?? params.chunkEmitter) : params.chunkEmitter;
|
|
276
347
|
const cfg = await loadConfig();
|
|
277
348
|
const session = params.ctx.session;
|
|
278
349
|
// Define `emit` early so the Phase J promote dispatch (below) can stream
|
|
@@ -311,6 +382,7 @@ export async function runTurn(params) {
|
|
|
311
382
|
sourcePath: fromPath,
|
|
312
383
|
targetSkill: params.skill,
|
|
313
384
|
prompt: inkAwarePrompt,
|
|
385
|
+
confirm: inkAwareConfirm,
|
|
314
386
|
log: (...lines) => lines.forEach((l) => emit(l)),
|
|
315
387
|
});
|
|
316
388
|
if (!result) {
|
|
@@ -385,7 +457,12 @@ ${imageExpansion.error}`));
|
|
|
385
457
|
// user has no standards file, so the composed block (and therefore the whole
|
|
386
458
|
// prompt) stays byte-identical to pre-feature output for existing users.
|
|
387
459
|
const standardsBlock = await renderStandardsBlock(params.ctx.cwd);
|
|
388
|
-
|
|
460
|
+
// Round 6 review I4 — a resume moved this session to another system: tell
|
|
461
|
+
// the model once, on this turn, then drop the note.
|
|
462
|
+
const switchNote = session.systemSwitchNote
|
|
463
|
+
? `<system_switch>${session.systemSwitchNote}</system_switch>`
|
|
464
|
+
: '';
|
|
465
|
+
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock, switchNote]);
|
|
389
466
|
// Deterministically extract structured facts from the user's prompt
|
|
390
467
|
// (package, transport, object names, environment hint, create intent)
|
|
391
468
|
// and prepend them as an XML `<session_context>` block. The model reads
|
|
@@ -398,9 +475,12 @@ ${imageExpansion.error}`));
|
|
|
398
475
|
// here, which would defeat a "first turn only" check. Skip only when the
|
|
399
476
|
// message already begins with <session_context> (caller enriched it) or
|
|
400
477
|
// when extraction yields nothing (enrichUserMessage returns original).
|
|
478
|
+
// Final batch 1 — a caller-enriched message still gets the switch note,
|
|
479
|
+
// inside its own <session_context> block.
|
|
401
480
|
const enriched = userMessageForLLM.startsWith('<session_context>')
|
|
402
|
-
? userMessageForLLM
|
|
481
|
+
? (switchNote ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${switchNote}`) : userMessageForLLM)
|
|
403
482
|
: enrichUserMessage(userMessageForLLM, sessionContextBlock);
|
|
483
|
+
const switchNoteUsed = switchNote !== '' && enriched.includes(switchNote);
|
|
404
484
|
// Phase 2 of Track B — opt-in project-context enrichment, gated by
|
|
405
485
|
// CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
|
|
406
486
|
// identical to today. When on, the rendered <project_context> block
|
|
@@ -434,6 +514,10 @@ ${imageExpansion.error}`));
|
|
|
434
514
|
// Images (if any) ride as blocks ahead of the text; with none this is the
|
|
435
515
|
// same plain string as before.
|
|
436
516
|
session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
|
|
517
|
+
// Round 6 review I4 / final batch 1 — the switch note is dropped only once
|
|
518
|
+
// it is in a pushed user message.
|
|
519
|
+
if (switchNoteUsed)
|
|
520
|
+
delete session.systemSwitchNote;
|
|
437
521
|
if (imageExpansion.images.length > 0 && params.provider.name === 'local-openai-compat') {
|
|
438
522
|
// The local (OpenAI-compatible) provider keeps text blocks only.
|
|
439
523
|
emit(chalk.dim('(local model: the image is not sent — the model gets the text only)'));
|
|
@@ -513,6 +597,7 @@ ${imageExpansion.error}`));
|
|
|
513
597
|
}
|
|
514
598
|
}
|
|
515
599
|
let buffer = initialBufferState();
|
|
600
|
+
let writingCode = false; // piece 2 §11.2 — "writing code…" while a fence is held back
|
|
516
601
|
let stream;
|
|
517
602
|
// 2026-05-06: paint a "thinking" spinner during the LLM round-trip
|
|
518
603
|
// wait so the user sees activity instead of staring at a silent
|
|
@@ -531,7 +616,7 @@ ${imageExpansion.error}`));
|
|
|
531
616
|
// keeps the stdout spinner unchanged.
|
|
532
617
|
// Guard: test/unit/ink-cursor-invariant.test.ts.
|
|
533
618
|
turnStatusEmitter.activity('thinking');
|
|
534
|
-
const thinkingSpinner = startThinkingSpinner({ chunkEmitter:
|
|
619
|
+
const thinkingSpinner = startThinkingSpinner({ chunkEmitter: livenessEmitter });
|
|
535
620
|
// v0.6 — guaranteed-visible heartbeat alongside the in-place spinner.
|
|
536
621
|
// The spinner self-disables on non-TTY (Windows PowerShell sometimes
|
|
537
622
|
// reports isTTY=false; resume mode also lost spinner visibility) so a
|
|
@@ -541,7 +626,7 @@ ${imageExpansion.error}`));
|
|
|
541
626
|
// heartbeat lines land in the Body region (via React) instead of
|
|
542
627
|
// being raw-written to stdout (which Ink overdraws on its next
|
|
543
628
|
// render, briefly flashing the line then making it disappear).
|
|
544
|
-
const thinkingHeartbeat = startThinkingHeartbeat({ chunkEmitter:
|
|
629
|
+
const thinkingHeartbeat = startThinkingHeartbeat({ chunkEmitter: livenessEmitter });
|
|
545
630
|
try {
|
|
546
631
|
stream = await retryWithBackoff(() => (async () => {
|
|
547
632
|
// W2.5 — apply optional tool filter (used by agent_run with
|
|
@@ -565,6 +650,12 @@ ${imageExpansion.error}`));
|
|
|
565
650
|
// the single chokepoint every request passes through, retries included —
|
|
566
651
|
// makes the 400 categorically impossible. Idempotent and O(messages).
|
|
567
652
|
healSessionMessagesInPlace(session.messages);
|
|
653
|
+
// 2026-09-27 — same chokepoint: a thinking block without its
|
|
654
|
+
// signature (a turn cancelled mid-thinking, saved by an older CLI)
|
|
655
|
+
// 400s every later request with "Invalid `signature` in `thinking`
|
|
656
|
+
// block". Drop it; the model simply thinks again.
|
|
657
|
+
stripUnsignedThinking(session.messages);
|
|
658
|
+
dropTrailingThinkingOnly(session.messages);
|
|
568
659
|
// 2026-09-24 — same chokepoint: a tool call cut off before its
|
|
569
660
|
// result was recorded 400s every later request (a customer's
|
|
570
661
|
// `continue` could never succeed). Give each orphan an error result
|
|
@@ -621,7 +712,11 @@ ${imageExpansion.error}`));
|
|
|
621
712
|
if (isPartialJsonApiError(err)) {
|
|
622
713
|
const scrubbed = healSessionMessagesInPlace(session.messages);
|
|
623
714
|
await saveSession(session);
|
|
624
|
-
emit(
|
|
715
|
+
emit('\n' + formatAttention({
|
|
716
|
+
text: 'Session repaired',
|
|
717
|
+
fact: `a broken tool call was cleaned up${scrubbed > 0 ? ` (${scrubbed} block${scrubbed === 1 ? '' : 's'})` : ''}`,
|
|
718
|
+
next: 'send your last message again',
|
|
719
|
+
}).trimEnd());
|
|
625
720
|
return;
|
|
626
721
|
}
|
|
627
722
|
// Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
|
|
@@ -629,13 +724,19 @@ ${imageExpansion.error}`));
|
|
|
629
724
|
const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
|
|
630
725
|
const body = typeof bodyRaw === 'string' ? JSON.parse(bodyRaw) : bodyRaw;
|
|
631
726
|
if (status === 409 && body?.error === 'upgrade_required') {
|
|
632
|
-
emit(
|
|
633
|
-
|
|
727
|
+
emit('\n' + formatFailure({
|
|
728
|
+
what: 'This skill needs a newer CSPeach',
|
|
729
|
+
detail: [`needs ${body.min_cli_version} or later · you have ${body.current ?? 'unknown'}`],
|
|
730
|
+
next: 'cspeach --upgrade',
|
|
731
|
+
}).trimEnd());
|
|
634
732
|
return;
|
|
635
733
|
}
|
|
636
734
|
throw err;
|
|
637
735
|
}
|
|
638
736
|
let currentAssistantContent = [];
|
|
737
|
+
// 2026-09-27 — blocks whose content_block_stop arrived. A thinking block
|
|
738
|
+
// not in here was cut mid-stream and must never be persisted.
|
|
739
|
+
const completedBlocks = new WeakSet();
|
|
639
740
|
let sawEndTurn = false;
|
|
640
741
|
let sawToolUse = false;
|
|
641
742
|
let sawMaxTokens = false;
|
|
@@ -682,6 +783,7 @@ ${imageExpansion.error}`));
|
|
|
682
783
|
// silent if the renderer buffers the block — the row keeps
|
|
683
784
|
// ticking "writing response · 90s" regardless.
|
|
684
785
|
turnStatusEmitter.activity('writing response');
|
|
786
|
+
writingCode = false;
|
|
685
787
|
const block = event.content_block;
|
|
686
788
|
currentAssistantContent.push(block);
|
|
687
789
|
if (block.type === 'text') {
|
|
@@ -689,7 +791,7 @@ ${imageExpansion.error}`));
|
|
|
689
791
|
if (params.chunkEmitter)
|
|
690
792
|
params.chunkEmitter.emit('chunk', sep);
|
|
691
793
|
else
|
|
692
|
-
|
|
794
|
+
writeStdout(sep);
|
|
693
795
|
}
|
|
694
796
|
}
|
|
695
797
|
else if (type === 'content_block_delta') {
|
|
@@ -698,11 +800,15 @@ ${imageExpansion.error}`));
|
|
|
698
800
|
if (delta.type === 'text_delta') {
|
|
699
801
|
const { output, state } = renderChunk(delta.text, buffer);
|
|
700
802
|
buffer = state;
|
|
803
|
+
const wa = nextWritingActivity(writingCode, buffer.pending);
|
|
804
|
+
writingCode = wa.writingCode;
|
|
805
|
+
if (wa.label)
|
|
806
|
+
turnStatusEmitter.activity(wa.label);
|
|
701
807
|
if (output) {
|
|
702
808
|
if (params.chunkEmitter)
|
|
703
809
|
params.chunkEmitter.emit('chunk', output);
|
|
704
810
|
else
|
|
705
|
-
|
|
811
|
+
writeStdout(output);
|
|
706
812
|
}
|
|
707
813
|
if (last?.type === 'text')
|
|
708
814
|
last.text = (last.text ?? '') + delta.text;
|
|
@@ -730,6 +836,8 @@ ${imageExpansion.error}`));
|
|
|
730
836
|
}
|
|
731
837
|
else if (type === 'content_block_stop') {
|
|
732
838
|
const last = currentAssistantContent[currentAssistantContent.length - 1];
|
|
839
|
+
if (last && typeof last === 'object')
|
|
840
|
+
completedBlocks.add(last);
|
|
733
841
|
if (last?.type === 'tool_use' && last.partial_json) {
|
|
734
842
|
try {
|
|
735
843
|
last.input = JSON.parse(last.partial_json);
|
|
@@ -804,8 +912,9 @@ ${imageExpansion.error}`));
|
|
|
804
912
|
if (params.chunkEmitter)
|
|
805
913
|
params.chunkEmitter.emit('chunk', flushed);
|
|
806
914
|
else
|
|
807
|
-
|
|
915
|
+
writeStdout(flushed);
|
|
808
916
|
buffer = initialBufferState();
|
|
917
|
+
writingCode = false;
|
|
809
918
|
}
|
|
810
919
|
// Repair partial blocks before persistence. ALWAYS run, not just on
|
|
811
920
|
// interruption: on a clean tool-use round the model's tool_use blocks can
|
|
@@ -815,7 +924,11 @@ ${imageExpansion.error}`));
|
|
|
815
924
|
// only DROPS a block when its `input` is undefined — which never happens on
|
|
816
925
|
// a clean round (content_block_stop always sets `input`), so this is a
|
|
817
926
|
// no-op-except-strip on the happy path. See ./repair-partial.ts.
|
|
818
|
-
currentAssistantContent = repairPartialBlocks(currentAssistantContent);
|
|
927
|
+
currentAssistantContent = repairPartialBlocks(currentAssistantContent, { completed: completedBlocks });
|
|
928
|
+
// Review M2 — a cut right after the thinking block leaves a thinking-only
|
|
929
|
+
// turn: nothing the user can read, and nothing the next request needs.
|
|
930
|
+
if (isThinkingOnly(currentAssistantContent))
|
|
931
|
+
currentAssistantContent = [];
|
|
819
932
|
// Persist the assistant message — even if it's partial. Empty content
|
|
820
933
|
// arrays are skipped so we don't push a meaningless empty assistant
|
|
821
934
|
// message that would confuse the next round.
|
|
@@ -842,7 +955,9 @@ ${imageExpansion.error}`));
|
|
|
842
955
|
if (interruptedError !== null) {
|
|
843
956
|
session.turnInterrupted = true;
|
|
844
957
|
const msg = interruptedError instanceof Error ? interruptedError.message : String(interruptedError);
|
|
845
|
-
|
|
958
|
+
// Round 7 item 8d — a stop the user asked for (Ctrl+C, /exit mid-turn)
|
|
959
|
+
// is saved as such, never as the raw "Request was aborted.".
|
|
960
|
+
session.turnInterruptedReason = params.signal?.aborted ? USER_STOP_REASON : msg.slice(0, 200);
|
|
846
961
|
}
|
|
847
962
|
else {
|
|
848
963
|
// Clean completion — clear any stale interrupted flag from a prior turn.
|
|
@@ -884,8 +999,9 @@ ${imageExpansion.error}`));
|
|
|
884
999
|
void appendCostLine(session.id, entry);
|
|
885
1000
|
}
|
|
886
1001
|
// Close the stream-to-disk mirror with a footer (lightweight diagnostics).
|
|
887
|
-
// Best-effort — failure here is invisible to the turn flow.
|
|
888
|
-
|
|
1002
|
+
// Best-effort — failure here is invisible to the turn flow. Awaited
|
|
1003
|
+
// (round 6 item 4e) so an exit right after the turn finds the log.
|
|
1004
|
+
await turnStreamWriter.close(interruptedError !== null
|
|
889
1005
|
? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
|
|
890
1006
|
: undefined);
|
|
891
1007
|
// Re-throw AFTER persistence so the outer REPL catch can render the
|
|
@@ -1038,7 +1154,7 @@ ${imageExpansion.error}`));
|
|
|
1038
1154
|
renderToolCallTop({
|
|
1039
1155
|
name: block.name,
|
|
1040
1156
|
args: (block.input ?? {}),
|
|
1041
|
-
chunkEmitter:
|
|
1157
|
+
chunkEmitter: toolRowEmitter,
|
|
1042
1158
|
});
|
|
1043
1159
|
// Phase 2a: start the peach-themed spinner. No-op outside TTY / Ink
|
|
1044
1160
|
// / CI — the result line still prints, just without the in-place
|
|
@@ -1048,7 +1164,7 @@ ${imageExpansion.error}`));
|
|
|
1048
1164
|
// orphan on its own row above the form.
|
|
1049
1165
|
const spinner = isWidgetSuppressedTool(block.name)
|
|
1050
1166
|
? { stop: () => undefined }
|
|
1051
|
-
: startToolSpinner({ chunkEmitter:
|
|
1167
|
+
: startToolSpinner({ chunkEmitter: livenessEmitter, label: toolActivityText(block.name, (block.input ?? {})) });
|
|
1052
1168
|
// 2026-06-06 (turn-liveness, B5 smoke feedback) — the in-place tool
|
|
1053
1169
|
// spinner self-disables under Ink (phantom cursor #25), which left
|
|
1054
1170
|
// slow tool calls (SAP over VPN: 10-60s) as DEAD AIR between the ⏺
|
|
@@ -1087,12 +1203,12 @@ ${imageExpansion.error}`));
|
|
|
1087
1203
|
if (isInteractiveTool)
|
|
1088
1204
|
turnStatusEmitter.pause();
|
|
1089
1205
|
else
|
|
1090
|
-
turnStatusEmitter.activity(block.name);
|
|
1206
|
+
turnStatusEmitter.activity(toolActivityText(block.name, (block.input ?? {})));
|
|
1091
1207
|
const toolHeartbeat = isInteractiveTool
|
|
1092
1208
|
? { stop: () => undefined }
|
|
1093
1209
|
: startThinkingHeartbeat({
|
|
1094
|
-
chunkEmitter:
|
|
1095
|
-
label:
|
|
1210
|
+
chunkEmitter: livenessEmitter,
|
|
1211
|
+
label: toolHeartbeatLabel(block.name, (block.input ?? {})),
|
|
1096
1212
|
});
|
|
1097
1213
|
// Phase 2b: dispatch (may take 100ms–several seconds for write tools).
|
|
1098
1214
|
const dispatchStart = Date.now();
|
|
@@ -1139,13 +1255,15 @@ ${imageExpansion.error}`));
|
|
|
1139
1255
|
durationMs,
|
|
1140
1256
|
isError: result.is_error ?? false,
|
|
1141
1257
|
resultSummary,
|
|
1142
|
-
chunkEmitter:
|
|
1258
|
+
chunkEmitter: toolRowEmitter,
|
|
1143
1259
|
// D29 (2026-06-12): self-identifying result row. Heartbeat lines,
|
|
1144
1260
|
// sap-client warns, and notice lines legitimately print between
|
|
1145
1261
|
// the ⏺ top line and this row — without the name here those rows
|
|
1146
1262
|
// read as anonymous `⎿ ✓ 364ms` orphans in the transcript.
|
|
1147
1263
|
name: block.name,
|
|
1148
1264
|
args: (block.input ?? {}),
|
|
1265
|
+
// Panels look: counts ("17 lines") and folded command output.
|
|
1266
|
+
resultContent: typeof result.content === 'string' ? result.content : undefined,
|
|
1149
1267
|
});
|
|
1150
1268
|
// v0.3 (Step 28.0): populate session.toolCalls so the SessionTimeline
|
|
1151
1269
|
// overlay can render per-write history. Cap per-entry payload size at
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// Loaded on use (fix round 3, item 11e): only local mode needs the OpenAI SDK.
|
|
2
|
+
const loadOpenAI = async () => (await import('openai')).default;
|
|
2
3
|
import crypto from 'node:crypto';
|
|
3
4
|
import { sha256 } from '@noble/hashes/sha256';
|
|
4
5
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
@@ -19,6 +20,7 @@ export function createLocalProvider(cfg) {
|
|
|
19
20
|
if (params.tools && params.tools.length > 0) {
|
|
20
21
|
throw new Error('Tool calling not supported in local mode (Plan A). Switch to managed/byok/ai-hub, or wait for the local-mode tool-calling follow-up PR.');
|
|
21
22
|
}
|
|
23
|
+
const OpenAI = await loadOpenAI();
|
|
22
24
|
const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
|
|
23
25
|
const openAiParams = anthropicParamsToOpenAI(params, model);
|
|
24
26
|
// stream: true widens via object spread; the literal-true overload cannot be
|
|
@@ -29,6 +31,7 @@ export function createLocalProvider(cfg) {
|
|
|
29
31
|
},
|
|
30
32
|
async healthCheck() {
|
|
31
33
|
try {
|
|
34
|
+
const OpenAI = await loadOpenAI();
|
|
32
35
|
const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
|
|
33
36
|
await sdk.chat.completions.create({
|
|
34
37
|
model,
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `partial_json` but `JSON.parse` was never called, so `input` is undefined.
|
|
9
|
-
*
|
|
10
|
-
* Persisting an assistant message that contains an incomplete tool_use block
|
|
11
|
-
* causes the next API call to reject the message with:
|
|
12
|
-
* 400 each `tool_use` block must contain `input` (or similar)
|
|
13
|
-
*
|
|
14
|
-
* Drop incomplete tool_use blocks. The model will re-emit them on the next
|
|
15
|
-
* round (the prior tool round's results are already on session.messages,
|
|
16
|
-
* giving the model the same context). Text and thinking blocks are kept
|
|
17
|
-
* even if truncated — partial prose is still useful for the user to read,
|
|
18
|
-
* and a short paragraph fragment is a valid assistant message.
|
|
2
|
+
* 2026-09-27 — a thinking block is only valid to send back to the API with its
|
|
3
|
+
* complete signature. The signature arrives last (`signature_delta`, right
|
|
4
|
+
* before `content_block_stop`), so a turn cancelled mid-thinking leaves a block
|
|
5
|
+
* with `signature: ''` — and every later request 400s with
|
|
6
|
+
* messages.N.content.M: Invalid `signature` in `thinking` block
|
|
7
|
+
* A redacted_thinking block is likewise only valid with its `data`.
|
|
19
8
|
*/
|
|
20
|
-
export function
|
|
9
|
+
export function isUnsignedThinking(b) {
|
|
10
|
+
if (b?.type === 'thinking')
|
|
11
|
+
return typeof b.signature !== 'string' || b.signature.length === 0;
|
|
12
|
+
if (b?.type === 'redacted_thinking')
|
|
13
|
+
return typeof b.data !== 'string' || b.data.length === 0;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
export function repairPartialBlocks(blocks, opts = {}) {
|
|
21
17
|
return blocks.filter((b) => {
|
|
22
18
|
if (b == null || typeof b !== 'object')
|
|
23
19
|
return false;
|
|
@@ -35,10 +31,46 @@ export function repairPartialBlocks(blocks) {
|
|
|
35
31
|
delete b.partial_json;
|
|
36
32
|
return true;
|
|
37
33
|
}
|
|
38
|
-
|
|
34
|
+
if (b.type === 'thinking' || b.type === 'redacted_thinking') {
|
|
35
|
+
// Never persist a partial thinking block: no signature, or cut before
|
|
36
|
+
// its content_block_stop. The model re-thinks on the next round.
|
|
37
|
+
if (isUnsignedThinking(b))
|
|
38
|
+
return false;
|
|
39
|
+
if (opts.completed && !opts.completed.has(b))
|
|
40
|
+
return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
// text etc. — keep as-is (partial prose is still useful and valid).
|
|
39
44
|
return true;
|
|
40
45
|
});
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Heal a session already poisoned by an unsigned thinking block (saved by a
|
|
49
|
+
* CLI before 2026-09-27): drop every thinking / redacted_thinking block that
|
|
50
|
+
* lacks its signature, and drop a message left with no content (the API
|
|
51
|
+
* rejects empty content; consecutive user turns are merged by the API).
|
|
52
|
+
* Mutates `messages`; returns the number of blocks removed. Idempotent.
|
|
53
|
+
*/
|
|
54
|
+
export function stripUnsignedThinking(messages) {
|
|
55
|
+
if (!messages)
|
|
56
|
+
return 0;
|
|
57
|
+
let removed = 0;
|
|
58
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
59
|
+
const msg = messages[i];
|
|
60
|
+
if (!Array.isArray(msg?.content))
|
|
61
|
+
continue;
|
|
62
|
+
const kept = msg.content.filter((b) => !isUnsignedThinking(b));
|
|
63
|
+
const dropped = msg.content.length - kept.length;
|
|
64
|
+
if (dropped === 0)
|
|
65
|
+
continue;
|
|
66
|
+
removed += dropped;
|
|
67
|
+
if (kept.length === 0)
|
|
68
|
+
messages.splice(i, 1);
|
|
69
|
+
else
|
|
70
|
+
msg.content = kept;
|
|
71
|
+
}
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
|
42
74
|
/**
|
|
43
75
|
* In-memory heal for a poisoned session.messages array.
|
|
44
76
|
*
|
|
@@ -167,3 +199,28 @@ export function healOrphanToolUses(messages) {
|
|
|
167
199
|
}
|
|
168
200
|
return added;
|
|
169
201
|
}
|
|
202
|
+
/** Review M2 — true when every block is thinking / redacted_thinking (and there is one). */
|
|
203
|
+
export function isThinkingOnly(blocks) {
|
|
204
|
+
return Array.isArray(blocks) && blocks.length > 0
|
|
205
|
+
&& blocks.every((b) => b?.type === 'thinking' || b?.type === 'redacted_thinking');
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Review M2 — a turn cut right after a signed thinking block (before any
|
|
209
|
+
* text or tool call) left an assistant message holding only thinking. Drop
|
|
210
|
+
* it when it is the LAST assistant message; earlier turns are never touched
|
|
211
|
+
* (removing a block mid-transcript would invalidate later signed blocks).
|
|
212
|
+
* Mutates `messages`; returns the number of messages removed. Idempotent.
|
|
213
|
+
*/
|
|
214
|
+
export function dropTrailingThinkingOnly(messages) {
|
|
215
|
+
if (!messages)
|
|
216
|
+
return 0;
|
|
217
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
218
|
+
if (messages[i]?.role !== 'assistant')
|
|
219
|
+
continue;
|
|
220
|
+
if (!isThinkingOnly(messages[i].content))
|
|
221
|
+
return 0;
|
|
222
|
+
messages.splice(i, 1);
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
return 0;
|
|
226
|
+
}
|