@hone-ai/cli 1.38.1 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-337f — read an Anthropic Messages response without losing half of it.
4
+ *
5
+ * Every CLI path used to take `data.content[0].text`. Once a model can emit a thinking block
6
+ * FIRST, block 0 is `{type: 'thinking'}` and that expression is `undefined`: an empty review or
7
+ * verdict reported as a content filter or a quota trip. The HC-GF-085 defect, on the CLI side.
8
+ *
9
+ * Returns a discriminated result; the caller owns its own exit semantics (skill-eval throws inside
10
+ * callLLM, release-review emits a status envelope, the judge throws so the scenario is an error and
11
+ * not a failed criterion). Nothing here throws, so the outcomes can be asserted in one table.
12
+ *
13
+ * { ok: true, text, stopReason }
14
+ * { ok: false, kind: 'refusal', category, stopReason, message } // HTTP 200, stop_reason "refusal"
15
+ * { ok: false, kind: 'truncated', stopReason, partialText, message } // cut off at the cap; partialText is null when there is none
16
+ * { ok: false, kind: 'no_text', stopReason, message } // blocks, but none with text
17
+ *
18
+ * Order is part of the contract: a refusal is decided FIRST (it can arrive with a thinking block and
19
+ * no text, and must never read as `no_text`). Then `max_tokens`: a response cut off at the cap is
20
+ * `truncated` EVEN WHEN it carries text, because a cut-off answer can be well-formed and wrong (a review
21
+ * cut off before its verdict). A caller whose success means approval (release-review) therefore gets
22
+ * the safe answer by default. A caller for which a partial answer is harmless (a judge verdict that
23
+ * defaults to NO, a skill-eval answer that fails its keywords) opts in with `allowPartial: true` and
24
+ * then gets the text back as `ok`. The first non-blank text block wins otherwise.
25
+ *
26
+ * Every non-ok message names the label, `stop_reason:` and, when the API gave one, the refusal category.
27
+ * Messages begin `<label> returned no text (stop_reason: ...)` when there is no text at all: callers and
28
+ * tests match that prefix.
29
+ *
30
+ * ADOPTER SURFACE (HC-LC-001): this file ships in the published CLI. It is a pure classifier: no
31
+ * I/O, no local Claude binary.
32
+ *
33
+ * @param {object} data the parsed response body
34
+ * @param {{label?: string, allowPartial?: boolean}} [opts] label: who is asking, so three call sites read
35
+ * differently in a log. allowPartial: accept text from a response cut off at max_tokens (default false).
36
+ */
37
+ function readAnthropicText(data, { label, allowPartial = false } = {}) {
38
+ const who = label || 'the provider';
39
+ const stopReason = (data && data.stop_reason) || 'unknown';
40
+ const category = (data && data.stop_details && data.stop_details.category) || null;
41
+ const blocks = Array.isArray(data && data.content) ? data.content : [];
42
+ const cause = `stop_reason: ${stopReason}${category ? `, category: ${category}` : ''}`;
43
+
44
+ if (stopReason === 'refusal') {
45
+ return { ok: false, kind: 'refusal', category, stopReason, message: `${who} returned no text (${cause}): the model declined the request` };
46
+ }
47
+ const block = blocks.find((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim().length > 0);
48
+ if (stopReason === 'max_tokens' && !(block && allowPartial)) {
49
+ const message = block
50
+ ? `${who} returned an incomplete answer (${cause}): it was cut off at max_tokens and may be missing its ending; raise max_tokens`
51
+ : `${who} returned no text (${cause}): the answer was cut off at max_tokens before any text, thinking included; raise max_tokens`;
52
+ return { ok: false, kind: 'truncated', stopReason, partialText: block ? block.text : null, message };
53
+ }
54
+ if (block) return { ok: true, text: block.text, stopReason };
55
+ return { ok: false, kind: 'no_text', stopReason, message: `${who} returned no text (${cause})` };
56
+ }
57
+
58
+ module.exports = { readAnthropicText };