diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 138428c36b..fefe903dd4 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -57,8 +57,23 @@ narrative is never classified by headings or clipped to a size budget. Read all delivered context and remaining reads before dependent work; changed sources need a fresh guard. Enabled scoped acceptance does not replace original Goal intent. +Explicitly enabled native context delivery requests complete Goal material before +capture. For an unmarked or mixed Goal, the registered-source adapter supplies +the entire original UTF-8 text with its full-file revision and `full_source: true`. +The existing TypeScript interaction owner checks that digest before fulfilling +the read; `complete_goal_v1` acknowledges support so an older runtime cannot +silently retain a pending Goal read. Missing or invalid sources still reject. +Marked sources retain their generated-region lineage and canonical Todo authority. + +Native delta delivery and source fulfillment share the same integrity predicate. +Only exact verified Goal text can be inherited from the retained baseline; +current source witnesses and unknown fields remain literal. Cold delivery and +context-loss recovery retain the complete original text, including arbitrary +sections, comments and final stops. This does not establish model retention, +execution authority, token savings or accepted outcome gains. + The Goal availability adapter still reads and UTF-8-validates the complete file -and hashes all bytes for its revision. For unmarked or mixed ownership, only its path and revision cross the +and hashes all bytes for its revision. With native context delivery off, only the path and revision of unmarked or mixed sources cross the internal context-projection bridge, preserving the complete progressive read without an unused body copy. Explicitly owned sources send the complete non-generated narrative and region lineage to the typed consumer. Missing files @@ -454,8 +469,20 @@ Todo 区域相交的区间均拒绝。 普通注释、代码块中的示例、损坏或未知回执、其他 Goal 的回执和附带其他文字的行均原样 保留。这样首轮全文读取不再携带累积的恢复日志;不改变回执重放校验,也不据此宣称 模型输入或 token 成本已经降低。 -Goal adapter 仍完整读取文件、校验 UTF-8,并以全部字节计算修订摘要;未标记或混合归属 -仅在内部桥接传路径与修订,避免未被消费的正文副本,Agent 仍须渐进式全文读取。显式 +显式开启的 native context delivery 在捕获前请求完整 Goal。未标记或混合归属的来源 +由原 registered-source adapter 提供全部原始 UTF-8 正文、完整修订与 `full_source: true`。 +既有 TypeScript interaction owner 核对正文 digest 后才履行读取;`complete_goal_v1` +确认 runtime 支持,防止旧版静默保留未完成 Goal 读取。来源缺失或无效仍拒绝;已有标记 +来源保留生成区域血缘和 canonical Todo 权限。 + +native 差分与来源履行共享同一完整性判断;只有精确核验的 Goal 正文可从保留的 +baseline 继承,当前来源凭据和未知字段仍明文提供。冷态及上下文丢失恢复保留完整 +原文,包括任意章节、注释和末尾停止条件。不据此证明模型保留、执行权限、token 节省 +或已验收成果收益。 + +Goal adapter 仍完整读取文件、校验 UTF-8,并以全部字节计算修订摘要;关闭 native +context delivery 时,未标记或混合归属仅在内部桥接传路径与修订,避免未被消费的正文 +副本,Agent 仍须渐进式全文读取。显式 归属的来源才向 typed owner 传完整非生成正文和区域血缘。字节减少不能证明模型费用、 延迟或任务效果改善。 已附带的内容不要求重复读取;未提供的 provider 内容继续保留精确命令与顺序。 diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index c6c5b7f319..540f0ea7a1 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -9,6 +9,7 @@ from .quota_capture import ( bind_capture_selection_transport, capture_decision, handoff_decision, handle_native_context_command, handle_replan_view, prepare_quota_capture, native_context_capture_view, + native_context_activation, ) from ..control_plane.turn_driver.decision_capture import ( context_capture_read_command, @@ -374,8 +375,6 @@ def _emit_quota_result( turn_id=_effective_spend_turn_instance_id(payload, heartbeat_turn_id=heartbeat_turn_id), started_at=usage_quota_started, ) - from .quota_capture import native_context_activation - context_delivery_enabled = payload.get("ok") is True and native_context_activation(args)["enabled"] bind_capture_selection_transport(payload, args) delivery_request = None @@ -610,6 +609,7 @@ def handle_quota_command( status_payload, goal_id=args.goal_id, agent_id=args.agent_id, + inline_goal_source=native_context_activation(args)["enabled"], available_capabilities=args.available_capabilities, include_scheduler_detail="scheduler" in detail_sections, include_agent_todo_detail=( diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index ec4b6fbca7..c3a7c71ff7 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -1,3 +1,4 @@ +import {completeGoalNarrative} from "../work_items/goal_context_source.ts"; /** Presentation of immutable quota, Turn and work-context captures. No admission or state writes. * Confirmed final input and explicit native-hook continuity assumptions remain * distinct; successful capture handoff alone is never retained-input proof. */ @@ -84,7 +85,8 @@ function splitContext(context: JsonObject, binding: JsonObject): Split { && readModel.contract_fields.every(field => typeof field === "string")) add(`todo_contract_fields:${i}`, [...prefix, "authority_read", "todo_read_model", "contract_fields"], true); if (kind === "goal_state" && typeof content.state_file === "string" - && typeof content.text === "string" && Array.isArray(content.excluded_todo_regions)) { + && typeof content.text === "string" && (Array.isArray(content.excluded_todo_regions) + || content.full_source === true && completeGoalNarrative(content))) { const id = JSON.stringify([kind, content.state_file]); if (sourceIds.has(id)) { valid = false; continue; } sourceIds.add(id); diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 8518f03ad5..982c5695d1 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -547,6 +547,7 @@ def build_live_quota_should_run_decision( turn_start_hook_dispatch: Mapping[str, Any] | None = None, workspace_path: Path | None = None, goal_ref: Mapping[str, object] | None = None, + inline_goal_source: bool = False, ) -> dict[str, Any]: """Build one live CLI decision while keeping host observation injectable.""" resolved_context = resolve_scheduler_execution_context(scheduler_execution_context) @@ -824,5 +825,5 @@ def build_live_quota_should_run_decision( from ..work_items.context_readback import attach_work_context attach_work_context(payload, registry_path=registry_path, runtime_root=runtime_root, - hook_dispatch=turn_start_hook_dispatch) + hook_dispatch=turn_start_hook_dispatch, inline_goal_source=inline_goal_source) return payload diff --git a/loopx/control_plane/work_items/context_readback.py b/loopx/control_plane/work_items/context_readback.py index cf071637eb..e110d6e627 100644 --- a/loopx/control_plane/work_items/context_readback.py +++ b/loopx/control_plane/work_items/context_readback.py @@ -20,7 +20,8 @@ from ..todos.summary_item import todo_text_content_revision -def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id): +def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id, + inline_goal_source=False): source = read.get("source") if source == "goal_state": _goal, _project, state_file = resolve_goal_state( @@ -32,8 +33,8 @@ def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id): "revision": "sha256:" + sha256(source_bytes).hexdigest()} # Only the writer's explicitly marked regions have generated ownership. # Retain every other byte, except identity-bound historical Monitor - # receipt lines owned by their codec. Legacy/unmarked input stays a full - # progressive read; malformed ownership fails rather than hiding text. + # receipt lines owned by their codec. Progressive callers keep legacy + # reads; native delivery retains the entire unmarked source. lines = text.splitlines(keepends=True) visible = visible_markdown_lines(lines) regions = find_todo_regions(lines, visible=visible) @@ -63,6 +64,8 @@ def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id): # grows the current prompt without adding recovery information. content["excluded_monitor_receipts"] = {"count": receipt_count, "line_ranges": receipt_ranges} + if inline_goal_source and "text" not in content: + content.update(text=text, full_source=True) return content if source == "goal_acceptance": # The typed acceptance owner scopes task-binding observations while @@ -127,7 +130,8 @@ def _user_context(*, registry_path, runtime_root, goal_id, agent_id): def attach_work_context(payload: dict[str, Any], *, registry_path: Path, - runtime_root: Path, hook_dispatch: dict[str, Any] | None) -> None: + runtime_root: Path, hook_dispatch: dict[str, Any] | None, + inline_goal_source: bool = False) -> None: packet_selected = payload.get("selected_todo") selected_text_snapshot = None if isinstance(packet_selected, dict): @@ -152,7 +156,8 @@ def attach_work_context(payload: dict[str, Any], *, registry_path: Path, result = {"command": read["command"]} try: result["content"] = _source_content(read, registry_path=registry_path, - runtime_root=runtime_root, goal_id=goal_id, todo_id=plan.get("todo_id")) + runtime_root=runtime_root, goal_id=goal_id, todo_id=plan.get("todo_id"), + inline_goal_source=inline_goal_source) except (OSError, ValueError, RuntimeError): result["error_code"] = "context_source_unavailable" results.append(result) @@ -167,7 +172,12 @@ def attach_work_context(payload: dict[str, Any], *, registry_path: Path, "required_reads": reads, "source_results": results, "selected_todo": plan.get("selected_todo"), "user_todos": users, "hook_dispatch": hook_dispatch, + **({"require_full_goal_source": True} if inline_goal_source else {}), }, large_local_snapshot=True) + # An older runtime must not silently ignore the requested delivery profile. + profile = projected.pop("source_delivery_profile", None) + if inline_goal_source and profile != "complete_goal_v1": + raise RuntimeError("Complete Goal source delivery is unsupported by this runtime") channel.update(projected) selected_todo = payload.get("selected_todo") if isinstance(selected_todo, dict): diff --git a/loopx/control_plane/work_items/goal_context_source.ts b/loopx/control_plane/work_items/goal_context_source.ts new file mode 100644 index 0000000000..23aa190569 --- /dev/null +++ b/loopx/control_plane/work_items/goal_context_source.ts @@ -0,0 +1,67 @@ +/** Integrity of registered Goal source observations, shared by delivery and exact reuse. */ +import {createHash} from "node:crypto"; +import type {JsonObject} from "../effect_program.ts"; +import {jsonObject} from "../runtime_decode.ts"; +import {ENVELOPED_SHA256_PATTERN} from "../content_digest.ts"; + +/** The registered file adapter reuses the writer's strict region parser. + * No heading/prose classification here: its exact complement is Goal context, + * and canonical Todo readers retain task authority. A complete original file + * needs no exclusions; metadata alone retains the progressive read. */ +export function completeGoalNarrative(content: JsonObject): boolean { + if (Object.hasOwn(content, "read_complete") && content.read_complete !== true) return false; + if (Object.hasOwn(content, "full_source")) { + return content.full_source === true && typeof content.text === "string" + && typeof content.state_file === "string" && content.state_file.length > 0 + && content.revision === `sha256:${createHash("sha256").update(content.text, "utf8").digest("hex")}` + && !Object.hasOwn(content, "excluded_todo_regions") + && !Object.hasOwn(content, "excluded_monitor_receipts"); + } + if (!Array.isArray(content.excluded_todo_regions) || !content.excluded_todo_regions.length + || typeof content.text !== "string" || typeof content.state_file !== "string" || !content.state_file + || typeof content.revision !== "string" || !ENVELOPED_SHA256_PATTERN.test(content.revision)) return false; + let end = 0; + if (!content.excluded_todo_regions.every(value => { + const region = jsonObject(value); + if (!region || !["user", "agent", "archive"].includes(String(region.role)) + || !Number.isSafeInteger(region.start_line) || !Number.isSafeInteger(region.end_line) + || Number(region.start_line) <= end || Number(region.end_line) < Number(region.start_line) + || typeof region.revision !== "string" || !ENVELOPED_SHA256_PATTERN.test(region.revision)) return false; + end = Number(region.end_line); + return true; + })) return false; + if (!Object.hasOwn(content, "excluded_monitor_receipts")) return true; + const receipts = content.excluded_monitor_receipts; + const compact = jsonObject(receipts); + let ranges: unknown[]; + let expectedCount: number; + if (compact) { + if (Object.keys(compact).some(key => !["count", "line_ranges"].includes(key)) + || !Number.isSafeInteger(compact.count) || Number(compact.count) <= 0 + || !Array.isArray(compact.line_ranges)) return false; + ranges = compact.line_ranges; + expectedCount = Number(compact.count); + } else { + // Already captured source observations retain their original readback. + if (!Array.isArray(receipts) || !receipts.length) return false; + ranges = []; + for (const value of receipts) { + const region = jsonObject(value); + if (!region || region.start_line !== region.end_line || typeof region.revision !== "string" + || !ENVELOPED_SHA256_PATTERN.test(region.revision)) return false; + ranges.push([region.start_line, region.end_line]); + } + expectedCount = receipts.length; + } + end = 0; + let count = 0; + return ranges.length > 0 && ranges.every(range => { + if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isSafeInteger) + || range[0] <= end || range[1] < range[0] + || (content.excluded_todo_regions as JsonObject[]).some(todo => + range[0] <= Number(todo.end_line) && range[1] >= Number(todo.start_line))) return false; + end = range[1]; + count += range[1] - range[0] + 1; + return true; + }) && Number.isSafeInteger(count) && count === expectedCount; +} diff --git a/loopx/control_plane/work_items/interaction_contract.ts b/loopx/control_plane/work_items/interaction_contract.ts index 651969b505..70b85d9464 100644 --- a/loopx/control_plane/work_items/interaction_contract.ts +++ b/loopx/control_plane/work_items/interaction_contract.ts @@ -1,3 +1,4 @@ +import {completeGoalNarrative} from "./goal_context_source.ts"; import {actionSelectionNeedsRecovery} from "./action_selection_admission.ts"; import {configuredGoalChecks} from "../goals/acceptance_contract.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; @@ -840,63 +841,30 @@ export function planInteractionWorkContext(request: JsonObject): JsonObject { && packet.effective_action !== EffectiveAction.HEARTBEAT_SETTLED_SKIP}; } -/** The registered file adapter reuses the writer's strict region parser. - * No heading/prose classification here: its exact complement is Goal context, - * and canonical Todo readers retain task authority. Unknown/legacy input keeps - * the complete progressive read rather than pretending to fulfill it. */ -function completeGoalNarrative(content: JsonObject): boolean { - if (!Array.isArray(content.excluded_todo_regions) || !content.excluded_todo_regions.length - || typeof content.text !== "string" || typeof content.state_file !== "string" || !content.state_file - || typeof content.revision !== "string" || !ENVELOPED_SHA256_PATTERN.test(content.revision)) return false; - let end = 0; - if (!content.excluded_todo_regions.every(value => { - const region = jsonObject(value); - if (!region || !["user", "agent", "archive"].includes(String(region.role)) - || !Number.isSafeInteger(region.start_line) || !Number.isSafeInteger(region.end_line) - || Number(region.start_line) <= end || Number(region.end_line) < Number(region.start_line) - || typeof region.revision !== "string" || !ENVELOPED_SHA256_PATTERN.test(region.revision)) return false; - end = Number(region.end_line); - return true; - })) return false; - if (!Object.hasOwn(content, "excluded_monitor_receipts")) return true; - const receipts = content.excluded_monitor_receipts; - const compact = jsonObject(receipts); - let ranges: unknown[]; - let expectedCount: number; - if (compact) { - if (Object.keys(compact).some(key => !["count", "line_ranges"].includes(key)) - || !Number.isSafeInteger(compact.count) || Number(compact.count) <= 0 - || !Array.isArray(compact.line_ranges)) return false; - ranges = compact.line_ranges; - expectedCount = Number(compact.count); - } else { - // Already captured source observations retain their original readback. - if (!Array.isArray(receipts) || !receipts.length) return false; - ranges = []; - for (const value of receipts) { - const region = jsonObject(value); - if (!region || region.start_line !== region.end_line || typeof region.revision !== "string" - || !ENVELOPED_SHA256_PATTERN.test(region.revision)) return false; - ranges.push([region.start_line, region.end_line]); +export function assertGoalContextDelivered(channel: JsonObject): void { + const reads = Array.isArray(channel.required_reads) ? channel.required_reads : []; + if (reads.some(read => jsonObject(read)?.source === "goal_state")) { + throw new EffectRuntimeRequestError("Complete Goal source is required before host dispatch"); + } + const context = channel.work_context === undefined ? {} + : requireJsonObject(channel.work_context, "Goal work context"); + if (context.sources !== undefined && !Array.isArray(context.sources)) { + throw new EffectRuntimeRequestError("Goal source records must be an array"); + } + for (const value of Array.isArray(context.sources) ? context.sources : []) { + const source = jsonObject(value); + if (source?.source === "goal_state" && !completeGoalNarrative(jsonObject(source.content) ?? {})) { + throw new EffectRuntimeRequestError("Goal source content or revision is invalid"); } - expectedCount = receipts.length; } - end = 0; - let count = 0; - return ranges.length > 0 && ranges.every(range => { - if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isSafeInteger) - || range[0] <= end || range[1] < range[0] - || (content.excluded_todo_regions as JsonObject[]).some(todo => - range[0] <= Number(todo.end_line) && range[1] >= Number(todo.start_line))) return false; - end = range[1]; - count += range[1] - range[0] + 1; - return true; - }) && Number.isSafeInteger(count) && count === expectedCount; } -/** Deliver full registered sources once. Unowned mixed Goal documents remain - * progressive reads; failed/changed sources forbid dependent work. */ +/** Deliver full registered sources once. Ordinary mixed Goals retain progressive + * reads; native delivery can require the complete original. */ export function projectInteractionWorkContext(request: JsonObject): JsonObject { + if (request.require_full_goal_source !== undefined && typeof request.require_full_goal_source !== "boolean") { + throw new EffectRuntimeRequestError("Goal source delivery requirement must be boolean"); + } if (!Array.isArray(request.required_reads) || !Array.isArray(request.source_results)) { throw new EffectRuntimeRequestError("context reads and source results must be arrays"); } @@ -952,11 +920,13 @@ export function projectInteractionWorkContext(request: JsonObject): JsonObject { pending.push(read); } else if (read.source === "goal_state") { if (completeGoalNarrative(content!)) sources.push({...read, - reason: `${String(read.reason ?? "")} This registered source delivers all text outside explicitly generated Todo regions inline, with full-file revision and excluded-region lineage; exact canonical Todo reads own task state/claims.${content!.excluded_monitor_receipts ? " Recognized historical Monitor receipt lines retain source lineage instead of repeating their encoded bodies; the original file remains available for receipt recovery." : ""}`, + reason: content!.full_source === true + ? `${String(read.reason ?? "")} This registered source delivers the complete original Goal file inline, bound to its full-file revision; canonical Todo reads retain task authority.` + : `${String(read.reason ?? "")} This registered source delivers all text outside explicitly generated Todo regions inline, with full-file revision and excluded-region lineage; exact canonical Todo reads own task state/claims.${content!.excluded_monitor_receipts ? " Recognized historical Monitor receipt lines retain source lineage instead of repeating their encoded bodies; the original file remains available for receipt recovery." : ""}`, content: content!}); else { pending.push(read); - if (Object.hasOwn(content!, "excluded_todo_regions")) failures.push({ + if (Object.hasOwn(content!, "excluded_todo_regions") || Object.hasOwn(content!, "full_source")) failures.push({ source: read.source, command: read.command, error_code: "context_source_unavailable", instruction: "Recover complete Goal source and rerun the guard; malformed region lineage cannot fulfill it.", }); @@ -1006,6 +976,9 @@ export function projectInteractionWorkContext(request: JsonObject): JsonObject { } } } + if (request.require_full_goal_source === true) { + assertGoalContextDelivered({required_reads: pending, work_context: {sources}}); + } const dispatch = jsonObject(request.hook_dispatch); const unavailable = projectTurnStartUnavailableContext(dispatch); const users = jsonObject(request.user_todos); @@ -1013,7 +986,8 @@ export function projectInteractionWorkContext(request: JsonObject): JsonObject { instruction: "Recover current User obligations and rerun the guard."}); const includeUserTodos = users && !users.error_code && (!Array.isArray(users.todos) || users.todos.length > 0 || users.authority_read != null); - return {required_reads: pending, work_context: {complete: failures.length === 0, + return {...(request.require_full_goal_source === true ? {source_delivery_profile: "complete_goal_v1"} : {}), + required_reads: pending, work_context: {complete: failures.length === 0, ...(selectedTodoTextVerified ? {selected_todo_text_verified: true} : {}), ...(selectedTodoRef ? {selected_todo_ref: "selected_todo"} : {}), ...(selectedTodoAuthority ? {selected_todo_authority: selectedTodoAuthority} : {}), diff --git a/tests/control_plane/test_goal_state_context_transport.py b/tests/control_plane/test_goal_state_context_transport.py index ed8e293553..759c9296db 100644 --- a/tests/control_plane/test_goal_state_context_transport.py +++ b/tests/control_plane/test_goal_state_context_transport.py @@ -1,4 +1,4 @@ -"""Full Goal availability checks do not transport an unconsumed source body.""" +"""Progressive quota and complete host delivery retain their distinct IO contracts.""" from __future__ import annotations import contextlib @@ -6,6 +6,7 @@ from hashlib import sha256 import io import json +import shlex import pytest @@ -20,9 +21,11 @@ @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_goal_availability_transport_preserves_full_read_and_recovery(tmp_path, monkeypatch, provider): isolate_sqlite_runtime(tmp_path, monkeypatch) + monkeypatch.chdir(tmp_path) runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" goal, todo_id = "goal-context", "todo_export" initial = b"---\nstatus: active\n---\n# Goal\n## Objective\nPreserve the export contract.\n" + initial += "\n\n## Unknown section\n停止条件:不得发布。\n".encode() state.write_bytes(initial) write_fixture_registry(project=tmp_path, runtime_root=runtime, registry_path=registry, goal_id=goal, domain="software", adapter_kind="generic_project_goal_v0", @@ -37,13 +40,17 @@ def test_goal_availability_transport_preserves_full_read_and_recovery(tmp_path, source_args = dict(registry_path=registry, runtime_root=runtime, goal_id=goal, todo_id=todo_id) original_effect = context_readback.effect_runtime_result requests, plans = [], [] + omit_delivery_profile = [False] def observe(operation, request, **kwargs): if operation == "work_item.context.plan": plans.append(deepcopy(request["packet"])) if operation == "work_item.context.project": requests.append(deepcopy(request)) - return original_effect(operation, request, **kwargs) + result = original_effect(operation, request, **kwargs) + if operation == "work_item.context.project" and omit_delivery_profile[0]: + result.pop("source_delivery_profile", None) + return result monkeypatch.setattr(context_readback, "effect_runtime_result", observe) @@ -58,6 +65,43 @@ def guard(): small_channel = guard() small_request = requests[-1] + from loopx.cli import build_parser + from loopx.cli_commands.quota_capture import read_context_capture + from loopx.control_plane.turn_driver import codex_context + + monkeypatch.setenv("CODEX_THREAD_ID", "complete-goal-native") + native = tmp_path / "native" + native.mkdir(mode=0o700) + captures = tmp_path / "captures" + captures.mkdir() + codex_context.codex_context_hook(str(native), {"hook_event_name": "SessionStart", + "source": "startup", "session_id": "complete-goal-native", "cwd": str(tmp_path)}) + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = main(["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "quota", "should-run", "--goal-id", goal, "--agent-id", "agent-a", "--codex-app", + "--todo-id", todo_id, "--turn-instance-id", "native-goal-material", + "--context-delivery", "on", "--codex-context-dir", str(native), + "--decision-output-root", str(captures)]) + page, raw = json.loads(output.getvalue()), bytearray() + assert code == 0 and page["ok"], (page.get("reason"), page.get("error"), page.get("error_code")) + digest = page["sha256"] + while True: + assert page["offset"] == len(raw) and page["sha256"] == digest + raw.extend(page["content"].encode()) + if page["eof"]: + assert len(raw) == page["total_bytes"] + break + page = read_context_capture(build_parser().parse_args(shlex.split(page["next_command"])[1:])) + assert sha256(raw).hexdigest() == digest + native_channel = json.loads(raw)["interaction_contract"]["agent_channel"] + delivered = [row["content"] for row in native_channel["work_context"]["sources"] + if row.get("source") == "goal_state"] + assert len(delivered) == 1, "Native delivery must include the complete registered Goal" + assert delivered[0] == {"state_file": str(state), "revision": "sha256:" + sha256(initial).hexdigest(), + "text": initial.decode(), "full_source": True} + assert not any(row.get("source") == "goal_state" for row in native_channel["required_reads"]) + assert state.read_bytes() == initial large = initial + b"\n## Evidence\n" + b"Prior evidence remains available. " * 100_000 large += "\n## Stop Conditions\n未经授权不得发布。\n".encode() assert len(large) > MAX_REQUEST_BYTES @@ -77,6 +121,16 @@ def guard(): assert len(json.dumps(small_request).encode()) == len(json.dumps(large_request).encode()) content = context_readback._source_content({"source": "goal_state"}, **source_args) assert content == {"state_file": str(state), "revision": "sha256:" + sha256(large).hexdigest()} + full = context_readback._source_content( + {"source": "goal_state"}, **source_args, inline_goal_source=True) + assert full == {**content, "text": large.decode("utf-8"), "full_source": True} + omit_delivery_profile[0] = True + try: + with pytest.raises(RuntimeError, match="unsupported by this runtime"): + context_readback.attach_work_context(deepcopy(captured_plan), registry_path=registry, + runtime_root=runtime, hook_dispatch=None, inline_goal_source=True) + finally: + omit_delivery_profile[0] = False # Independently compare the current typed projector with the former full # source input. Removing the transport must not weaken its obligations. legacy_request = deepcopy(large_request) @@ -111,5 +165,8 @@ def guard(): assert channel["delivery_allowed"] is False assert channel["work_context"]["complete"] is False assert any(row.get("source") == "goal_state" for row in channel["required_reads"]) + with pytest.raises(RuntimeError, match="Goal source"): + context_readback.attach_work_context(deepcopy(captured_plan), registry_path=registry, + runtime_root=runtime, hook_dispatch=None, inline_goal_source=True) state.write_bytes(large) assert guard() == large_channel diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index 7e942ad8eb..9c8d2b52db 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -1,3 +1,4 @@ +import {createHash} from "node:crypto"; import assert from "node:assert/strict"; import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; @@ -185,6 +186,36 @@ function inline(delivery: JsonObject, path: (string | number)[]): unknown { } } +test("complete original Goal text reuses the same integrity witness as Host dispatch", () => { + const goal = {state_file: "goal.md", text: long, full_source: true, + revision: `sha256:${createHash("sha256").update(long, "utf8").digest("hex")}`}; + const value = {complete: true, sources: [{source: "goal_state", content: goal}]}; + const baseline = full(value); + const delta = warm(value, baseline); + assert.equal(delta.mode, "delta"); + assert.ok(!JSON.stringify(delta.changes).includes(long), "A verified complete Goal is already in the retained input"); + assert.equal(inline(delta, ["sources", 0, "content", "full_source"]), true); + assert.equal(inline(delta, ["sources", 0, "content", "revision"]), goal.revision); + assert.deepEqual(recover(delta, baseline, value).context, value); + for (const changed of [ + {...goal, text: long + "Corrected final stop."}, + {...goal, revision: `sha256:${"a".repeat(64)}`}, + {...goal, read_complete: false}, {...goal, full_source: false}, + ]) { + const current = {complete: true, sources: [{source: "goal_state", content: changed}]}; + const wire = warm(current, baseline); + const text = wire.mode === "full" + ? ((wire.context as JsonObject).sources as JsonObject[])[0].content as JsonObject + : {text: inline(wire, ["sources", 0, "content", "text"])}; + assert.equal(text.text, changed.text); + assert.deepEqual(recover(wire, baseline, current).context, current); + } + const corrected = {...goal, text: long + "Corrected final stop."}; + corrected.revision = `sha256:${createHash("sha256").update(corrected.text, "utf8").digest("hex")}`; + const current = {complete: true, sources: [{source: "goal_state", content: corrected}]}; + assert.deepEqual(recover(warm(current, baseline), baseline, current).context, current); +}); + test("full first; unsupported or uncertain retention never becomes an Agent gate", () => { const original = context(), saved = structuredClone(original), baseline = full(original); assert.equal(baseline.mode, "full"); diff --git a/tests/control_plane_ts/interaction_contract.test.ts b/tests/control_plane_ts/interaction_contract.test.ts index 830a872387..5f7212d2f3 100644 --- a/tests/control_plane_ts/interaction_contract.test.ts +++ b/tests/control_plane_ts/interaction_contract.test.ts @@ -13,6 +13,7 @@ import { INTERACTION_EXECUTION_FACTS_SCHEMA_VERSION, projectInteractionRequiredReads, projectInteractionWorkContext, + assertGoalContextDelivered, projectBlockedSuccessorWaitObservation, type AgentInteractionChannel, } from "../../loopx/control_plane/work_items/interaction_contract.ts"; @@ -642,6 +643,31 @@ test("declared outcomes remain required reads without enabled checks after a wor }); +test("full original Goal delivery fulfills its read without excluding legacy text", () => { + const text = "Original intent\nUnknown section\n停止条件:不得发布。\n"; + const read = {source: "goal_state", command: "cat -- goal.md", ordering: "before_work"}; + const content = {state_file: "goal.md", text, full_source: true, + revision: `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`}; + const project = (value: JsonObject | null) => projectInteractionWorkContext({ + required_reads: [read], require_full_goal_source: true, + source_results: value ? [{command: read.command, content: value}] : [], + }); + const fulfilled = project(content); + assert.equal(fulfilled.source_delivery_profile, "complete_goal_v1"); + assert.deepEqual(fulfilled.required_reads, []); + assert.equal((fulfilled.work_context as JsonObject).complete, true); + const [source] = (fulfilled.work_context as JsonObject).sources as JsonObject[]; + assert.deepEqual(source.content, content); + for (const invalid of [null, {state_file: "goal.md", revision: content.revision}, + {...content, text: "Original intent\n"}, {...content, full_source: false}, + {...content, excluded_todo_regions: []}, {...content, read_complete: false}]) { + assert.throws(() => project(invalid), /Goal source/); + } + assert.throws(() => assertGoalContextDelivered({required_reads: [read]}), /Goal source/); + assert.throws(() => assertGoalContextDelivered({required_reads: [], + work_context: {sources: [{source: "goal_state", content: {...content, read_complete: false}}]}}), /Goal source/); +}); + test("Goal narrative fulfillment needs complete source and ordered region lineage", () => { const read = {source: "goal_state", command: "cat -- goal.md"}; const region = {role: "agent", start_line: 4, end_line: 8, revision: `sha256:${"b".repeat(64)}`};