From 0e8a77e5d9b0c0d9b2bf0cfea4ed92f5b7cd3658 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:14:45 +0800 Subject: [PATCH] fix: restore file deltas carrying the current unread index Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/quota/context_delivery.ts | 24 +++++++++++++++---- .../quota/progressive_context.ts | 7 ++---- .../test_context_unread_reachability.py | 10 ++++++++ .../control_plane_ts/context_delivery.test.ts | 2 ++ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index 7853f79249..f0569628ba 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -585,6 +585,12 @@ export function prepareContextDelivery(request: JsonObject, legacyQuotaFields: r return publish(request, encodeContextDelivery(request, legacyQuotaFields, values), values); } +/** Progressive file deltas expose current discovery even without a retained model baseline. */ +export function fileNavigationPaths(context: JsonObject): Path[] { + const details = jsonObject(context.context_details); + return details && Object.hasOwn(details, "unread_context_index") ? [["context_details", "unread_context_index"]] : []; +} + /** Shared encoding within one presentation transaction; callers publish once * against their original source so numeric facts never round-trip through JS. */ export function encodeContextDelivery(request: JsonObject, legacyQuotaFields: readonly string[], @@ -675,13 +681,21 @@ export function restoreContextDelivery(request: JsonObject, legacyQuotaFields: r || delivery.ancestry !== Number(baseline.ancestry) + 1 || Number(delivery.ancestry) > MAX_ANCESTRY) return fallback(); const before = freshPaths(context, surface, legacyQuotaFields, base), after = freshPaths(currentContext, surface, legacyQuotaFields, target); if (!fileBacked && (!before.valid || !after.valid)) return fallback(); - const expected = changeTree(context, currentContext, fileBacked ? [] : after.paths, fileBacked, + const currentChanges = (paths: Path[]) => changeTree(context, currentContext, paths, fileBacked, {identity, before: ["baseline", "context"], after: ["current", "context"], ...(request.source_edits === true ? {values} : {})}); - // Also rejects omitted unchanged controls, redundant/conflicting edits, - // malformed array indices/lengths and unsupported operations before applying. - if (contextProjectionIdentity(request, {changes: delivery.changes}, request.source_number_tokens, values) - !== contextProjectionIdentity(request, {changes: expected}, request.source_number_tokens, values)) return fallback(); + const wireIdentity = contextProjectionIdentity(request, {changes: delivery.changes}, request.source_number_tokens, values); + const matches = (changes: ReturnType) => + contextProjectionIdentity(request, {changes}, request.source_number_tokens, values) === wireIdentity; + const navigation = fileBacked ? fileNavigationPaths(currentContext) : []; + let expected = currentChanges(fileBacked ? navigation : after.paths); + if (!matches(expected)) { + // The ordinary codec may encode the same file without progressive + // navigation. Verify that exact encoding too; never apply caller edits. + if (!navigation.length) return fallback(); + expected = currentChanges([]); + if (!matches(expected)) return fallback(); + } const result = applyTree(context, expected); if (!equal(result, currentContext)) return fallback(); return publish(request, {...copy(delivery), context: copy(currentContext)}, values); diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 53b7962709..22bb9899d3 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -8,7 +8,7 @@ import {GOAL_OUTCOMES_SCHEMA, OUTCOME_CONTEXT_NAVIGATION_SCHEMA} from "../goals/ import {FULL_OUTCOME_READ_GUIDANCE, PROGRESSIVE_OUTCOME_READ_GUIDANCE} from "../work_items/interaction_contract.ts"; import {quotaSummaryReferences, contextSourceIdentity, contextProjectionChanges, type ProjectionSourceValues} from "./diagnostic_projection.ts"; -import {encodeContextDelivery} from "./context_delivery.ts"; +import {encodeContextDelivery, fileNavigationPaths} from "./context_delivery.ts"; import {createHash} from "node:crypto"; type Path = (string | number)[]; @@ -244,10 +244,7 @@ export function prepareProgressiveContextDelivery(request: JsonObject, legacyFie const required = requiredNativeContext(request, sourceValues); const codec = nativeContextContinuity({...request, context: required.context, ...(request.source_number_tokens === undefined ? {} : {source_number_tokens: required.tokens})}); - const details = jsonObject(required.context.context_details); - const navigation: Path[] = details && Object.hasOwn(details, "unread_context_index") - ? [["context_details", "unread_context_index"]] : []; - const delivery = encodeContextDelivery(codec, legacyFields, sourceValues, navigation); + const delivery = encodeContextDelivery(codec, legacyFields, sourceValues, fileNavigationPaths(required.context)); const {operation: _operation, source_edits: _edits, source_number_tokens: _numbers, ...source} = request; return contextProjectionChanges(source, {context: required.context, delivery, projected: required.projected, read_sections: inlineReadSections(request, required.context)}, sourceValues); diff --git a/tests/control_plane/test_context_unread_reachability.py b/tests/control_plane/test_context_unread_reachability.py index 679871571b..f1f2e33986 100644 --- a/tests/control_plane/test_context_unread_reachability.py +++ b/tests/control_plane/test_context_unread_reachability.py @@ -77,6 +77,16 @@ def read_detail(source, pointer): _, warm = project(tmp_path, "next-turn", payload=current) assert warm["mode"] == "delta" required = json.loads(Path(warm["recovery"]["path"]).read_bytes()) + from loopx.control_plane.quota.turn_envelope import restore_context_delivery + + current_capture = {"schema_version": warm["schema_version"], "mode": "full", + "target": warm["target"], "context": required} + restored = restore_context_delivery(warm, baseline=first, current=current_capture) + assert restored["mode"] == "delta" and restored["context"] == required + damaged = copy.deepcopy(warm) + damaged["changes"] = {"value": required} # Same output, but an unauthorized alternative change tree. + rejected = restore_context_delivery(damaged, baseline=first, current=current_capture) + assert rejected["reason"] == "automatic_full_recovery" and rejected["context"] == required index = required["context_details"]["unread_context_index"] assert index["coverage_confirmed"] is False assert Path(index["source"]["path"]).name == "projection.json" diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index f201e58851..073ab91ff1 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -923,6 +923,7 @@ test("legacy quota fields are bound by the existing adapter, never caller data", test("verified file deltas inherit unchanged controls within an epoch without retained-input claims", () => { const original = {ok: true, goal_id: "goal", controls: {stop: false, owner: "agent"}, + context_details: {unread_context_index: {pointers: ["/support"]}}, opaque_control: "Unknown controls also remain available in the exact file. ".repeat(400)}; const baseline = full(original); const nextScope = {...scope}; @@ -934,6 +935,7 @@ test("verified file deltas inherit unchanged controls within an epoch without re assert.equal(delta.continuity_basis, "captured_file"); assert.ok(JSON.stringify(delta).length < JSON.stringify(current).length / 3); const restored = restoreContextDelivery({delivery: delta, baseline, current: prepareContextDelivery(args)}); + assert.equal(restored.mode, "delta"); assert.deepEqual(restored.context, current); for (const key of ["goal_id", "agent_id", "session_id", "context_epoch", "continuity_generation"]) { assert.equal(prepareContextDelivery({...args, scope: {...nextScope, [key]: "other"}, baseline, continuity}).mode, "full");