diff --git a/loopx/control_plane/turn_driver/chat_queue_steering.ts b/loopx/control_plane/turn_driver/chat_queue_steering.ts index 5ed1c4a2f3..f42fddb216 100644 --- a/loopx/control_plane/turn_driver/chat_queue_steering.ts +++ b/loopx/control_plane/turn_driver/chat_queue_steering.ts @@ -32,7 +32,7 @@ function mutation(value: unknown): JsonObject | null { if (value == null) return null; const row = requireJsonObject(value, "queue input receipt"); requireNonEmptyString(row.operation_id, "queue input operation_id"); - if (!["edit", "cancel"].includes(String(row.action)) || + if ((row.action !== "edit" && row.action !== "cancel") || !Number.isSafeInteger(row.expected_revision) || Number(row.expected_revision) < 0 || row.input_revision !== Number(row.expected_revision) + 1 || row.action === "edit" && typeof row.message !== "string") throw new Error("Invalid queue input receipt"); @@ -122,7 +122,7 @@ export function planQueuedChatMutation(input: JsonObject): JsonObject { const operationId = requireNonEmptyString(request.operation_id, "operation_id"); if (operationId.length > 160 || !/^[A-Za-z0-9._-]+$/.test(operationId) || !Number.isSafeInteger(request.expected_revision) || Number(request.expected_revision) < 0 || - !["edit", "cancel"].includes(String(request.action)) || + (request.action !== "edit" && request.action !== "cancel") || Object.keys(request).some(key => !["operation_id", "expected_revision", "action", "message"].includes(key))) { throw new Error("Invalid queue input request"); } diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 30b23b6604..bd46fbdec4 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `queue_action_type_coercion` | A queued input action such as `["edit"]` is accepted but applies cancellation, or a stored operation accepts a non-string action. | Original JSON types, the typed planner and receipt decoder, real HTTP responses, canonical Turn/transcript/events/Session snapshots and native IO before/after refusal. | Membership checks stringify the action while effect selection and replay compare its original literal value. | Validate the original literal action at the existing owner before branching and when decoding pending or committed receipts. Cover arrays, nested arrays, objects, nulls, numbers, booleans and unknown strings; require rejection without state or native effects, then verify legal edit/cancel, original-operation replay and queue races. Preserve malformed historical evidence rather than coercing or rewriting receipts. | | `typed_adapter_validation_scope_mismatch` | A local adapter type check passes while the same CI entry reports a large legacy dependency error set. | Exact source revisions, configured roots, cold same-environment runs, installation/import paths, and original-root plus adapter counterfactuals. | Adding runtime adapters to a kernel root list expands its import closure; an extra source path can mask that traversal locally. | Preserve the existing kernel gate and its import behavior. Make the additive adapter boundary explicit, retain imported-code debt as unqualified, and prove strict root checking with original diagnostics and core/adapter mutations in an unmasked source installation. Do not globally disable import checks, remove original roots or rewrite historical failures as passes. | | `saved_projection_goal_route_coupling` | A saved context projection fails on conflicting default runtime roots even with native transport disabled, while the same capture remains readable. | Run the shipped projection and returned reader against one digest-bound synthetic capture, with implicit, missing and global registry selectors; retain digest/identity refusals and unchanged runtime snapshots. | Shared CLI registry discovery runs before the saved-view owner validates its source. | Classify the saved presentation at the existing CLI route owner; preserve machine-configuration routing, live authority conflict checks and source/session validation. Reuse the registry-independence integration tests rather than adding a second recovery path or repairing live state. | | `native_reader_program_route_loss` | A native producer or read uses a selected CLI or interpreter, but returned navigation, continuation or recovery invokes another runtime. | Actual lifecycle capture, generated first-read and recovery instructions, unchanged execution of emitted commands with an isolated competing program, complete source/digest readback and installed-package provenance. | A presentation layer used its generic command-renderer default after the caller had already selected the runtime. | Pass the current program argv through existing CLI, navigation, bounded-result and reader renderers; retain constructor defaults and shell quoting. Include emitted command metadata in native byte-cost estimates. Cover cold guard and saved-context projection before direct first reads, raw/selected/index pagination, quoted aliases, refusal details and source-state immutability through real CLI and isolated wheel entrypoints. Routing preserves the selected program; it does not pin an installation version or prove Host adoption. | diff --git a/tests/control_plane_ts/chat_queue_steering.test.ts b/tests/control_plane_ts/chat_queue_steering.test.ts index 871c93edc1..75d7ee327a 100644 --- a/tests/control_plane_ts/chat_queue_steering.test.ts +++ b/tests/control_plane_ts/chat_queue_steering.test.ts @@ -45,6 +45,21 @@ test("partial input writes exclude both FIFO and steering; exact retries finish assert.equal(planQueuedChatMutation({...f, turn: ended, request: {...request, expected_revision: 1}}).error_code, "queue_mutation_identity_conflict"); }); +test("queue input actions are literal strings in requests and stored receipts", () => { + const f = facts(); + const request = {action: "cancel", expected_revision: 0, operation_id: "strict-input"}; + for (const action of [null, false, 0, {}, [], ["edit"], ["cancel"], [["edit"]], {toString: "edit"}, "unknown"]) { + assert.throws(() => planQueuedChatMutation({...f, request: {...request, action}}), /Invalid queue input request/); + const invalid = {...request, action, input_revision: 1}; + assert.throws(() => selectQueuedChatTurns({turns: [{...f.turn, mutation: invalid}], include_reserved: true}), + /Invalid queue input receipt/); + for (const extra of [{mutation: invalid}, {operations: [invalid]}]) { + assert.throws(() => planQueuedChatMutation({...f, request, turn: {...f.turn, ...extra}}), + /Invalid queue input receipt/); + } + } +}); + test("queued steering requires an unclaimed original and exact live target", () => { const f = facts(); assert.equal(planQueuedChatSteering(f).action, "dispatch"); for (const status of ["starting", "running", "interrupting", "completed", "failed"]) { diff --git a/tests/presentation/test_chat_turn_queue.py b/tests/presentation/test_chat_turn_queue.py index 7545dccaaf..231a79f205 100644 --- a/tests/presentation/test_chat_turn_queue.py +++ b/tests/presentation/test_chat_turn_queue.py @@ -173,6 +173,21 @@ def test_goal_queue_input_http_with_canonical_goal_providers(ordinary, tmp_path, qid = queued["turn_id"] assert request(f"/api/chat/sessions/{sid}/queue")[0] == 200 path = f"/api/chat/sessions/{sid}/queue/{qid}/input" + original_turn = store.load_turn(sid, qid) + original_messages = store.messages(sid) + original_events = store.events_after(sid, qid, None) + original_session = store.load_session(sid) + for index, action in enumerate([ + None, False, 0, {}, [], ["edit"], ["cancel"], [["edit"]], {"toString": "edit"}, "unknown", + ]): + invalid = {"action": action, "operation_id": f"invalid-{index}", "expected_revision": 0} + response = request(path, invalid) + assert store.load_turn(sid, qid) == original_turn, response + assert store.messages(sid) == original_messages + assert store.events_after(sid, qid, None) == original_events + assert store.load_session(sid) == original_session + assert not capture.exists() + assert response[0] == 409, response assert request(path, queue_input_body())[0] == 200 history = request(f"/api/chat/sessions/{sid}?turn_id={qid}&message_limit=200")[1]["messages"] assert history[0]["text"] == "Corrected queued input." and history[0]["queue_input_revision"] == 1