kairos-chain 3.55.0 → 3.56.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.
Files changed (22) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/kairos_mcp/version.rb +1 -1
  4. data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +196 -11
  5. data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +25 -3
  6. data/templates/skillsets/llm_client/skillset.json +1 -1
  7. data/templates/skillsets/llm_client/test/test_claude_code_adapter_parse.rb +104 -0
  8. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +265 -74
  9. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/observer_set.rb +43 -3
  10. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +60 -28
  11. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +18 -2
  12. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/review_serializer.rb +39 -0
  13. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/verdict_vocabulary.rb +171 -0
  14. data/templates/skillsets/multi_llm_review/skillset.json +2 -2
  15. data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +165 -50
  16. data/templates/skillsets/multi_llm_review/test/test_mutation_survivors.rb +1103 -0
  17. data/templates/skillsets/multi_llm_review/test/test_observer_set.rb +146 -8
  18. data/templates/skillsets/multi_llm_review/test/test_observer_set_seams.rb +745 -76
  19. data/templates/skillsets/multi_llm_review/test/test_tool_wiring.rb +155 -8
  20. data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +86 -52
  21. data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +27 -27
  22. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2291c491a077a0cfdce3d77bdd3709e8e6278fc9eb656a02f3de5508e4f66580
4
- data.tar.gz: 96fa06b0195df3167b3113c54125a093f31f4bed10955c044a2ef218fe6d9278
3
+ metadata.gz: 185d6af51fd316124bc72eabbfc3f6b280fee6a2beeda91ed2b690781bb6c26f
4
+ data.tar.gz: 83951da8550d67d22b67a62153c71a923c4d016a59e94c86d07062a5aac4fedb
5
5
  SHA512:
6
- metadata.gz: 72064bd0e1bde0516e8dd2b90adcbb9a85f3d079f71507b9f77210985e2f995fc9a1e8af066eee19835dda664a707f09ae8f6420ecfb6135dec62aa92449b756
7
- data.tar.gz: 2553da41e8590b33c49f8e0d3526d37ac80bc8f742bab79af45cb5798c7a425ad978e66099a47454f9ca1ade3cbce310a798b7a9d1400eedac7a781d3cfdd745
6
+ metadata.gz: 14288ed7e74a7a276f68c94292935804830f1d302272ac5ff2e1ba11865ac00cf99acb3d8d93382a2fa0c878e3c0df1ddb4f2b9dfbc742f387386d32c0a6e1da
7
+ data.tar.gz: cdc967af9cd5962d175787d22cdfd63c881be35aefd6d2dcfba0afd14714482674465cf11df138f646214c37b2800163b982546da5381367c8826d9a74b3b7f4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.56.0] - 2026-07-31
8
+
9
+ ### Changed
10
+
11
+ - **`multi_llm_review` SkillSet 0.7.0 — verdict reading hardened across both
12
+ observer paths** (review rounds R10–R15, frozen by operator declaration
13
+ 2026-07-31 after three consecutive rounds with zero deployment-grounded
14
+ findings).
15
+ - The verdict vocabulary is one table (`VerdictVocabulary::WORDS`); the
16
+ prose-search and whole-value patterns are built from it, differing only
17
+ in anchoring and separator strictness. The rebuilt regexes are
18
+ source-identical to the previous literals.
19
+ - Verdict *reading* is `stated` (whole-value, anchored) on every path.
20
+ `VerdictVocabulary.classify`, `PersonaAssembly.normalize_verdict`, and
21
+ the dead constants `VERDICT_PATTERNS` / `ALLOWED_VERDICTS` /
22
+ `*_ALIASES` are deleted; their absence is pinned by tests.
23
+ - A persona verdict field that is not a verdict is **refused at
24
+ submission** (`PersonaAssembly.validate!` raises ArgumentError) instead
25
+ of being word-searched (≤ R12) or defaulted to REVISE (R13). The collect
26
+ tool validates before consuming, so a refused submission leaves the
27
+ pending token collectable and the corrected submission carries the vote.
28
+ - Declared verdicts are carried in canonical case (`extract_verdict`
29
+ merges the admitted form), so a lower-case declaration can no longer sit
30
+ in the denominator without counting.
31
+ - Test suite grew 401 → 478 runs / 1676 assertions, including a
32
+ mutation-survivor file distilled from three generative sweeps.
33
+ - **`llm_client` SkillSet 0.2.0 — CLI envelope diagnostics preserved.**
34
+ `claude_code_adapter` now picks `model_observed` by output tokens rather
35
+ than hash order, and passes `model_usage`, `api_error_status`,
36
+ `fast_mode_state` and `terminal_reason` through to the response. Four
37
+ model-divergence incidents (a slot requesting opus-4-6 answered by
38
+ haiku-4-5) were undiagnosable because these fields were discarded; the
39
+ divergence itself was confirmed real (the divergent reply cited
40
+ identifiers that exist nowhere in the reviewed code).
41
+
7
42
  ## [3.55.0] - 2026-07-27
8
43
 
9
44
  ### Added
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.55.0"
2
+ VERSION = "3.56.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: multi_llm_review_workflow
3
3
  description: "Multi-LLM review methodology and execution — workflow pattern, CLI tooling, consensus analysis, Persona Assembly. Applicable to design, implementation, documentation, or any artifact."
4
- version: "3.7.0"
4
+ version: "3.8.0"
5
5
  tags:
6
6
  - workflow
7
7
  - review
@@ -29,6 +29,51 @@ This skill covers:
29
29
  For **WHO** (which LLM is good at what), see: `multi_llm_reviewer_evaluation`
30
30
  For **development lifecycle** (design → implement → verify), see: `design_to_implementation_workflow`
31
31
 
32
+ ## Step -1 — Pre-declared review spec (loop hygiene)
33
+
34
+ > Validation scope: these rules were derived from one non-converging loop
35
+ > (multi_llm_review R10–R15, 2026-07) where they took the P0 count from 10 to
36
+ > 2 in two rounds and closed the loop in three. They are instance practice
37
+ > until reproduced on a second, non-self-referential subject; treat the
38
+ > numbers below as one loop's evidence, not a law.
39
+
40
+ Before dispatching round 1 — and again whenever the review TARGET changes —
41
+ write a review spec and declare it frozen for the round:
42
+
43
+ 1. **Pre-declare the pass condition** (per `loop_validation`: spec before
44
+ judgement, fail-closed). State what APPROVE requires. A loop whose target
45
+ drifted (e.g. from an implementation to the instrument that measures it)
46
+ without a re-declared spec is structurally non-converging: a 300-claim
47
+ artifact at any realistic per-claim error rate yields double-digit
48
+ findings every round regardless of quality.
49
+ 2. **Split target from appendix.** Only the shipping deliverable is
50
+ P0-eligible. Instruments, sweep logs, classification tables are appendix:
51
+ findings against them are advisory and go to the queue. This is what
52
+ collapsed the claim surface from ~350 to ~30.
53
+ 3. **Cap fixes per round (≤5)** and write one line per fix: *what this fix
54
+ newly claims* (values pinned, ranges narrowed, failure visibility
55
+ changed). A fix that cannot state its new claims is doing more than the
56
+ finding asked.
57
+ 4. **Pre-flight falsifier.** Before dispatch, one agent whose only job is to
58
+ refute every factual claim in the spec and artifact — especially numbers
59
+ and "X does not exist" claims. In this loop it caught real errors before
60
+ every single dispatch (3 + 1 + 0 refuted across R13–R15); rounds without
61
+ it had returned the same errors as P0s.
62
+ 5. **Check threshold reachability before dispatching.** Compute the maximum
63
+ achievable approve count from live slots; if the threshold is
64
+ unreachable, declare the exhaustion path up front (the frozen design's
65
+ own closing: findings exhausted → operator freeze declaration) instead of
66
+ discovering it at collect.
67
+ 6. **Reference originals by path + sha256; do not transcribe.** Reviewers
68
+ read the repository; the artifact carries the manifest. Transcription
69
+ errors are undetectable and 100KB+ pastes rot.
70
+
71
+ Corollaries observed in the same loop: fix the *class*, and fix every copy —
72
+ a corrected lib comment whose refuted twin survives in a test file costs a
73
+ full round. When an author writes history into comments, the falsifier must
74
+ check the cited records; two of the loop's P0s were numbers copied from the
75
+ wrong document.
76
+
32
77
  ## Step 0 — Load reviewer characteristics (mandatory)
33
78
 
34
79
  **Before invoking any reviewer**, fetch `multi_llm_reviewer_evaluation` via
@@ -878,26 +923,102 @@ after five consecutive non-substantive returns.
878
923
 
879
924
  ### Substance and the denominator
880
925
 
881
- A reply that carries a verdict and nothing else does not count as a review. It
882
- leaves the denominator instead of being counted as agreement — otherwise it
883
- raises the agreement everyone else must reach while contributing none of its own,
884
- which is the failure mode that retired Fable 5 from the roster.
926
+ A reply counts toward the denominator when it **carries a verdict** and **says
927
+ something beyond it**. Both conditions, separately checked; a reply failing
928
+ either leaves the denominator rather than being counted, because otherwise it
929
+ raises the agreement everyone else must reach while contributing none of its own.
930
+
931
+ The two failures look nothing alike, and conflating them is why this rule was
932
+ rebuilt four times:
933
+
934
+ | Reply | Carries a verdict? | Says something? | Outcome |
935
+ |---|---|---|---|
936
+ | `APPROVE` | yes | no | `insubstantial` |
937
+ | `APPROVE. P0` | yes | no (a severity names no defect) | `insubstantial` |
938
+ | `I'll review this and give my assessment.` | **no** | yes | `no_verdict` |
939
+ | `競合状態あり` | no (no verdict word) | yes | `no_verdict` |
940
+ | `REJECT: P0 key logged in plaintext` | yes | yes | counts |
941
+ | `NO-GO` + findings | yes (see the vocabulary below) | yes | counts as REJECT |
942
+
943
+ The second row is the shape that retired Fable 5 — long enough to pass any
944
+ substance rule, and stating no judgement. Three rebuilds of the substance rule
945
+ missed it because they were all looking at the wrong half. A reply that states
946
+ no verdict used to be counted as a conservative `REVISE`, which blocked
947
+ convergence on a judgement its author never gave.
885
948
 
886
949
  - The test is mechanical and has no setting: **does anything remain once the
887
950
  verdict word is removed?** No model is asked to judge whether a review is good.
888
951
  - It applies to every observer equally, the persona team included. A persona
889
952
  submission whose findings are blank leaves the denominator exactly as a
890
- subprocess reply would.
953
+ subprocess reply would — and so does a structured reply from any other slot.
954
+ A JSON reply is judged by **the words it carries, whatever keys they live
955
+ under**, not by the text it renders to and not by keys named in advance.
956
+ Judging it by rendered text counted its own key names as content: the residue
957
+ of `{"overall_verdict": "APPROVE", "findings": [], "reasoning": ""}` is three
958
+ words of schema and no review. Naming the keys instead threw away real
959
+ reviews: a REJECT whose findings used `description` was recorded as an empty
960
+ submission. **Answer in whatever shape you like** — nothing here requires a
961
+ particular key.
962
+ - **Open your reply with your verdict.** A structured submission states its
963
+ verdict as a field; a free-text reply states it in an `**Overall Verdict**`
964
+ header **on the first line, before anything else**. A header anywhere else is
965
+ not read as yours — it may be a sample you are discussing — so quoting verdict
966
+ headers further down is safe and needs no escaping.
967
+
968
+ Three rounds tried to tell a stated header from a displayed one by reading the
969
+ text more cleverly, and each attempt opened a hole in the direction that
970
+ passes: round 4 anchored the search to the start of a line, and a line inside
971
+ a fence starts a line; round 6 excluded fenced and blockquoted regions, and a
972
+ four-backtick fence closes on the inner three-backtick line, taking the rest
973
+ of the reply — including the real verdict — with it. A quotation and a
974
+ statement are the same characters, so no amount of reading decides between
975
+ them. Position does, because a quotation cannot be first unless the reviewer
976
+ chose to open with one.
977
+
978
+ A reply that opens with a preamble falls to a last-resort word scan over the
979
+ whole text. That is a guess, and it reads quotations along with everything
980
+ else — but it checks REJECT first, so it cannot turn a stated rejection into
981
+ a recorded approval.
982
+ - **The words that count as a verdict are one list, the same for every
983
+ observer.** `APPROVE / PASS / ACCEPT / LGTM / SHIP IT`,
984
+ `REJECT / FAIL / BLOCK / NO-GO / NACK / DENY / VETO`,
985
+ `REVISE / CHANGES REQUIRED / NEEDS WORK / NEEDS REVISION / REWORK`. Where a
986
+ reply carries more than one, the blocking reading wins (REJECT, then REVISE,
987
+ then APPROVE) — a reply saying two of them has qualified one judgement, not
988
+ stated two. Japanese verdict words are deliberately **not** in this list
989
+ (author decision, 2026-07-28): a Japanese review counts on its findings, but
990
+ its judgement must be stated in one of the words above.
891
991
  - It is **not** a quality bar, and must not be tuned into one. `競合状態あり` is a
892
992
  review. So is `REJECT: P0 private key logged in plaintext`. Earlier versions of
893
993
  this rule measured length and, at one setting, discarded Japanese reviews
894
994
  entirely — reviewers answer in the artifact's language, and this project's
895
995
  artifacts are largely Japanese.
896
- - What leaves the denominator is recorded with the reason: `skip_reason` reads
897
- `insubstantial` or `transport`, per reviewer and again in
898
- `denominator_composition`. A slot that answered emptily and a slot that never
899
- answered are both absent from the numerator and must never be confused in the
900
- record.
996
+ - What leaves the denominator is recorded with the reason, per reviewer and
997
+ again in `denominator_composition`. A slot that answered emptily and a slot
998
+ that never answered are both absent from the numerator and must never be
999
+ confused in the record, so `skip_reason` distinguishes: `insubstantial` (a
1000
+ reply that said nothing beyond its verdict), `no_verdict` (a reply that stated
1001
+ no judgement), `transport` (a call that was attempted and failed),
1002
+ `not_dispatched` or the dispatcher's own wording such as `dispatch_timeout`
1003
+ (a slot this system declined to run), and `declined` (a submission that stated
1004
+ SKIP for itself — reachable only by a caller that declares it, which nothing
1005
+ in this repository currently does). Only a token-shaped reason is carried
1006
+ through; anything else becomes `not_dispatched`, so a traceback or a sentence
1007
+ cannot land in a field documented as a small vocabulary. Nothing is defaulted:
1008
+ a row with no reason omits the field, because a default here states a cause
1009
+ the record does not know.
1010
+
1011
+ - **The escalation record is filled in only where it is wholly absent.** Pending
1012
+ state written before escalation existed carries no such field, and that one
1013
+ case is answered truthfully — a version with no escalation to offer cannot
1014
+ have been asked for it. A record that exists is carried as recorded, gaps and
1015
+ all: completing a partial one wrote `requested: false` beside
1016
+ `escalated: true`, a pair the producing code cannot emit. Silence about a key
1017
+ is legible to a reader; a value asserted where none was recorded is not.
1018
+ - The count beside these is `observers_reporting` — the observers that answered,
1019
+ which is not the number of slots the configuration named. That question is
1020
+ answered by `denominator_composition`, which lists every observer including
1021
+ the ones that never ran and why.
901
1022
 
902
1023
  **What this rule cannot do.** It cannot tell a terse honest approval from a lazy
903
1024
  one. `**Overall Verdict**: APPROVE / No findings` is the exact form the reviewer
@@ -1247,5 +1368,69 @@ Compression ratio: parallel agent raw → Assembly ≈ 2:1
1247
1368
  the human, is the primary close — a reached ratio is neither necessary nor
1248
1369
  sufficient on its own
1249
1370
 
1371
+ - Verdict determination hardened after a live failure (v3.7.1, 2026-07-27):
1372
+ round 4 of the escalation/persona implementation review recorded the persona
1373
+ team's REVISE as an APPROVE. The team's verdict was being re-derived by
1374
+ searching the assembled text, and a persona had quoted
1375
+ `{"overall_verdict": "APPROVE", ...}` inside a finding as an example of a
1376
+ defect; the search found the quotation before the `**Overall Verdict**:
1377
+ REVISE` on line 1. Three fixes: a structured submission now states its verdict
1378
+ as a field and nothing in its prose overrules it; the header is recognised
1379
+ only at the start of a line; and a JSON verdict is read by parsing a reply
1380
+ that *is* a JSON document rather than by scanning prose for an object. The
1381
+ residual limitation is recorded in § Substance and the denominator — the
1382
+ last-resort word heuristic still sees verdict words inside quotations, so
1383
+ state your verdict in the header when discussing reply shapes. Separately,
1384
+ INV-E2 was found to have been implemented by half: it asks that a counted
1385
+ reply *carry a verdict* and *have substance*, and only substance was checked,
1386
+ so an opening sentence with no judgement in it entered the denominator as a
1387
+ conservative REVISE — the exact shape that retired Fable 5, blocking
1388
+ convergence on nobody's verdict
1389
+
1390
+ - Three of round 4's own fixes reopened what they closed (v3.7.2, 2026-07-28):
1391
+ round 5 of the same implementation review found that each of the three
1392
+ verdict fixes recorded above had left a hole, and all three in the direction
1393
+ that passes. Start-of-line header matching does not exclude a quotation,
1394
+ because a line inside a fenced block starts a line — the header is now read
1395
+ from what the reviewer said, with fences and blockquotes excluded, and a
1396
+ reply that is entirely fenced is read whole because it is quoting nothing.
1397
+ Judging a structured reply by named keys (`reasoning`, `issue`) threw away a
1398
+ REJECT whose findings used `description` — the rule now asks whether anything
1399
+ was said in words, under any key. And the words that count as a verdict were
1400
+ two different lists, so `NO-GO` from a persona was a REJECT while `NO-GO`
1401
+ from an external slot was no judgement at all; there is now one list, with
1402
+ one precedence. Alongside these: `skip_reason` distinguishes five outcomes
1403
+ where it previously flattened three into `transport`; `total_configured` was
1404
+ renamed `observers_reporting` because it never counted what it claimed; the
1405
+ eased convergence rule after an exclusion now asks whether the denominator
1406
+ actually shrank rather than which reason fired; and a synchronous delegation
1407
+ writes the same storage layout as the parallel one, so the lock serialising
1408
+ two concurrent collects is no longer silently skipped
1409
+
1410
+ - Position replaces cleverness in verdict reading (v3.7.3, 2026-07-28): round 6
1411
+ was reviewed and rejected by five of six observers, all of them naming the
1412
+ same shape — the fixes of round 6 had reopened what they closed, in the
1413
+ direction that passes. Two were shipped defects: the header capture was
1414
+ written with `\s`, which includes the newline in Ruby, so it ran past the
1415
+ header into the following prose and recorded a stated APPROVE followed by
1416
+ "No blocking issues" as a REJECT; and the fence regex tracked neither fence
1417
+ length nor delimiter, so a four-backtick block quoting a three-backtick
1418
+ sample gave the quoted verdict the reviewer's vote. Rather than a fourth
1419
+ attempt at reading free text more carefully, the rule is now positional: the
1420
+ reviewer's verdict is the header the reply opens with, and the fence and
1421
+ blockquote machinery is deleted. The prompt asks for it there, and this test
1422
+ now pins that it does. Alongside: a partial escalation record is carried as
1423
+ recorded rather than completed with values the producing code cannot emit;
1424
+ only a token-shaped skip reason is carried into the record; and a row with no
1425
+ reason omits the field in the per-reviewer list as it already did in the
1426
+ composition.
1427
+
1428
+ The round's other lesson was about testing, and it is recorded here because
1429
+ it generalises past this SkillSet: **a regex pinned by one literal example is
1430
+ free everywhere else**. Round 6's own mutation pass replaced whole functions
1431
+ and killed 21 of 21; the review's mutation pass went after regex internals —
1432
+ fence markers, character classes, digit ranges, word boundaries — and 18 of
1433
+ 27 survived. Mutate the inside of a pattern, not only the pattern.
1434
+
1250
1435
  **Key insight**: Design reviews and implementation reviews find
1251
1436
  **categorically different bugs**. Both phases are necessary.
@@ -183,18 +183,40 @@ module KairosMcp
183
183
  result_text = data['result'] || ''
184
184
  tool_use = extract_tool_use(result_text)
185
185
  usage = data['usage'] || {}
186
+ model_usage = data['modelUsage'] || {}
187
+
188
+ # The answering model is the entry that produced the output tokens,
189
+ # not the first hash key. `keys.first` read whichever entry the CLI
190
+ # inserted first, so if the envelope ever carries a second model
191
+ # beside the main call, the reply could be attributed to the wrong
192
+ # one. Every envelope probed so far carries exactly one key, so the
193
+ # two readings have not yet disagreed — this is defence, not a
194
+ # reproduced defect.
195
+ observed = model_usage.max_by { |_m, u| (u || {})['outputTokens'].to_i }&.first
186
196
 
187
197
  {
188
198
  'content' => tool_use ? nil : result_text,
189
199
  'tool_use' => tool_use,
190
200
  'stop_reason' => tool_use ? 'tool_use' : map_stop_reason(data['stop_reason']),
191
- 'model' => requested_model || data.dig('modelUsage')&.keys&.first || 'claude_code',
201
+ 'model' => requested_model || observed || 'claude_code',
192
202
  # What the CLI reports as having answered, when it reports it.
193
203
  # Kept separate from 'model' (which echoes the request) so callers
194
204
  # can tell a request from an observation and notice when the two
195
205
  # disagree — the CLI may serve a different model than the one
196
- # asked for.
197
- 'model_observed' => data.dig('modelUsage')&.keys&.first,
206
+ # asked for. That is not hypothetical: four times in the
207
+ # multi_llm_review loop (R6/R8/R10/R13, 2026-07) a slot requesting
208
+ # claude-opus-4-6 was answered by claude-haiku-4-5, and the R13
209
+ # reply's content confirmed the observation (it cited identifiers
210
+ # that exist nowhere in the reviewed code).
211
+ 'model_observed' => observed,
212
+ # Diagnostic envelope, previously discarded. All four divergence
213
+ # incidents above were undiagnosable from the record because the
214
+ # fields that say what happened did not survive this method.
215
+ # Consumers that persist reviews should carry these through.
216
+ 'model_usage' => model_usage.empty? ? nil : model_usage,
217
+ 'api_error_status' => data['api_error_status'],
218
+ 'fast_mode_state' => data['fast_mode_state'],
219
+ 'terminal_reason' => data['terminal_reason'],
198
220
  'input_tokens' => usage['input_tokens'],
199
221
  'output_tokens' => usage['output_tokens']
200
222
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm_client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pure LLM provider abstraction. One API call, returns response. No loop, no retry, no fallback.",
5
5
  "author": "Masaomi Hatakeyama",
6
6
  "layer": "L1",
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What parse_response reads from the CLI envelope, pinned after the
4
+ # claude_cli_opus4.6 divergence investigation (2026-07-31).
5
+ #
6
+ # Four times in the multi_llm_review loop (R6/R8/R10/R13) a slot requesting
7
+ # claude-opus-4-6 was answered by claude-haiku-4-5. The record could not say
8
+ # why, because this method discarded every diagnostic field the envelope
9
+ # carries (modelUsage breakdown, api_error_status, fast_mode_state,
10
+ # terminal_reason). The R13 reply's own content settled the observation side
11
+ # — it cited identifiers that exist nowhere in the reviewed code — but the
12
+ # cause remains open precisely because nothing was kept. These tests hold the
13
+ # two fixes: the answering model is chosen by output tokens rather than hash
14
+ # order, and the diagnostic fields survive into the response.
15
+
16
+ require 'minitest/autorun'
17
+ require_relative '../lib/llm_client/adapter'
18
+ require_relative '../lib/llm_client/claude_code_adapter'
19
+
20
+ module KairosMcp
21
+ module SkillSets
22
+ module LlmClient
23
+ class TestClaudeCodeAdapterParse < Minitest::Test
24
+ def setup
25
+ @adapter = ClaudeCodeAdapter.new({ 'timeout_seconds' => 30 })
26
+ end
27
+
28
+ def parse(payload, requested_model: 'claude-opus-4-6')
29
+ @adapter.send(:parse_response, JSON.generate(payload), requested_model: requested_model)
30
+ end
31
+
32
+ def envelope(model_usage:, **extra)
33
+ {
34
+ 'type' => 'result', 'is_error' => false, 'result' => 'ok',
35
+ 'stop_reason' => 'end_turn',
36
+ 'usage' => { 'input_tokens' => 10, 'output_tokens' => 20 },
37
+ 'modelUsage' => model_usage
38
+ }.merge(extra)
39
+ end
40
+
41
+ def test_a_single_model_envelope_is_that_model
42
+ out = parse(envelope(model_usage: {
43
+ 'claude-opus-4-6' => { 'outputTokens' => 116 }
44
+ }))
45
+
46
+ assert_equal 'claude-opus-4-6', out['model_observed']
47
+ end
48
+
49
+ # The regression the old `keys.first` invited: if the envelope ever
50
+ # carries a second model beside the main call, insertion order must
51
+ # not decide which one "answered". The output tokens do.
52
+ def test_the_answering_model_is_the_one_that_wrote_the_output
53
+ out = parse(envelope(model_usage: {
54
+ 'claude-haiku-4-5-20251001' => { 'outputTokens' => 12 },
55
+ 'claude-opus-4-6' => { 'outputTokens' => 2048 }
56
+ }))
57
+
58
+ assert_equal 'claude-opus-4-6', out['model_observed']
59
+ end
60
+
61
+ def test_a_divergent_answer_is_observed_as_itself
62
+ out = parse(envelope(model_usage: {
63
+ 'claude-haiku-4-5-20251001' => { 'outputTokens' => 900 }
64
+ }))
65
+
66
+ assert_equal 'claude-haiku-4-5-20251001', out['model_observed']
67
+ assert_equal 'claude-opus-4-6', out['model']
68
+ refute_equal out['model'], out['model_observed']
69
+ end
70
+
71
+ def test_an_envelope_without_model_usage_observes_nothing
72
+ out = parse(envelope(model_usage: nil))
73
+
74
+ assert_nil out['model_observed']
75
+ assert_nil out['model_usage']
76
+ assert_equal 'claude-opus-4-6', out['model']
77
+ end
78
+
79
+ def test_the_diagnostic_fields_survive_into_the_response
80
+ out = parse(envelope(
81
+ model_usage: { 'claude-haiku-4-5-20251001' => { 'outputTokens' => 900 } },
82
+ 'api_error_status' => 429,
83
+ 'fast_mode_state' => 'off',
84
+ 'terminal_reason' => 'completed'
85
+ ))
86
+
87
+ assert_equal({ 'claude-haiku-4-5-20251001' => { 'outputTokens' => 900 } }, out['model_usage'])
88
+ assert_equal 429, out['api_error_status']
89
+ assert_equal 'off', out['fast_mode_state']
90
+ assert_equal 'completed', out['terminal_reason']
91
+ end
92
+
93
+ def test_a_missing_output_tokens_entry_does_not_crash_the_choice
94
+ out = parse(envelope(model_usage: {
95
+ 'claude-opus-4-6' => nil,
96
+ 'claude-haiku-4-5-20251001' => { 'outputTokens' => 5 }
97
+ }))
98
+
99
+ assert_equal 'claude-haiku-4-5-20251001', out['model_observed']
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end