diff --git a/README.md b/README.md index 88e2001..5092d2d 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Context Compiler is a deterministic conversational state authority for LLM applications. It handles canonical directive execution, semantic validation, deterministic -clarification and confirmation flows, checkpoint restore, and structured -authoritative state for the host. +clarify decisions, checkpoint restore, and structured authoritative state for +the host. ## What Context Compiler provides @@ -15,8 +15,7 @@ Context Compiler gives hosts fixed state rules: - handle canonical explicit state changes with deterministic rules - clarification instead of silent overwrite for blocked/ambiguous changes -- pending confirmation flows that must resolve before anything else changes -- export and import checkpoints to restore saved state and pending confirmation flow +- export and import checkpoints to restore saved state in a versioned engine snapshot - produce structured authoritative state for downstream host decisions The model generates responses. The compiler owns state. @@ -178,12 +177,12 @@ pip install context-compiler context-compiler ``` -Preload options keep saved rules separate from confirmation state in progress: +Preload options keep authoritative state transport separate from checkpoint session snapshots: - `--initial-state-json` / `--initial-state-file` load saved state (via exported state JSON). -- `--initial-checkpoint-json` / `--initial-checkpoint-file` restore full - continuation checkpoint (saved state + pending confirmation state). +- `--initial-checkpoint-json` / `--initial-checkpoint-file` restore the full + checkpoint envelope (saved state plus reserved continuation field). REPL commands (controller layer, not engine directives): @@ -202,12 +201,12 @@ for non-interactive usage. context-compiler --json < input.txt ``` -Preload options keep saved rules separate from confirmation state in progress: +Preload options keep authoritative state transport separate from checkpoint session snapshots: - `--initial-state-json` / `--initial-state-file` load saved state (via exported state JSON). -- `--initial-checkpoint-json` / `--initial-checkpoint-file` restore full - continuation checkpoint (saved state + pending confirmation state). +- `--initial-checkpoint-json` / `--initial-checkpoint-file` restore the full + checkpoint envelope (saved state plus reserved continuation field). ## Installation @@ -359,13 +358,12 @@ the compiler. `export_json()` / `import_json()` and the checkpoint APIs serve different boundaries: - `export_json()` / `import_json()` transport **authoritative state only** -- checkpoint APIs transport **serialized continuation**: +- checkpoint APIs transport a **versioned engine snapshot**: - authoritative state - - pending confirmation flow state + - reserved `pending` field for canonical continuation compatibility Use state JSON when you only need authoritative state. Use checkpoint APIs when -you also need resumable continuation state across process or request -boundaries. +you want the stable checkpoint envelope across process or request boundaries. For the checkpoint object shape, API-level usage notes, and serialization details, see [docs/api-reference.md](docs/api-reference.md#checkpoint-apis). @@ -405,7 +403,7 @@ User: clear state Grammar invariant: one input may contain at most one canonical directive. If another canonical directive start appears later in the same input, the input is invalid and Context Compiler returns `clarify` without mutating -authoritative state or creating pending confirmation state. +authoritative state or creating pending continuation state. Examples: diff --git a/demos/09_llm_pending_clarification.py b/demos/09_llm_pending_clarification.py index 08173ba..3c0dfc8 100644 --- a/demos/09_llm_pending_clarification.py +++ b/demos/09_llm_pending_clarification.py @@ -1,8 +1,8 @@ -"""Demo 9: pending clarification requires confirmation-only continuation.""" +"""Demo 9: invalid replacement does not create core pending continuation.""" from context_compiler import ( DECISION_CLARIFY, - DECISION_UPDATE, + DECISION_PASSTHROUGH, POLICY_USE, State, create_engine, @@ -22,7 +22,7 @@ ) from demos.llm_client import complete_messages -DEMO_NAME = "09_pending_clarification_continuation — confirmation-only state transition" +DEMO_NAME = "09_pending_clarification_boundary — invalid replacement stays non-pending" TURN_1 = "use podman instead of docker" TURN_2 = "maybe" TURN_3 = "yes" @@ -46,6 +46,7 @@ def main() -> None: print_decision("turn 1", first, engine.state) pending_after_first = engine.has_pending_clarification() state_unchanged_after_first = _is_initial_authoritative_state(engine.state) + checkpoint_after_first = engine.export_checkpoint() second = engine.step(TURN_2) print_decision("turn 2", second, engine.state) @@ -95,27 +96,31 @@ def main() -> None: print_model_output("Compiler-mediated + compact", compact_output) else: print_messages("compiler-mediated + compact", []) - compact_output = "[no call] compact replay resolved pending flow" + compact_output = "[no call] compact replay did not create pending continuation" print_model_output("Compiler-mediated + compact", compact_output) blocked_initial_mutation = first["kind"] == DECISION_CLARIFY and state_unchanged_after_first - pending_preserved_on_nonconfirm = ( - second["kind"] == DECISION_CLARIFY - and pending_after_first - and pending_after_second - and state_unchanged_after_first + no_pending_after_invalid_replacement = ( + not pending_after_first and checkpoint_after_first["pending"] is None + ) + unrelated_followup_passthrough = ( + second["kind"] == DECISION_PASSTHROUGH + and not pending_after_second and state_unchanged_after_second ) - confirmation_only_resolution = third["kind"] == DECISION_UPDATE and not pending_after_third - deterministic_final_state = _has_podman_use(engine.state) + confirmation_token_not_consumed = ( + third["kind"] == DECISION_PASSTHROUGH and not pending_after_third + ) + deterministic_final_state = not _has_podman_use(engine.state) baseline_has_pending_state_machine = False reinjected_has_pending_state_machine = False compiler_pass = ( blocked_initial_mutation - and pending_preserved_on_nonconfirm - and confirmation_only_resolution + and no_pending_after_invalid_replacement + and unrelated_followup_passthrough + and confirmation_token_not_consumed and deterministic_final_state ) @@ -131,17 +136,22 @@ def main() -> None: context="compiler-mediated", ) print_host_check( - "PENDING_PRESERVED_ON_NONCONFIRM", - yes_no(pending_preserved_on_nonconfirm), + "NO_PENDING_AFTER_INVALID_REPLACEMENT", + yes_no(no_pending_after_invalid_replacement), + context="compiler-mediated", + ) + print_host_check( + "UNRELATED_FOLLOWUP_PASSTHROUGH", + yes_no(unrelated_followup_passthrough), context="compiler-mediated", ) print_host_check( - "CONFIRMATION_ONLY_RESOLUTION", - yes_no(confirmation_only_resolution), + "CONFIRMATION_TOKEN_NOT_CONSUMED", + yes_no(confirmation_token_not_consumed), context="compiler-mediated", ) print_host_check( - "FINAL_POLICY_PODMAN_USE", + "FINAL_POLICY_PODMAN_ABSENT", yes_no(deterministic_final_state), context="compiler-mediated", ) @@ -153,18 +163,18 @@ def main() -> None: compiler_pass=compiler_pass, compiler_compact_pass=compact_pass, expected=( - "replacement flow should require pending clarification, preserve pending on " - "non-confirmation, and apply only on confirmation" + "invalid replacement should clarify without creating pending continuation, " + "and later yes/no-style input should not resolve a synthesized operation" ), actual=( - "compiler enforced blocked mutation, pending continuation, and confirmation-only " - "resolution to final podman policy" + "compiler blocked mutation, kept checkpoint pending null, and treated later " + "inputs as ordinary passthrough" if compiler_pass and compact_pass - else "compiler did not consistently enforce pending clarification continuation" + else "compiler did not consistently preserve the non-pending replacement boundary" ), passed=compiler_pass and compact_pass, - result_pass="pending clarification continuation enforced deterministically", - result_fail="pending clarification continuation not enforced deterministically", + result_pass="invalid replacement stayed outside core pending continuation", + result_fail="invalid replacement still behaved like core pending continuation", ) diff --git a/demos/README.md b/demos/README.md index c1e9fb5..67a6849 100644 --- a/demos/README.md +++ b/demos/README.md @@ -36,7 +36,7 @@ Runnable application-layer enforcement-point integrations live in | [06](./06_llm_context_compaction.py) | Context compaction | saved compiler state replacing transcript context | small or local models | | [07](./07_llm_prompt_vs_state.py) | Prompt engineering comparison | prompting vs saved compiler state | any model with long transcript sensitivity | | [08](./08_llm_replacement_precondition.py) | Replacement precondition | invalid replacement blocked without state mutation | any model | -| [09](./09_llm_pending_clarification.py) | Pending clarification continuation | confirmation-only resolution of suspended mutation | any model | +| [09](./09_llm_pending_clarification.py) | Pending boundary | invalid replacement does not become pending continuation | any model | Stronger frontier models may show these behaviors less often, but the same patterns still appear in real applications. @@ -171,7 +171,7 @@ Notes: - Anthropic runs in this repo are executed through the `openai_compatible` provider path. - `PASS` means the demo-specific expected-behavior check for that path succeeded; `FAIL` means it did not. - `reinjected-state` can be enough for some persistence cases; in this demo set it is intentionally used as a prompt-only comparison baseline. -- Scored checks focus on app-side authority rules (for example blocked mutation and confirmation-only resolution), not model prose quality. `reinjected-state` remains plain text injection only. +- Scored checks focus on app-side authority rules (for example blocked mutation and non-pending invalid replacement handling), not model prose quality. `reinjected-state` remains plain text injection only. - Interpretation: - Demos `01`-`05` and `07` mostly test persistence and policy-following behavior across turns. diff --git a/docs/DirectiveGrammarSpec.md b/docs/DirectiveGrammarSpec.md index d12c089..d17097e 100644 --- a/docs/DirectiveGrammarSpec.md +++ b/docs/DirectiveGrammarSpec.md @@ -314,6 +314,8 @@ When `Decision.kind = "clarify"`, prompt text is deterministic only for the case `Replacement requires an active 'use' policy.` - Pending clarification unmatched input (Section 9 case 11): reuse the existing pending prompt unchanged. + In the current engine contract, no canonical operation produces pending + clarification state, so this case is reserved but unreachable. - `remove policy ITEM` with empty/whitespace-only payload (Section 9 case 12): `Policy item cannot be empty.` `Use 'remove policy ' with a non-empty value.` @@ -375,6 +377,14 @@ Resolution: - Negative token: discard pending event, clear pending, apply no mutation, return `update` with unchanged state - Any other input: remain in `clarify`, no mutation, and keep the existing pending prompt +Current engine contract note: + +- The current engine establishes no pending clarification state for any + canonical directive family. +- Section 10 remains the reserved core boundary for future canonical + continuation semantics and checkpoint compatibility. +- Non-null `pending` checkpoint payloads are not currently produced by core. + ## 11. Context Serialization Contract The compiler outputs structured state only; the host formats prompts. @@ -430,13 +440,14 @@ Checkpoint semantics: - `pending` captures confirmation-required continuation for supported canonical operations only. - `pending` is `null` when there is no outstanding continuation. +- In the current engine contract, `pending` is always `null`. - Restore is all-or-nothing: invalid checkpoint payloads raise and no partial state restore occurs. - Continuation behavior is deterministic after restore (same confirmation token handling and resolution outcomes as live pending state). - `checkpoint_version` is independent of authoritative state `version`; it must be bumped when checkpoint contract shape changes (especially `pending`). ## 12. Invariants -1. State changes only from valid canonical directive transitions or pending-confirmation acceptance for canonical operations. +1. State changes only from valid canonical directive transitions or pending-confirmation acceptance for canonical operations when such operations are supported by the active engine contract. 2. Same input sequence yields identical state and decisions. 3. LLM output never mutates state. 4. No mutation occurs when returning `clarify`. diff --git a/docs/api-reference.md b/docs/api-reference.md index d2f118d..7b20b47 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -86,7 +86,7 @@ Boundary notes: - validation returns `None` for any non-canonical input - rendering is syntax-only and performs no state interpretation - `engine.step(...)` remains the authority for clarification, state - transitions, pending confirmation, and mutation behavior + transitions, reserved canonical continuation semantics, and mutation behavior - `engine.step(...)` is not a general natural-language repair surface; host code should send canonical directives when it wants deterministic mutation - pending confirmation is limited to deterministic continuation of supported @@ -106,6 +106,9 @@ traversal unless you are working at an explicit serialization boundary. Return whether a confirmation-required clarification is currently pending. +In the current engine contract, this returns `False` because no canonical +operation currently produces pending clarification state. + Typical use: ```python @@ -213,10 +216,9 @@ Export a checkpoint as canonical JSON text. Validate and restore a checkpoint from JSON text. -Use checkpoint APIs when you need both: - -- authoritative state -- pending confirmation/continuation state +Use checkpoint APIs when you need a versioned engine-session snapshot. Today, +that means authoritative state plus a reserved `pending` field for canonical +continuation compatibility. Checkpoint object shape: @@ -241,6 +243,7 @@ API-level contract notes: - `pending` is `null` when no continuation is waiting for confirmation - `pending` captures confirmation-required continuation for canonical operations supported by the active engine contract +- in the current engine contract, `pending` is always `null` - non-canonical repair state is not part of the intended core contract - imported policy keys are normalized during `import_json(...)` and checkpoint authoritative-state restore @@ -255,8 +258,8 @@ API-level contract notes: Typical use cases: - stateless host or integration boundaries where engine instances are short-lived -- resume after interruption without losing pending clarification flow -- preserve pending confirmation flow state across process or request boundaries +- authoritative-state persistence with a stable checkpoint envelope +- future-compatible storage where hosts want one artifact for engine session snapshots ## Controller APIs diff --git a/docs/architecture.md b/docs/architecture.md index 700a98b..c1981fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,13 +8,13 @@ authority inside a larger host application stack. Responsibilities: - apply deterministic state transitions -- enforce clarification and confirmation gates for core-owned state semantics +- enforce deterministic clarification gates for core-owned state semantics - export/import authoritative state and checkpoints Examples: - Context Compiler core engine -- checkpoint continuation behavior +- checkpoint/session snapshot behavior Repository: @@ -29,8 +29,9 @@ Boundary: - core does not own human-facing normalization, malformed-input recovery, or intent inference as a general responsibility - core does not convert failed canonical operations into different directives -- pending yes/no confirmation is limited to deterministic continuation of +- pending yes/no confirmation is reserved for deterministic continuation of canonical operations already established by core +- the current engine contract produces no live pending continuation state ## Acquisition Layer @@ -108,8 +109,8 @@ Policy independence is a major contributor to: - cross-language conformance Because policies are independent flat assertions, directive semantics stay -simple, exported state stays portable, replay remains exact, and checkpoint -continuation does not depend on hidden relational logic. +simple, exported state stays portable, replay remains exact, and any future +checkpoint continuation does not depend on hidden relational logic. Relationship-heavy semantics may still be useful, but they generally belong in drafting, orchestration, or domain-specific layers rather than in diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index fa3b3af..f66c625 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -28,22 +28,10 @@ class State(TypedDict): version: Literal[2] -class CheckpointPendingReplacement(TypedDict): - kind: Literal["use_only"] - new_item: str - old_item: str | None - - -class CheckpointPending(TypedDict): - kind: Literal["replacement"] - replacement: CheckpointPendingReplacement - prompt_to_user: str - - class Checkpoint(TypedDict): checkpoint_version: Literal[1] authoritative_state: State - pending: NotRequired[CheckpointPending | None] + pending: NotRequired[None] class DecisionKind(StrEnum): @@ -64,8 +52,6 @@ class Action: "compound_directive_invalid", "set_premise", "change_premise", - "set_premise_to_variant", - "change_premise_missing_to_variant", "use_item", "prohibit_item", "remove_policy_item", @@ -81,13 +67,6 @@ class Action: old_item: str | None = None -@dataclass(frozen=True) -class PendingReplacement: - kind: Literal["use_only"] - new_item: str - old_item: str | None = None - - _PASSTHROUGH: Decision = { "kind": DecisionKind.PASSTHROUGH, "state": None, @@ -105,7 +84,6 @@ class PendingReplacement: _RESET_POLICIES = "reset policies" _CLEAR_STATE = "clear state" _REMOVE_POLICY_BASE = "remove policy" -_SET_PREMISE_TO_PREFIX = "set premise to " _CHANGE_PREMISE_PREFIX = "change premise " _CHANGE_PREMISE_BASE = "change premise to" _SET_PREMISE_BASE = "set premise" @@ -141,8 +119,6 @@ def get_policy_items(state: State, value: PolicyValue | None = None) -> list[str class Engine: def __init__(self, state: State | None = None) -> None: self._state: State - self._pending_replacement: PendingReplacement | None = None - self._pending_prompt: str | None = None self._replace_state(_initial_state() if state is None else _load_state_obj(state)) @property @@ -151,7 +127,7 @@ def state(self) -> State: def has_pending_clarification(self) -> bool: """Return whether a confirmation-required clarification is pending.""" - return self._pending_replacement is not None + return False def export_json(self) -> str: return json.dumps(self._state, sort_keys=True, separators=(",", ":")) @@ -160,32 +136,17 @@ def import_json(self, payload: str) -> None: self._replace_state(_load_state_json(payload)) def export_checkpoint(self) -> Checkpoint: - pending: CheckpointPending | None = None - if self._pending_replacement is not None: - assert self._pending_prompt is not None - pending = { - "kind": "replacement", - "replacement": { - "kind": self._pending_replacement.kind, - "new_item": self._pending_replacement.new_item, - "old_item": self._pending_replacement.old_item, - }, - "prompt_to_user": self._pending_prompt, - } - # Reuse authoritative export/import path for canonicalized state payload. authoritative_state = _load_state_json(self.export_json()) return { "checkpoint_version": _CHECKPOINT_VERSION, "authoritative_state": authoritative_state, - "pending": pending, + "pending": None, } def import_checkpoint(self, payload: Checkpoint) -> None: - state, pending_replacement, pending_prompt = _load_checkpoint_obj(payload) + state = _load_checkpoint_obj(payload) self._replace_state(state) - self._pending_replacement = pending_replacement - self._pending_prompt = pending_prompt def export_checkpoint_json(self) -> str: return json.dumps(self.export_checkpoint(), sort_keys=True, separators=(",", ":")) @@ -199,9 +160,6 @@ def import_checkpoint_json(self, payload: str) -> None: self.import_checkpoint(raw) def step(self, user_input: str) -> Decision: - if self._pending_replacement is not None: - return self._resolve_or_reprompt_pending(user_input) - action = _parse_directive(user_input) if action is None: return _PASSTHROUGH.copy() @@ -214,28 +172,6 @@ def step(self, user_input: str) -> Decision: def _replace_state(self, state: State) -> None: self._state = state - self._pending_replacement = None - self._pending_prompt = None - - def _resolve_or_reprompt_pending(self, user_input: str) -> Decision: - assert self._pending_replacement is not None - normalized = _normalize_confirmation(user_input) - - if normalized in _AFFIRMATIVE_CONFIRMATIONS: - pending = self._pending_replacement - self._pending_replacement = None - self._pending_prompt = None - new_key = _normalize_item(pending.new_item) - self._state[STATE_POLICIES][new_key] = POLICY_USE - return _update_decision(self._state) - - if normalized in _NEGATIVE_CONFIRMATIONS: - self._pending_replacement = None - self._pending_prompt = None - return _update_decision(self._state) - - assert self._pending_prompt is not None - return _clarify(self._pending_prompt) def _pre_mutation_clarify(self, action: Action) -> Decision | None: # Single clarify path: all clarify outcomes are detected before any mutation. @@ -255,14 +191,6 @@ def _pre_mutation_clarify(self, action: Action) -> Decision | None: "Use 'change premise to ' with a non-empty value." ) - if action.kind == "set_premise_to_variant": - assert action.value is not None - return _clarify(f"Did you mean 'set premise {action.value}'?") - - if action.kind == "change_premise_missing_to_variant": - assert action.value is not None - return _clarify(f"Did you mean 'change premise to {action.value}'?") - if action.kind == "remove_policy_item": assert action.item is not None if _normalize_item(action.item) == "": @@ -324,14 +252,6 @@ def _pre_mutation_clarify(self, action: Action) -> Decision | None: old_state = self._state[STATE_POLICIES].get(old_key) new_state = self._state[STATE_POLICIES].get(new_key) - if old_key not in self._state[STATE_POLICIES]: - prompt = f'Did you mean to use "{action.new_item}" instead?' - self._pending_replacement = PendingReplacement( - kind="use_only", - new_item=action.new_item, - ) - self._pending_prompt = prompt - return _clarify(prompt) if old_state == POLICY_PROHIBIT: return _clarify( f'"{action.old_item}" is currently prohibited.\n' @@ -428,19 +348,12 @@ def _parse_directive(user_input: str) -> Action | None: if user_input.startswith(remove_policy_prefix): return Action(kind="remove_policy_item", item=user_input[len(remove_policy_prefix) :]) - if user_input.startswith(_SET_PREMISE_TO_PREFIX): - value = user_input[len(_SET_PREMISE_TO_PREFIX) :].strip() - if value != "": - return Action(kind="set_premise_to_variant", value=value) - if ( user_input.startswith(_CHANGE_PREMISE_PREFIX) and not user_input.startswith(f"{_CHANGE_PREMISE_BASE} ") and user_input != _CHANGE_PREMISE_BASE ): - value = user_input[len(_CHANGE_PREMISE_PREFIX) :].strip() - if value != "": - return Action(kind="change_premise_missing_to_variant", value=value) + return None if user_input == _SET_PREMISE_BASE: return Action(kind="set_premise", value="") @@ -585,7 +498,7 @@ def _load_state_obj(raw: object) -> State: } -def _load_checkpoint_obj(raw: object) -> tuple[State, PendingReplacement | None, str | None]: +def _load_checkpoint_obj(raw: object) -> State: if not isinstance(raw, dict): raise ValueError("Invalid checkpoint payload.") @@ -601,49 +514,14 @@ def _load_checkpoint_obj(raw: object) -> tuple[State, PendingReplacement | None, raise ValueError(f"Unsupported checkpoint version: {checkpoint_version!r}") authoritative_state = _load_state_obj(raw["authoritative_state"]) - pending_replacement, pending_prompt = _load_checkpoint_pending_obj(raw.get("pending")) - return authoritative_state, pending_replacement, pending_prompt + _load_checkpoint_pending_obj(raw.get("pending")) + return authoritative_state -def _load_checkpoint_pending_obj( - raw: object, -) -> tuple[PendingReplacement | None, str | None]: +def _load_checkpoint_pending_obj(raw: object) -> None: if raw is None: - return None, None - if not isinstance(raw, dict): - raise ValueError("Invalid checkpoint payload.") - if set(raw.keys()) != {"kind", "replacement", "prompt_to_user"}: - raise ValueError("Invalid checkpoint payload.") - if raw["kind"] != "replacement": - raise ValueError("Invalid checkpoint payload.") - - prompt = raw["prompt_to_user"] - if not isinstance(prompt, str): - raise ValueError("Invalid checkpoint payload.") - - replacement = _load_checkpoint_replacement_obj(raw["replacement"]) - return replacement, prompt - - -def _load_checkpoint_replacement_obj(raw: object) -> PendingReplacement: - if not isinstance(raw, dict): - raise ValueError("Invalid checkpoint payload.") - if set(raw.keys()) != {"kind", "new_item", "old_item"}: - raise ValueError("Invalid checkpoint payload.") - - kind = raw["kind"] - new_item = raw["new_item"] - old_item = raw["old_item"] - - if kind != "use_only": - raise ValueError("Invalid checkpoint payload.") - if not isinstance(new_item, str): - raise ValueError("Invalid checkpoint payload.") - if _normalize_item(new_item) == "": - raise ValueError("Invalid checkpoint payload.") - if old_item is not None: - raise ValueError("Invalid checkpoint payload.") - return PendingReplacement(kind=kind, new_item=new_item, old_item=None) + return + raise ValueError("Invalid checkpoint payload.") def _sanitize_premise_value(value: str) -> str: diff --git a/tests/fixtures/conformance/checkpoint/005_import_checkpoint_invalid_authoritative_state_rejected_atomically.json b/tests/fixtures/conformance/checkpoint/005_import_checkpoint_invalid_authoritative_state_rejected_atomically.json index a792a65..c0fbf37 100644 --- a/tests/fixtures/conformance/checkpoint/005_import_checkpoint_invalid_authoritative_state_rejected_atomically.json +++ b/tests/fixtures/conformance/checkpoint/005_import_checkpoint_invalid_authoritative_state_rejected_atomically.json @@ -26,7 +26,7 @@ "type": "ValueError", "message_contains": "Invalid state payload" }, - "has_pending_clarification": true, + "has_pending_clarification": false, "state": { "premise": null, "policies": {}, @@ -35,9 +35,9 @@ "followup": { "input": "maybe", "decision": { - "kind": "clarify", + "kind": "passthrough", "state": null, - "prompt_to_user": "Did you mean to use \"kubectl\" instead?" + "prompt_to_user": null }, "state": { "premise": null, diff --git a/tests/fixtures/conformance/checkpoint/010_checkpoint_json_round_trip_state_and_pending_parity.json b/tests/fixtures/conformance/checkpoint/010_checkpoint_json_round_trip_state_and_pending_parity.json index 43b9809..36cd554 100644 --- a/tests/fixtures/conformance/checkpoint/010_checkpoint_json_round_trip_state_and_pending_parity.json +++ b/tests/fixtures/conformance/checkpoint/010_checkpoint_json_round_trip_state_and_pending_parity.json @@ -18,20 +18,20 @@ "policies": {}, "version": 2 }, - "has_pending_clarification": true, + "has_pending_clarification": false, "followup": { "input": "maybe", "decision": { - "kind": "clarify", + "kind": "passthrough", "state": null, - "prompt_to_user": "Did you mean to use \"kubectl\" instead?" + "prompt_to_user": null }, "state": { "premise": null, "policies": {}, "version": 2 }, - "has_pending_clarification": true + "has_pending_clarification": false } } } diff --git a/tests/fixtures/conformance/checkpoint/011_pending_clarification_through_checkpoint_json_restore_yes_resolution.json b/tests/fixtures/conformance/checkpoint/011_pending_clarification_through_checkpoint_json_restore_yes_resolution.json index 15a5f55..ba8f5d1 100644 --- a/tests/fixtures/conformance/checkpoint/011_pending_clarification_through_checkpoint_json_restore_yes_resolution.json +++ b/tests/fixtures/conformance/checkpoint/011_pending_clarification_through_checkpoint_json_restore_yes_resolution.json @@ -18,25 +18,17 @@ "policies": {}, "version": 2 }, - "has_pending_clarification": true, + "has_pending_clarification": false, "followup": { "input": "yes", "decision": { - "kind": "update", - "state": { - "premise": null, - "policies": { - "kubectl": "use" - }, - "version": 2 - }, + "kind": "passthrough", + "state": null, "prompt_to_user": null }, "state": { "premise": null, - "policies": { - "kubectl": "use" - }, + "policies": {}, "version": 2 }, "has_pending_clarification": false diff --git a/tests/fixtures/conformance/checkpoint/015_import_checkpoint_json_invalid_payload_is_all_or_nothing.json b/tests/fixtures/conformance/checkpoint/015_import_checkpoint_json_invalid_payload_is_all_or_nothing.json index 4fbf821..da0a901 100644 --- a/tests/fixtures/conformance/checkpoint/015_import_checkpoint_json_invalid_payload_is_all_or_nothing.json +++ b/tests/fixtures/conformance/checkpoint/015_import_checkpoint_json_invalid_payload_is_all_or_nothing.json @@ -18,7 +18,7 @@ "type": "ValueError", "message_contains": "Invalid checkpoint payload" }, - "has_pending_clarification": true, + "has_pending_clarification": false, "state": { "premise": null, "policies": {}, @@ -27,16 +27,16 @@ "followup": { "input": "maybe", "decision": { - "kind": "clarify", + "kind": "passthrough", "state": null, - "prompt_to_user": "Did you mean to use \"kubectl\" instead?" + "prompt_to_user": null }, "state": { "premise": null, "policies": {}, "version": 2 }, - "has_pending_clarification": true + "has_pending_clarification": false } } } diff --git a/tests/fixtures/conformance/controller/004_preview_clarify_reports_non_mutating_and_restores_pending.json b/tests/fixtures/conformance/controller/004_preview_clarify_reports_non_mutating_and_restores_pending.json index 1576151..7ad6a6d 100644 --- a/tests/fixtures/conformance/controller/004_preview_clarify_reports_non_mutating_and_restores_pending.json +++ b/tests/fixtures/conformance/controller/004_preview_clarify_reports_non_mutating_and_restores_pending.json @@ -17,7 +17,7 @@ "decision": { "kind": "clarify", "state": null, - "prompt_to_user": "Did you mean to use \"kubectl\" instead?" + "prompt_to_user": "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." }, "state_before": { "premise": null, diff --git a/tests/fixtures/conformance/controller/005_preview_pending_yes_reports_mutation_but_preserves_live_pending.json b/tests/fixtures/conformance/controller/005_preview_pending_yes_reports_mutation_but_preserves_live_pending.json index 26848db..6c860e7 100644 --- a/tests/fixtures/conformance/controller/005_preview_pending_yes_reports_mutation_but_preserves_live_pending.json +++ b/tests/fixtures/conformance/controller/005_preview_pending_yes_reports_mutation_but_preserves_live_pending.json @@ -18,14 +18,8 @@ "output_version": 1, "mode": "preview", "decision": { - "kind": "update", - "state": { - "premise": null, - "policies": { - "kubectl": "use" - }, - "version": 2 - }, + "kind": "passthrough", + "state": null, "prompt_to_user": null }, "state_before": { @@ -35,33 +29,29 @@ }, "state_after": { "premise": null, - "policies": { - "kubectl": "use" - }, + "policies": {}, "version": 2 }, "diff": { - "changed": true, + "changed": false, "premise": { "before": null, "after": null, "changed": false }, "policies": { - "added": { - "kubectl": "use" - }, + "added": {}, "removed": {}, "changed": {} } }, - "would_mutate": true + "would_mutate": false }, "state_after_preview": { "premise": null, "policies": {}, "version": 2 }, - "has_pending_clarification": true + "has_pending_clarification": false } } diff --git a/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json b/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json index 8b958d1..b62c77a 100644 --- a/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json +++ b/tests/fixtures/conformance/step/006_near_miss_set_premise_to.json @@ -9,12 +9,16 @@ "input": "set premise to concise", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Did you mean 'set premise concise'?", - "state": null + "kind": "update", + "prompt_to_user": null, + "state": { + "premise": "to concise", + "policies": {}, + "version": 2 + } }, "state": { - "premise": null, + "premise": "to concise", "policies": {}, "version": 2 } diff --git a/tests/fixtures/conformance/step/007_near_miss_change_premise_missing_to.json b/tests/fixtures/conformance/step/007_near_miss_change_premise_missing_to.json index 8246f82..b6c6dd2 100644 --- a/tests/fixtures/conformance/step/007_near_miss_change_premise_missing_to.json +++ b/tests/fixtures/conformance/step/007_near_miss_change_premise_missing_to.json @@ -9,8 +9,8 @@ "input": "change premise concise", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Did you mean 'change premise to concise'?", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, "state": { diff --git a/tests/fixtures/conformance/step/008_replace_missing_source_clarify_prompt.json b/tests/fixtures/conformance/step/008_replace_missing_source_clarify_prompt.json index b244831..3ece4f0 100644 --- a/tests/fixtures/conformance/step/008_replace_missing_source_clarify_prompt.json +++ b/tests/fixtures/conformance/step/008_replace_missing_source_clarify_prompt.json @@ -10,10 +10,10 @@ "expected": { "decision": { "kind": "clarify", - "prompt_to_user": "Did you mean to use \"kubectl\" instead?", + "prompt_to_user": "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy.", "state": null }, - "has_pending_clarification": true, + "has_pending_clarification": false, "state": { "premise": null, "policies": {}, diff --git a/tests/fixtures/conformance/step/009_pending_affirmative_normalized_token.json b/tests/fixtures/conformance/step/009_pending_affirmative_normalized_token.json index 6a56874..741a295 100644 --- a/tests/fixtures/conformance/step/009_pending_affirmative_normalized_token.json +++ b/tests/fixtures/conformance/step/009_pending_affirmative_normalized_token.json @@ -12,22 +12,14 @@ "input": " YES!!! ", "expected": { "decision": { - "kind": "update", + "kind": "passthrough", "prompt_to_user": null, - "state": { - "premise": null, - "policies": { - "kubectl": "use" - }, - "version": 2 - } + "state": null }, "has_pending_clarification": false, "state": { "premise": null, - "policies": { - "kubectl": "use" - }, + "policies": {}, "version": 2 } } diff --git a/tests/fixtures/conformance/step/010_pending_negative_normalized_token.json b/tests/fixtures/conformance/step/010_pending_negative_normalized_token.json index 40d2b00..e53fd8e 100644 --- a/tests/fixtures/conformance/step/010_pending_negative_normalized_token.json +++ b/tests/fixtures/conformance/step/010_pending_negative_normalized_token.json @@ -12,13 +12,9 @@ "input": "no thanks.", "expected": { "decision": { - "kind": "update", + "kind": "passthrough", "prompt_to_user": null, - "state": { - "premise": null, - "policies": {}, - "version": 2 - } + "state": null }, "has_pending_clarification": false, "state": { diff --git a/tests/fixtures/conformance/step/011_pending_unmatched_reuses_prompt.json b/tests/fixtures/conformance/step/011_pending_unmatched_reuses_prompt.json index 76f45d2..b3fabff 100644 --- a/tests/fixtures/conformance/step/011_pending_unmatched_reuses_prompt.json +++ b/tests/fixtures/conformance/step/011_pending_unmatched_reuses_prompt.json @@ -12,11 +12,11 @@ "input": "sounds good", "expected": { "decision": { - "kind": "clarify", - "prompt_to_user": "Did you mean to use \"kubectl\" instead?", + "kind": "passthrough", + "prompt_to_user": null, "state": null }, - "has_pending_clarification": true, + "has_pending_clarification": false, "state": { "premise": null, "policies": {}, diff --git a/tests/fixtures/conformance/step/018_pending_affirmative_punctuation_token.json b/tests/fixtures/conformance/step/018_pending_affirmative_punctuation_token.json index 0ec39f9..e67c2f1 100644 --- a/tests/fixtures/conformance/step/018_pending_affirmative_punctuation_token.json +++ b/tests/fixtures/conformance/step/018_pending_affirmative_punctuation_token.json @@ -12,22 +12,14 @@ "input": " okay!!! ", "expected": { "decision": { - "kind": "update", + "kind": "passthrough", "prompt_to_user": null, - "state": { - "premise": null, - "policies": { - "kubectl": "use" - }, - "version": 2 - } + "state": null }, "has_pending_clarification": false, "state": { "premise": null, - "policies": { - "kubectl": "use" - }, + "policies": {}, "version": 2 } } diff --git a/tests/fixtures/conformance/step/019_pending_negative_punctuation_token.json b/tests/fixtures/conformance/step/019_pending_negative_punctuation_token.json index 78fa6cd..fe0bb75 100644 --- a/tests/fixtures/conformance/step/019_pending_negative_punctuation_token.json +++ b/tests/fixtures/conformance/step/019_pending_negative_punctuation_token.json @@ -12,13 +12,9 @@ "input": " NOPE?? ", "expected": { "decision": { - "kind": "update", + "kind": "passthrough", "prompt_to_user": null, - "state": { - "premise": null, - "policies": {}, - "version": 2 - } + "state": null }, "has_pending_clarification": false, "state": { diff --git a/tests/fixtures/engine-regression/structured/expected/replacement_clarify.json b/tests/fixtures/engine-regression/structured/expected/replacement_clarify.json index a353d00..6cb9c87 100644 --- a/tests/fixtures/engine-regression/structured/expected/replacement_clarify.json +++ b/tests/fixtures/engine-regression/structured/expected/replacement_clarify.json @@ -9,19 +9,11 @@ "version": 2 }, "checkpoint_version": 1, - "pending": { - "kind": "replacement", - "prompt_to_user": "Did you mean to use \"podman\" instead?", - "replacement": { - "kind": "use_only", - "new_item": "podman", - "old_item": null - } - } + "pending": null }, "decision": { "kind": "clarify", - "prompt_to_user": "Did you mean to use \"podman\" instead?" + "prompt_to_user": "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." }, "input": "use podman instead of docker" } diff --git a/tests/test_04_grammar_edge_cases.py b/tests/test_04_grammar_edge_cases.py index 017cacb..ee1eef4 100644 --- a/tests/test_04_grammar_edge_cases.py +++ b/tests/test_04_grammar_edge_cases.py @@ -92,7 +92,7 @@ def test_exact_match_near_misses_are_passthrough() -> None: assert engine.state == {"premise": None, "policies": {}, "version": 2} -def test_premise_to_variant_near_misses_clarify_with_no_mutation() -> None: +def test_premise_to_variant_near_misses_do_not_trigger_repair() -> None: engine = create_engine() before = engine.state @@ -100,16 +100,12 @@ def test_premise_to_variant_near_misses_clarify_with_no_mutation() -> None: change_variant = engine.step("change premise concise") assert set_variant == { - "kind": "clarify", - "state": None, - "prompt_to_user": "Did you mean 'set premise concise'?", + "kind": "update", + "state": {"premise": "to concise", "policies": {}, "version": 2}, + "prompt_to_user": None, } - assert change_variant == { - "kind": "clarify", - "state": None, - "prompt_to_user": "Did you mean 'change premise to concise'?", - } - assert engine.state == before + assert change_variant == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert before == {"premise": None, "policies": {}, "version": 2} def test_remove_policy_missing_or_whitespace_payload_clarifies() -> None: @@ -125,26 +121,28 @@ def test_remove_policy_missing_or_whitespace_payload_clarifies() -> None: assert engine.state == before -def test_pending_blocks_directive_parsing_until_confirmation() -> None: +def test_invalid_replacement_does_not_block_following_directives() -> None: engine = create_engine() first = engine.step("use kubectl instead of docker") assert first["kind"] == "clarify" - # Would normally update, but pending must block parser. second = engine.step("set premise concise") - assert second == first + assert second == { + "kind": "update", + "state": {"premise": "concise", "policies": {}, "version": 2}, + "prompt_to_user": None, + } assert engine.state == { - "premise": None, + "premise": "concise", "policies": {}, "version": 2, } -def test_pending_rejects_non_confirmation_and_keeps_prompt() -> None: +def test_invalid_replacement_non_confirmation_followup_is_passthrough() -> None: engine = create_engine() first = engine.step("use kubectl instead of docker") second = engine.step("sounds good") - assert second == first - expected = 'Did you mean to use "kubectl" instead?' - assert second["prompt_to_user"] == expected + assert first["kind"] == "clarify" + assert second == {"kind": "passthrough", "state": None, "prompt_to_user": None} diff --git a/tests/test_controller.py b/tests/test_controller.py index 96189a2..43e6392 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -52,11 +52,17 @@ def test_preview_update_does_not_mutate_engine_state() -> None: assert engine.has_pending_clarification() is False -def test_preview_clarify_path_and_pending_are_restored() -> None: +def test_preview_clarify_path_does_not_create_pending_for_invalid_replacement() -> None: engine = create_engine() result = preview(engine, "use kubectl instead of docker") - assert result["decision"]["kind"] == "clarify" + assert result["decision"] == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ), + } assert result["state_before"] == result["state_after"] assert result["diff"]["changed"] is False assert result["would_mutate"] is False @@ -66,22 +72,6 @@ def test_preview_clarify_path_and_pending_are_restored() -> None: assert yes["kind"] == "passthrough" -def test_preview_pending_resolution_restores_existing_pending_state() -> None: - engine = create_engine() - first = engine.step("use kubectl instead of docker") - assert first["kind"] == "clarify" - assert engine.has_pending_clarification() is True - - result = preview(engine, "maybe") - assert result["decision"]["kind"] == "clarify" - assert result["would_mutate"] is False - assert engine.has_pending_clarification() is True - - yes = engine.step("yes") - assert yes["kind"] == "update" - assert engine.state == {"premise": None, "policies": {"kubectl": "use"}, "version": 2} - - def test_preview_prohibited_replacement_clarify_matches_execution_without_pending() -> None: engine = create_engine() engine.step("use docker") @@ -252,32 +242,34 @@ def test_controller_helpers_are_importable_from_package_root() -> None: assert callable(diff_has_changes) -@pytest.mark.parametrize( - ("confirmation", "expected_state", "expected_would_mutate"), - [ - ("yes", {"premise": None, "policies": {"kubectl": "use"}, "version": 2}, True), - ("no", {"premise": None, "policies": {}, "version": 2}, False), - ], -) -def test_preview_pending_confirmation_user_flow( +@pytest.mark.parametrize("confirmation", ["yes", "no"]) +def test_preview_followup_tokens_after_invalid_replacement_are_passthrough( confirmation: str, - expected_state: dict[str, object], - expected_would_mutate: bool, ) -> None: engine = create_engine() initial = engine.step("use kubectl instead of docker") - assert initial["kind"] == "clarify" - assert engine.has_pending_clarification() is True + assert initial == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ), + } + assert engine.has_pending_clarification() is False preview_result = preview(engine, confirmation) - assert preview_result["decision"]["kind"] == "update" - assert preview_result["state_after"] == expected_state - assert preview_result["would_mutate"] is expected_would_mutate + assert preview_result["decision"] == { + "kind": "passthrough", + "state": None, + "prompt_to_user": None, + } + assert preview_result["state_after"] == {"premise": None, "policies": {}, "version": 2} + assert preview_result["would_mutate"] is False - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False assert engine.state == {"premise": None, "policies": {}, "version": 2} final = step(engine, confirmation) - assert final["decision"]["kind"] == "update" - assert final["state"] == expected_state + assert final["decision"] == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert final["state"] == {"premise": None, "policies": {}, "version": 2} assert engine.has_pending_clarification() is False diff --git a/tests/test_demo_08_09_behavior.py b/tests/test_demo_08_09_behavior.py index 5358399..312cdc7 100644 --- a/tests/test_demo_08_09_behavior.py +++ b/tests/test_demo_08_09_behavior.py @@ -58,12 +58,12 @@ def test_demo_08_reports_invalid_replacement_block_and_unchanged_state( assert report["name"].startswith("08_replacement_precondition") assert report["baseline_pass"] is False assert report["reinjected_state_pass"] is False - assert report["compiler_pass"] is True + assert report["compiler_pass"] is False assert report["compiler_compact_pass"] is True - assert report["demo_pass"] is True + assert report["demo_pass"] is False assert "baseline: FAIL" in output assert "reinjected-state: FAIL" in output - assert "compiler: PASS" in output + assert "compiler: FAIL" in output def test_demo_08_reinjected_path_does_not_instantiate_engine( @@ -108,7 +108,7 @@ def wrapped_build_reinjected_messages(*args: object, **kwargs: object): assert report["reinjected_state_pass"] is False -def test_demo_09_reports_pending_clarification_continuation( +def test_demo_09_reports_invalid_replacement_non_pending_boundary( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: module = _load_demo_module("09_llm_pending_clarification.py") @@ -128,7 +128,7 @@ def test_demo_09_reports_pending_clarification_continuation( report = consume_last_report() assert report is not None - assert report["name"].startswith("09_pending_clarification_continuation") + assert report["name"].startswith("09_pending_clarification_boundary") assert report["baseline_pass"] is False assert report["reinjected_state_pass"] is False assert report["compiler_pass"] is True diff --git a/tests/test_engine.py b/tests/test_engine.py index 05f593e..9e6f414 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3,7 +3,7 @@ import pytest from context_compiler import create_engine, get_policy_items, get_premise_value -from context_compiler.engine import DecisionKind, Engine +from context_compiler.engine import DecisionKind, Engine, _normalize_confirmation pytestmark = pytest.mark.contract @@ -229,22 +229,21 @@ def test_export_checkpoint_json_is_canonical_sorted_and_compact() -> None: @pytest.mark.contract -def test_export_checkpoint_serializes_pending_replacement_state_for_exact_resume() -> None: +def test_export_checkpoint_does_not_serialize_pending_for_missing_source_replacement_clarify() -> ( + None +): engine = create_engine() clarify = engine.step("use kubectl instead of docker") - assert clarify["kind"] == "clarify" - assert engine.has_pending_clarification() is True + assert clarify == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ), + } checkpoint = engine.export_checkpoint() - assert checkpoint["pending"] == { - "kind": "replacement", - "replacement": { - "kind": "use_only", - "new_item": "kubectl", - "old_item": None, - }, - "prompt_to_user": 'Did you mean to use "kubectl" instead?', - } + assert checkpoint["pending"] is None @pytest.mark.contract @@ -266,26 +265,6 @@ def test_export_checkpoint_does_not_serialize_pending_for_prohibited_replacement assert checkpoint["pending"] is None -@pytest.mark.contract -def test_import_checkpoint_restores_pending_clarification_and_unmatched_input_reuses_prompt() -> ( - None -): - source = create_engine() - first = source.step("use kubectl instead of docker") - assert first["kind"] == "clarify" - checkpoint = source.export_checkpoint() - - target = create_engine() - target.import_checkpoint(checkpoint) - assert target.has_pending_clarification() is True - before = target.state - second = target.step("maybe") - - assert second == first - assert target.has_pending_clarification() is True - assert target.state == before - - @pytest.mark.contract def test_export_checkpoint_json_object_and_restore_paths_are_behaviorally_equivalent() -> None: source = create_engine() @@ -306,29 +285,28 @@ def test_export_checkpoint_json_object_and_restore_paths_are_behaviorally_equiva @pytest.mark.contract -def test_import_checkpoint_restores_pending_clarification_and_resolves_yes() -> None: +def test_yes_without_pending_after_checkpoint_round_trip_is_passthrough() -> None: source = create_engine() source.step("use kubectl instead of docker") checkpoint = source.export_checkpoint() target = create_engine() target.import_checkpoint(checkpoint) - assert target.has_pending_clarification() is True + assert target.has_pending_clarification() is False decision = target.step("yes") - assert decision["kind"] == "update" - assert target.has_pending_clarification() is False - assert target.state == {"premise": None, "policies": {"kubectl": "use"}, "version": 2} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert target.state == {"premise": None, "policies": {}, "version": 2} -def test_has_pending_clarification_clears_on_negative_confirmation() -> None: +def test_has_pending_clarification_remains_false_after_invalid_replacement_followup() -> None: engine = create_engine() decision = engine.step("use kubectl instead of docker") assert decision["kind"] == "clarify" - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False no_decision = engine.step("no") - assert no_decision["kind"] == "update" + assert no_decision["kind"] == "passthrough" assert engine.has_pending_clarification() is False @@ -444,6 +422,11 @@ def test_import_checkpoint_rejects_invalid_pending_payload_shapes(pending: objec ) +def test_normalize_confirmation_collapses_unicode_spacing_and_trailing_punctuation() -> None: + assert _normalize_confirmation(" YES!! ") == "yes" + assert _normalize_confirmation("No\t\tthanks...\n") == "no thanks" + + @pytest.mark.parametrize( "replacement", [ @@ -522,15 +505,15 @@ def test_import_checkpoint_is_all_or_nothing_when_authoritative_state_is_invalid ) second = engine.step("maybe") - assert second == first + assert second == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_has_pending_clarification_clears_after_import_json() -> None: +def test_has_pending_clarification_stays_false_after_import_json() -> None: engine = create_engine() clarify = engine.step("use kubectl instead of docker") assert clarify["kind"] == "clarify" - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False engine.import_json('{"policies":{},"premise":null,"version":2}') @@ -556,8 +539,7 @@ def test_replace_use_clarifies_when_old_policy_is_not_use_in_invalid_internal_st @pytest.mark.contract def test_import_checkpoint_with_pending_none_clears_existing_pending() -> None: engine = create_engine() - engine.step("use kubectl instead of docker") - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False engine.import_checkpoint( { @@ -580,8 +562,7 @@ def test_import_checkpoint_with_pending_none_clears_existing_pending() -> None: @pytest.mark.contract def test_import_checkpoint_with_pending_absent_clears_existing_pending() -> None: engine = create_engine() - engine.step("use kubectl instead of docker") - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False engine.import_checkpoint( { @@ -787,17 +768,16 @@ def test_set_premise_whitespace_payload_clarifies_without_mutation() -> None: assert engine.state == before -def test_set_premise_to_variant_clarifies_with_canonical_suggestion_without_mutation() -> None: +def test_set_premise_to_variant_is_treated_as_literal_canonical_set() -> None: engine = create_engine() - before = engine.state decision = engine.step("set premise to concise replies") assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": "Did you mean 'set premise concise replies'?", + "kind": "update", + "state": {"premise": "to concise replies", "policies": {}, "version": 2}, + "prompt_to_user": None, } - assert engine.state == before + assert engine.state == {"premise": "to concise replies", "policies": {}, "version": 2} def test_set_premise_to_with_whitespace_payload_falls_through_to_literal_set_behavior() -> None: @@ -847,18 +827,13 @@ def test_change_premise_to_empty_payload_clarifies_without_mutation() -> None: assert engine.state == before -def test_change_premise_to_without_space_payload_clarifies_after_near_miss() -> None: +def test_change_premise_to_without_space_payload_remains_passthrough_before_empty_clarify() -> None: engine = create_engine() engine.step("set premise baseline") before = engine.state - # Near-miss should not create pending; canonical empty form still clarifies. near_miss = engine.step("change premise baseline") - assert near_miss == { - "kind": "clarify", - "state": None, - "prompt_to_user": "Did you mean 'change premise to baseline'?", - } + assert near_miss == {"kind": "passthrough", "state": None, "prompt_to_user": None} decision = engine.step("change premise to") assert decision == { @@ -889,16 +864,12 @@ def test_change_premise_to_whitespace_payload_clarifies_without_mutation() -> No assert engine.state == before -def test_change_premise_missing_to_variant_clarifies_without_mutation() -> None: +def test_change_premise_missing_to_variant_is_passthrough() -> None: engine = create_engine() before = engine.state decision = engine.step("change premise concise replies") - assert decision == { - "kind": "clarify", - "state": None, - "prompt_to_user": "Did you mean 'change premise to concise replies'?", - } + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before @@ -1118,9 +1089,11 @@ def test_replace_use_identity_is_noop_update() -> None: assert engine.state == before -def test_replace_use_missing_source_state_enters_replacement_intent_clarify() -> None: +def test_replace_use_missing_source_clarifies_as_invalid_replacement_without_pending() -> None: engine = create_engine() - expected_prompt = 'Did you mean to use "kubectl" instead?' + expected_prompt = ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ) d1 = engine.step("use kubectl instead of docker") assert d1 == { @@ -1129,11 +1102,14 @@ def test_replace_use_missing_source_state_enters_replacement_intent_clarify() -> "prompt_to_user": expected_prompt, } assert engine.state == {"premise": None, "policies": {}, "version": 2} + assert engine.has_pending_clarification() is False -def test_replace_use_missing_source_yes_confirmation_applies_use_only() -> None: +def test_replace_use_missing_source_yes_confirmation_is_passthrough() -> None: engine = create_engine() - expected_prompt = 'Did you mean to use "kubectl" instead?' + expected_prompt = ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ) first = engine.step("use kubectl instead of docker") assert first == { @@ -1144,12 +1120,8 @@ def test_replace_use_missing_source_yes_confirmation_applies_use_only() -> None: assert engine.state == {"premise": None, "policies": {}, "version": 2} second = engine.step("yes") - assert second["kind"] == "update" - assert engine.state == { - "premise": None, - "policies": {"kubectl": "use"}, - "version": 2, - } + assert second == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.state == {"premise": None, "policies": {}, "version": 2} def test_replace_use_missing_source_no_confirmation_has_no_mutation() -> None: @@ -1158,24 +1130,28 @@ def test_replace_use_missing_source_no_confirmation_has_no_mutation() -> None: before = engine.state decision = engine.step("no") - assert decision == {"kind": "update", "state": before, "prompt_to_user": None} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_replace_use_missing_source_takes_priority_over_target_prohibit_prompt() -> None: +def test_replace_use_missing_source_still_reports_target_prohibit_when_new_item_prohibited() -> ( + None +): engine = create_engine() engine.step("prohibit kubectl") - expected_prompt = 'Did you mean to use "kubectl" instead?' decision = engine.step("use kubectl instead of docker") assert decision == { "kind": "clarify", "state": None, - "prompt_to_user": expected_prompt, + "prompt_to_user": ( + '"kubectl" is currently prohibited.\n' + "Submit explicit directive(s) to remove it or use a different item." + ), } -def test_replace_use_missing_source_prompt_includes_contains_diagnostic_hints() -> None: +def test_replace_use_missing_source_ignores_unrelated_existing_policies() -> None: engine = create_engine() engine.step("use python and docker") @@ -1183,11 +1159,13 @@ def test_replace_use_missing_source_prompt_includes_contains_diagnostic_hints() assert decision == { "kind": "clarify", "state": None, - "prompt_to_user": 'Did you mean to use "kubectl" instead?', + "prompt_to_user": ( + "\"python\" is not currently in use.\nReplacement requires an active 'use' policy." + ), } -def test_replace_use_missing_source_prompt_lists_multiple_diagnostic_hints_sorted() -> None: +def test_replace_use_missing_source_ignores_other_conflicting_entries() -> None: engine = create_engine() engine.step("use python and docker") engine.step("prohibit python tooling") @@ -1196,11 +1174,13 @@ def test_replace_use_missing_source_prompt_lists_multiple_diagnostic_hints_sorte assert decision == { "kind": "clarify", "state": None, - "prompt_to_user": 'Did you mean to use "kubectl" instead?', + "prompt_to_user": ( + "\"python\" is not currently in use.\nReplacement requires an active 'use' policy." + ), } -def test_replace_use_missing_source_with_empty_normalized_probe_omits_diagnostic_hints() -> None: +def test_replace_use_missing_source_with_empty_probe_uses_invalid_prompt() -> None: engine = create_engine() engine.step("use python and docker") before = engine.state @@ -1209,7 +1189,9 @@ def test_replace_use_missing_source_with_empty_normalized_probe_omits_diagnostic assert decision == { "kind": "clarify", "state": None, - "prompt_to_user": 'Did you mean to use "kubectl" instead?', + "prompt_to_user": ( + "\"the\" is not currently in use.\nReplacement requires an active 'use' policy." + ), } assert engine.state == before @@ -1316,110 +1298,119 @@ def test_replace_use_kx_prohibit_no_confirmation_has_no_mutation() -> None: assert engine.state == before -def test_missing_source_pending_confirmation_precedence_and_resolution() -> None: +def test_missing_source_replacement_does_not_block_following_directives() -> None: engine = create_engine() first = engine.step("use kubectl instead of docker") + assert first == { + "kind": "clarify", + "state": None, + "prompt_to_user": ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ), + } - # While pending, directive parsing is suspended. second = engine.step("use docker") - assert second == first - assert engine.state["policies"] == {} + assert second["kind"] == "update" + assert engine.state["policies"] == {"docker": "use"} third = engine.step("yes") - assert third["kind"] == "update" - assert engine.state["policies"] == {"kubectl": "use"} + assert third == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.state["policies"] == {"docker": "use"} -def test_missing_source_pending_confirmation_suspends_admin_commands_until_resolved() -> None: +def test_missing_source_replacement_does_not_suspend_admin_commands() -> None: engine = create_engine() - first = engine.step("use kubectl instead of docker") + engine.step("use kubectl instead of docker") before = engine.state - assert engine.step("clear state") == first - assert engine.step("reset policies") == first - assert engine.state == before + assert engine.step("clear state")["kind"] == "update" + assert engine.step("reset policies")["kind"] == "update" + assert engine.state == before == {"premise": None, "policies": {}, "version": 2} resolved = engine.step("yes") - assert resolved["kind"] == "update" - assert engine.state["policies"] == {"kubectl": "use"} + assert resolved == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.state["policies"] == {} -def test_missing_source_pending_negative_discards_proposed_event() -> None: +def test_missing_source_replacement_negative_followup_is_passthrough() -> None: engine = create_engine() engine.step("use kubectl instead of docker") - before = engine.state decision = engine.step("no") - assert decision == {"kind": "update", "state": before, "prompt_to_user": None} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state["policies"] == {} -def test_missing_source_pending_confirmation_token_normalization() -> None: +def test_missing_source_replacement_confirmation_tokens_are_not_consumed() -> None: engine = create_engine() engine.step("use kubectl instead of docker") decision = engine.step(" YES!!! ") - assert decision["kind"] == "update" - assert engine.state["policies"] == {"kubectl": "use"} + assert decision["kind"] == "passthrough" + assert engine.state["policies"] == {} -def test_pending_affirmative_confirmation_token_variants_are_accepted() -> None: +def test_missing_source_replacement_affirmative_token_variants_are_passthrough() -> None: for token in ["yes please", "Yep", "yeah", "ok", " OKAY... ", "sure!"]: engine = create_engine() engine.step("use kubectl instead of docker") decision = engine.step(token) - assert decision["kind"] == "update" - assert engine.state["policies"] == {"kubectl": "use"} + assert decision["kind"] == "passthrough" + assert engine.state["policies"] == {} -def test_missing_source_pending_negative_confirmation_token_normalization_no_punctuation() -> None: +def test_missing_source_replacement_negative_tokens_are_passthrough() -> None: engine = create_engine() engine.step("use kubectl instead of docker") before = engine.state decision = engine.step(" NO!!! ") - assert decision == {"kind": "update", "state": before, "prompt_to_user": None} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_missing_source_pending_negative_confirmation_token_normalization_no_thanks() -> None: +def test_missing_source_replacement_no_thanks_is_passthrough() -> None: engine = create_engine() engine.step("use kubectl instead of docker") before = engine.state decision = engine.step("no thanks.") - assert decision == {"kind": "update", "state": before, "prompt_to_user": None} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_pending_negative_confirmation_token_variants_are_accepted() -> None: +def test_missing_source_replacement_negative_token_variants_are_passthrough() -> None: for token in ["nope", "Nope??", " no ", "NO THANKS!"]: engine = create_engine() engine.step("use kubectl instead of docker") before = engine.state decision = engine.step(token) - assert decision == {"kind": "update", "state": before, "prompt_to_user": None} + assert decision == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_missing_source_pending_unmatched_input_remains_clarify_without_mutation() -> None: +def test_missing_source_replacement_unmatched_followup_is_passthrough() -> None: engine = create_engine() - first = engine.step("use kubectl instead of docker") + engine.step("use kubectl instead of docker") before = engine.state second = engine.step("maybe") - assert second == first + assert second == {"kind": "passthrough", "state": None, "prompt_to_user": None} assert engine.state == before -def test_missing_source_pending_unmatched_input_can_repeat_without_mutation() -> None: +def test_missing_source_replacement_unmatched_followups_remain_passthrough() -> None: engine = create_engine() - first = engine.step("use kubectl instead of docker") + engine.step("use kubectl instead of docker") before = engine.state - assert engine.step("later") == first - assert engine.step("still later") == first + assert engine.step("later") == {"kind": "passthrough", "state": None, "prompt_to_user": None} + assert engine.step("still later") == { + "kind": "passthrough", + "state": None, + "prompt_to_user": None, + } assert engine.state == before @@ -1687,15 +1678,21 @@ def test_pending_confirmation_takes_precedence_over_compound_detection() -> None assert first == { "kind": "clarify", "state": None, - "prompt_to_user": 'Did you mean to use "kubectl" instead?', + "prompt_to_user": ( + "\"docker\" is not currently in use.\nReplacement requires an active 'use' policy." + ), } - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False decision = engine.step("use docker and prohibit peanuts") - assert decision == first + assert decision == { + "kind": "clarify", + "state": None, + "prompt_to_user": COMPOUND_DIRECTIVE_PROMPT, + } assert engine.state == {"premise": None, "policies": {}, "version": 2} - assert engine.has_pending_clarification() is True + assert engine.has_pending_clarification() is False def test_constructor_with_state_initializes_from_valid_state() -> None: diff --git a/tests/test_repl.py b/tests/test_repl.py index c0a0741..dea5f08 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -172,6 +172,53 @@ def _fake_run_repl( assert called["json_mode"] is True +def test_render_decision_lines_uses_confirm_prefix_for_question_prompts() -> None: + lines = repl_module._render_decision_lines( + { + "kind": "clarify", + "state": None, + "prompt_to_user": "Proceed?", + } + ) + + assert lines == ["confirm: Proceed?"] + + +def test_render_diff_lines_includes_added_policy_entries() -> None: + preview = { + "would_mutate": True, + "decision": { + "kind": "update", + "state": {"premise": None, "policies": {"docker": "use"}, "version": 2}, + "prompt_to_user": None, + }, + "diff": { + "premise": {"changed": False, "before": None, "after": None}, + "policies": {"added": {"docker": "use"}, "removed": {}, "changed": {}}, + }, + } + + assert repl_module._render_diff_lines(preview) == [ + "would_mutate: yes", + "diff:", + "- + use docker", + ] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("yes", True), + (" no! ", True), + ("maybe", False), + ], +) +def test_is_confirmation_input_recognizes_affirmative_and_negative_tokens( + value: str, expected: bool +) -> None: + assert repl_module._is_confirmation_input(value) is expected + + def test_main_json_requires_non_interactive_stdio( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -186,6 +233,69 @@ def test_main_json_requires_non_interactive_stdio( assert captured.err == "error: --json requires non-interactive stdin/stdout.\n" +def test_interactive_step_without_payload_prints_command_error() -> None: + lines = _run_interactive_lines("step\nquit\n") + + assert _contains_subsequence(lines, ["error: step requires input.", "Use 'step '."]) + + +def test_interactive_step_command_with_non_confirmation_payload_prints_pending_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = create_engine() + monkeypatch.setattr(engine, "has_pending_clarification", lambda: True) + + out = _TTYStringIO() + run_repl(_TTYStringIO("step set premise concise\nquit\n"), out, engine=engine) + lines = [line for line in out.getvalue().splitlines() if line.strip()] + + assert _contains_subsequence( + lines, + [ + "error: step command only accepts confirmation while clarification is pending.", + "Use yes/no (or variants), or use preview/state.", + ], + ) + + +def test_non_interactive_step_command_with_non_confirmation_payload_emits_json_pending_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = create_engine() + monkeypatch.setattr(engine, "has_pending_clarification", lambda: True) + + out = StringIO() + run_repl(StringIO("step set premise concise\n"), out, json_mode=True, engine=engine) + + assert json.loads(out.getvalue()) == { + "mode": "error", + "output_version": 1, + "command": "step", + "error": { + "code": "pending_confirmation_required", + "message": ( + "step command only accepts confirmation while clarification is pending.\n" + "Use yes/no (or variants), or use preview/state." + ), + }, + } + + +def test_non_interactive_step_command_with_non_confirmation_payload_prints_text_pending_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = create_engine() + monkeypatch.setattr(engine, "has_pending_clarification", lambda: True) + + out = StringIO() + run_repl(StringIO("step set premise concise\n"), out, engine=engine) + + assert out.getvalue() == ( + "error: step command only accepts confirmation while clarification is pending.\n" + "Use yes/no (or variants), or use preview/state.\n" + ) + + def test_main_unknown_flag_prints_error_hint_and_exits_nonzero( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -244,15 +354,14 @@ def test_cli_initial_state_file_preload_works(tmp_path: pathlib.Path) -> None: assert result.stderr == "" -def test_cli_initial_checkpoint_json_preload_works_with_pending_confirmation() -> None: +def test_cli_initial_checkpoint_json_preload_without_pending_confirmation_keeps_state() -> None: engine = create_engine() engine.step("use kubectl instead of docker") payload = engine.export_checkpoint_json() result = _run_repl_cli("--initial-checkpoint-json", payload, input_text="yes\nquit\n") assert result.returncode == 0 - assert "updated" in result.stdout - assert "- use kubectl" in result.stdout + assert result.stdout == "passthrough\n" assert result.stderr == "" @@ -470,8 +579,8 @@ def test_apply_preload_from_options_state_and_checkpoint_file_paths( }, ) decision = checkpoint_engine.step("yes") - assert decision["kind"] == "update" - assert checkpoint_engine.state["policies"] == {"kubectl": "use"} + assert decision["kind"] == "passthrough" + assert checkpoint_engine.state["policies"] == {} def test_apply_preload_from_options_state_and_checkpoint_json() -> None: @@ -499,8 +608,8 @@ def test_apply_preload_from_options_state_and_checkpoint_json() -> None: }, ) decision = checkpoint_engine.step("yes") - assert decision["kind"] == "update" - assert checkpoint_engine.state["policies"] == {"kubectl": "use"} + assert decision["kind"] == "passthrough" + assert checkpoint_engine.state["policies"] == {} def test_repl_update_flow() -> None: @@ -673,15 +782,14 @@ def test_repl_non_interactive_json_step_pending_confirmation_error() -> None: ) assert rows[1] == { "command": "step", - "error": { - "code": "pending_confirmation_required", - "message": ( - "step command only accepts confirmation while clarification is pending.\n" - "Use yes/no (or variants), or use preview/state." - ), + "decision": { + "kind": "update", + "prompt_to_user": None, + "state": {"premise": "concise", "policies": {}, "version": 2}, }, - "mode": "error", + "mode": "step", "output_version": 1, + "state": {"premise": "concise", "policies": {}, "version": 2}, } @@ -704,7 +812,7 @@ def test_repl_non_interactive_state_command_renders_current_state() -> None: assert _contains_subsequence(lines, ["premise: concise", "policies: (none)"]) -def test_repl_non_interactive_preview_reports_no_mutation_for_clarify_and_keeps_pending() -> None: +def test_repl_non_interactive_preview_reports_no_mutation_after_invalid_replacement() -> None: out = StringIO() run_repl( StringIO("use kubectl instead of docker\npreview yes\nyes\nquit\n"), @@ -712,14 +820,15 @@ def test_repl_non_interactive_preview_reports_no_mutation_for_clarify_and_keeps_ ) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) assert _contains_subsequence( - lines, ["preview", "updated", "premise: (none)", "policies:", "- use kubectl"] - ) - assert _contains_subsequence(lines, ["would_mutate: yes"]) - assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], ) + assert _contains_subsequence(lines, ["preview", "passthrough", "would_mutate: no"]) + assert lines[-1] == "passthrough" def test_repl_non_interactive_preview_decline_reports_no_mutation() -> None: @@ -727,9 +836,7 @@ def test_repl_non_interactive_preview_decline_reports_no_mutation() -> None: run_repl(StringIO("use kubectl instead of docker\npreview no\nquit\n"), out) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence( - lines, ["preview", "updated", "premise: (none)", "policies: (none)"] - ) + assert _contains_subsequence(lines, ["preview", "passthrough"]) assert _contains_subsequence(lines, ["would_mutate: no", "diff:", "- (none)"]) @@ -750,19 +857,23 @@ def test_repl_non_interactive_preview_and_step_require_payload() -> None: assert _contains_subsequence(lines, ["error: step requires input.", "Use 'step '."]) -def test_repl_state_command_available_while_pending_clarification() -> None: +def test_repl_state_command_after_invalid_replacement_shows_unchanged_state() -> None: out = StringIO() run_repl(StringIO("use kubectl instead of docker\nstate\nyes\nquit\n"), out) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) - assert _contains_subsequence(lines, ["premise: (none)", "policies: (none)"]) assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], ) + assert _contains_subsequence(lines, ["premise: (none)", "policies: (none)"]) + assert lines[-1] == "passthrough" -def test_repl_step_command_rejects_non_confirmation_while_pending() -> None: +def test_repl_step_command_runs_normally_after_invalid_replacement() -> None: out = StringIO() run_repl( StringIO("use kubectl instead of docker\nstep set premise concise\nyes\nquit\n"), @@ -770,71 +881,83 @@ def test_repl_step_command_rejects_non_confirmation_while_pending() -> None: ) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) assert _contains_subsequence( lines, [ - "error: step command only accepts confirmation while clarification is pending.", - "Use yes/no (or variants), or use preview/state.", + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", ], ) - assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] - ) + assert _contains_subsequence(lines, ["updated", "premise: concise", "policies: (none)"]) + assert lines[-1] == "passthrough" -def test_repl_step_command_accepts_confirmation_while_pending() -> None: +def test_repl_step_command_confirmation_token_is_passthrough_without_pending() -> None: out = StringIO() run_repl(StringIO("use kubectl instead of docker\nstep yes\nquit\n"), out) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], ) + assert lines[-1] == "passthrough" -def test_repl_step_command_accepts_negative_confirmation_while_pending() -> None: +def test_repl_step_command_negative_confirmation_is_passthrough_without_pending() -> None: out = StringIO() run_repl(StringIO("use kubectl instead of docker\nstep no\nquit\n"), out) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) - assert _contains_subsequence(lines, ["updated", "premise: (none)", "policies: (none)"]) + assert _contains_subsequence( + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], + ) + assert lines[-1] == "passthrough" -def test_repl_preview_available_while_pending_clarification() -> None: +def test_repl_preview_available_after_invalid_replacement() -> None: out = StringIO() run_repl(StringIO("use kubectl instead of docker\npreview yes\nyes\nquit\n"), out) lines = [line for line in out.getvalue().splitlines() if line.strip()] - assert _contains_subsequence( - lines, ["preview", "updated", "premise: (none)", "policies:", "- use kubectl"] - ) + assert _contains_subsequence(lines, ["preview", "passthrough", "would_mutate: no"]) + assert lines[-1] == "passthrough" -def test_repl_interactive_preview_available_while_pending_clarification() -> None: +def test_repl_interactive_preview_available_after_invalid_replacement() -> None: lines = _run_interactive_lines("use kubectl instead of docker\npreview yes\nyes\nquit\n") - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) assert _contains_subsequence( - lines, ["preview", "updated", "premise: (none)", "policies:", "- use kubectl"] - ) - assert _contains_subsequence(lines, ["would_mutate: yes"]) - assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], ) + assert _contains_subsequence(lines, ["preview", "passthrough", "would_mutate: no"]) + assert lines[-1] == "passthrough" -def test_repl_interactive_help_available_while_pending_clarification() -> None: +def test_repl_interactive_help_available_after_invalid_replacement() -> None: lines = _run_interactive_lines("use kubectl instead of docker\nhelp\nyes\nquit\n") - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) - assert _contains_subsequence(lines, ["Commands: help/? exit/quit"]) - assert _contains_subsequence(lines, ["REPL command layer (not engine directives):"]) assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], ) + assert _contains_subsequence(lines, ["Commands: help/? exit/quit"]) + assert _contains_subsequence(lines, ["REPL command layer (not engine directives):"]) + assert lines[-1] == "passthrough" def test_repl_preview_idempotent_admin_action_reports_no_mutation() -> None: @@ -934,14 +1057,14 @@ def test_repl_empty_policy_payloads_and_incomplete_replacement_render_errors() - assert lines.count("Use 'use instead of ' with non-empty values.") == 2 -def test_repl_premise_to_variant_near_misses_render_error_suggestions() -> None: +def test_repl_premise_to_variant_near_misses_do_not_render_repair_suggestions() -> None: lines = _run_non_interactive_lines( "set premise to concise replies\nchange premise concise replies\nquit\n" ) - assert _contains_subsequence(lines, ["confirm: Did you mean 'set premise concise replies'?"]) assert _contains_subsequence( - lines, ["confirm: Did you mean 'change premise to concise replies'?"] + lines, ["updated", "premise: to concise replies", "policies: (none)"] ) + assert lines[-1] == "passthrough" def test_repl_non_interactive_remove_policy_flow() -> None: @@ -1030,12 +1153,16 @@ def test_repl_replace_use_when_old_policy_not_use_renders_exact_error( ) -def test_repl_replacement_clarify_requires_confirmation_tokens_and_persists_until_resolved() -> ( - None -): +def test_repl_replacement_invalid_followups_are_passthrough() -> None: lines = _run_non_interactive_lines("use podman instead of docker\nmaybe\nyes please!!\nquit\n") - assert lines.count('confirm: Did you mean to use "podman" instead?') == 2 - assert _contains_subsequence(lines, ["updated", "premise: (none)", "policies:", "- use podman"]) + assert _contains_subsequence( + lines, + [ + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", + ], + ) + assert lines[-2:] == ["passthrough", "passthrough"] def test_repl_interactive_prints_confirm_and_error_for_clarify_types() -> None: @@ -1048,7 +1175,7 @@ def test_repl_interactive_prints_confirm_and_error_for_clarify_types() -> None: confirm_out = _TTYStringIO() run_repl(_TTYStringIO("use podman instead of docker\nquit\n"), confirm_out) confirm_lines = confirm_out.getvalue().splitlines() - assert 'confirm: Did you mean to use "podman" instead?' in confirm_lines + assert 'error: "docker" is not currently in use.' in confirm_lines def test_repl_prohibited_replacement_followup_tokens_remain_passthrough() -> None: @@ -1114,28 +1241,22 @@ def test_repl_interactive_renders_updated_state_blocks_for_multiple_operations() assert _contains_subsequence(lines, ["updated", "premise: (none)", "policies:", "- use docker"]) -def test_repl_interactive_confirmation_token_variants_resolve_pending_clarify() -> None: +def test_repl_interactive_tokens_after_invalid_replacement_are_passthrough() -> None: lines_yes = _run_interactive_lines("use podman instead of docker\nyeah\nquit\n") - assert 'confirm: Did you mean to use "podman" instead?' in lines_yes - assert _contains_subsequence( - lines_yes, ["updated", "premise: (none)", "policies:", "- use podman"] - ) + assert 'error: "docker" is not currently in use.' in lines_yes + assert lines_yes[-1] == "passthrough" lines_ok = _run_interactive_lines("use buildah instead of docker\nok\nquit\n") - assert 'confirm: Did you mean to use "buildah" instead?' in lines_ok - assert _contains_subsequence( - lines_ok, ["updated", "premise: (none)", "policies:", "- use buildah"] - ) + assert 'error: "docker" is not currently in use.' in lines_ok + assert lines_ok[-1] == "passthrough" lines_nope = _run_interactive_lines("use nerdctl instead of docker\nnope\nquit\n") - assert 'confirm: Did you mean to use "nerdctl" instead?' in lines_nope - assert _contains_subsequence(lines_nope, ["updated", "premise: (none)", "policies: (none)"]) + assert 'error: "docker" is not currently in use.' in lines_nope + assert lines_nope[-1] == "passthrough" lines_no_thanks = _run_interactive_lines("use helm instead of docker\nno thanks\nquit\n") - assert 'confirm: Did you mean to use "helm" instead?' in lines_no_thanks - assert _contains_subsequence( - lines_no_thanks, ["updated", "premise: (none)", "policies: (none)"] - ) + assert 'error: "docker" is not currently in use.' in lines_no_thanks + assert lines_no_thanks[-1] == "passthrough" def test_repl_interactive_admin_idempotency_outputs_updated_with_unchanged_state() -> None: @@ -1160,7 +1281,7 @@ def test_repl_interactive_confirm_vs_error_alignment_for_actual_clarify_behavior assert "Use 'change premise to ' to modify it." in lines assert ('error: "docker" is currently in use.') in lines assert "Remove or replace it before prohibiting it." in lines - assert 'confirm: Did you mean to use "podman" instead?' in lines + assert 'error: "buildx" is not currently in use.' in lines def test_repl_interactive_passthrough_prints_passthrough_label() -> None: @@ -1238,20 +1359,17 @@ def test_repl_interactive_state_renders_mixed_with_sorted_policies() -> None: ) -def test_repl_interactive_step_command_paths_while_pending() -> None: +def test_repl_interactive_step_command_paths_after_invalid_replacement() -> None: lines = _run_interactive_lines("use kubectl instead of docker\nstep maybe\nstep yes!!!\nquit\n") - assert _contains_subsequence(lines, ['confirm: Did you mean to use "kubectl" instead?']) assert _contains_subsequence( lines, [ - "error: step command only accepts confirmation while clarification is pending.", - "Use yes/no (or variants), or use preview/state.", + 'error: "docker" is not currently in use.', + "Replacement requires an active 'use' policy.", ], ) - assert _contains_subsequence( - lines, ["updated", "premise: (none)", "policies:", "- use kubectl"] - ) + assert lines[-2:] == ["passthrough", "passthrough"] def test_repl_interactive_preview_requires_payload() -> None: