diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md new file mode 100644 index 0000000000..f2e3434451 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md @@ -0,0 +1,59 @@ +# Todo summary-display projection retirement (2026-10-26) + +| Boundary | Delivered evidence | +| --- | --- | +| Goal/source | Existing TypeScript migration T3/T4 transaction payoff; baseline `c6462f902`. One read-only summary-display slice, with no new capability, RPC operation, provider or runtime. | +| Observable gap | Python still selected summary/planning sources, normalized display declarations and assembled handoff displays before asking the typed quota owner to select lanes. | +| Single owner | `todos/context_projection.ts` now owns standalone summary compaction, source order/deduplication, ready-successor overlays and terminal-inclusive frontier preservation through the existing `todo.context.page`. `todos/read_codec.ts` reuses `handoffSource`; quota planning composes the display in process before lane decisions. Both complete planning decisions remain independent. | +| Retired symbols/callers | Python `_compact_todo_summary_item_record`, `_refs`, `_TEXT_FIELDS` and `handoff_context_source` are deleted. The bodies of `compact_todo_summary_items`, `todo_summary_source_items` and `todo_planning_source_items` become transport facades. `todo_index.py` sends raw record facts for both handoff stages. Existing `todo_summary.py` compact/open callers reach the same raw-fact adapter through `attach_todo_handoff_notes`; they require no new decision branch. `quota_selection.py` removes its display-source join and sends raw display facts. | +| Retained Python placement | `summary_item.py` retains public import signatures, source-key compatibility exports, empty/bounded transport, response validation and `todo_text_content_revision`. `handoff_note.py` retains generic JSON conversion with Python string/repr facts for tuples, sets, floats and other non-JSON scalars, batching/private snapshots, rejection propagation and mutable-row attachment identity. Existing handoff-gate source IO/compatibility remains. No Python decision fallback or per-field RPC is added. | +| Facade deletion condition | Delete the summary/handoff transport facades and their raw-fact request carriers after the last Python display, index, quota, management and planning callers consume the existing typed owners in process. Delete `todo_text_content_revision` only after its last compatibility caller exits. This does not retire the other context-page callers, delegation facts, Turn recovery or monitor-poll facades. | +| Formats/rollback | No persisted format, authority, CAS, writer fence, lease, receipt, provider selection or default changes. Rollback is a code-only revert; no data migration. | + +## Cost and real-path evidence + +Production source: `summary_item.py` 329 → 100 lines, `handoff_note.py` 150 → +112 lines; Python −273 net, TypeScript +157 net, production −116 net. The seven +production files total 242 added / 358 deleted lines. The related refactoring +pass reuses the existing handoff/read codecs rather than introducing another +owner or RPC leaf. Larger caller retirement is deferred to the existing exits. + +One real-CLI trace per command and fixture size (N=1/N=3) gives: + +| Command | Crossings base → candidate | Stdout bytes N=1 | Stdout bytes N=3 | +| --- | --- | --- | --- | +| `agent-context --phase before_plan` | 1 → 1 | 1885 → 1885 | 2247 → 2247 | +| `agent-context --phase after_delegate_result` | 3 → 3 | 1612 → 1612 | 1612 → 1612 | +| Same result phase with a Turn identity | 3 → 3 | 2594 → 2594 | 2594 → 2594 | +| `quota should-run` | 75 → 74 | 25520 → 25520 | 25904 → 25904 | + +The estimated 73-crossing target is **not met**: the first (empty user) planning +call has no display rows and therefore no preceding display RPC to remove. +Only the nonempty Agent planning display crossing is retired. Do not remove a +second planning decision or absorb an unrelated caller to manufacture the +estimate. Fewer crossings do not certify latency improvement; the matched +25-run performance comparison remains external qualification. + +The pre-cutover synthetic quota Todo rows and their Python display results are +pinned in `tests/control_plane_ts/fixtures/todo_summary_display.json`. An external isolated-runtime replay with Python/Node wall clocks pinned matches +all eight complete CLI outputs byte for byte, including `context_id`, against +the base Python owners. Independent TS cases cover nested-field precedence, +legacy identity, Unicode and credential +exclusion before truncation, bounded/public-safe blocked causes, open-advancement +hint visibility, normalized declarations, last-outcome precedence, ordered source +deduplication, explicit false readiness and terminal frontier fields. Python +retains real File/SQLite CLI recovery, mutation-identity, large/oversize transport +and fail-closed adapter tests, including tuple/set/float facts. + +Focused validation: 89 TS tests pass; typecheck, changed-Python ruff, configured +mypy (17 files), diff whitespace and the semantic advisory pass. The requested +Python set reports 467 passed and one temporary-path depth skip, with four +existing legacy-path failures (three quota legacy-provider +cases and `test_absent_fence_preserves_legacy_path_without_starting_typescript`), +reproduced with the base Python owners in the same environment. These remain +failures, not passing skips. The configured mypy list does not cover the changed +Python modules. Real installed-wheel qualification, the minimum Node lane and +isolated PostgreSQL CI are unverified here; no PostgreSQL code is changed. This +source checkpoint does not certify full CI, installed delivery or a latency gain. + +Review correction: summary display now preserves the canonical `decision_scope_outcomes[*].schema_version` as `todo_decision_scope_outcome_v0` by reusing the completion writer’s shared constant, replacing the unregistered `decision_scope_outcome_v0`; TS regression and File/SQLite `quota should-run` coverage were added. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md new file mode 100644 index 0000000000..0dd4684404 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md @@ -0,0 +1,52 @@ +# Todo 摘要展示投影退役(2026-10-26) + +| 边界 | 交付证据 | +| --- | --- | +| 目标/来源 | 既有 TypeScript 迁移 T3/T4 事务收益;基线 `c6462f902`。仅一个只读摘要展示切片,不新增 capability、RPC 操作、provider 或 runtime。 | +| 可观察缺口 | Python 仍在选择摘要/规划来源、规范化展示声明,并在请求 typed quota owner 选择 lane 前组装 handoff 展示。 | +| 唯一 owner | `todos/context_projection.ts` 通过既有 `todo.context.page` 统一负责独立摘要压缩、来源顺序/去重、后继就绪覆盖以及含终态的 frontier 字段保留。`todos/read_codec.ts` 复用 `handoffSource`;quota planning 在进程内先组合展示再决定 lane。两次完整规划决策保持独立。 | +| 删除的符号/调用方 | 删除 Python `_compact_todo_summary_item_record`、`_refs`、`_TEXT_FIELDS`、`handoff_context_source`。`compact_todo_summary_items`、`todo_summary_source_items`、`todo_planning_source_items` 的实现缩为传输 facade。`todo_index.py` 的两个 handoff 阶段改传原始记录事实。`todo_summary.py` 既有 compact/open 调用经 `attach_todo_handoff_notes` 使用同一原始事实适配器,无需新增决策分支。`quota_selection.py` 删除展示来源 join,改传原始展示事实。 | +| 保留 Python 的放置依据 | `summary_item.py` 保留公开导入签名、来源 key 兼容导出、空批次/有界传输、响应校验及 `todo_text_content_revision`。`handoff_note.py` 保留通用 JSON 转换,为 tuple、set、float 及其他非 JSON 标量提供 Python string/repr 事实,并保留分批/私有快照、拒绝传播及可变行 attachment identity。既有 handoff-gate 来源 IO/兼容适配保留。不新增 Python 决策回退或逐字段 RPC。 | +| Facade 删除条件 | 最后一个 Python 展示、索引、quota、管理及规划调用方改为在进程内消费既有 typed owner 后,删除摘要/handoff 传输 facade 及原始事实请求载体。`todo_text_content_revision` 仅在最后一个兼容调用方退出后删除。本切片不退役其他 context-page 调用方、委派事实、Turn recovery 或 monitor-poll facade。 | +| 格式/回滚 | 不改变持久化格式、authority、CAS、writer fence、lease、receipt、provider 选择或默认行为。仅回退代码即可,无需数据迁移。 | + +## 成本与真实路径证据 + +生产源码:`summary_item.py` 从 329 降至 100 行,`handoff_note.py` 从 150 +降至 112 行;Python 净减 273 行,TypeScript 净增 157 行,生产代码净减 +116 行。七个生产文件共增加 242/删除 358 行。相关重构复用既有 handoff/read +codec,不新增 owner 或 RPC leaf。更大范围调用方退役沿用既有退出条件。 + +对每条命令、每个夹具规模(N=1/N=3)各执行一次真实 CLI 跟踪,结果如下: + +| 命令 | 跨运行时调用:基线 → 候选 | Stdout 字节 N=1 | Stdout 字节 N=3 | +| --- | --- | --- | --- | +| `agent-context --phase before_plan` | 1 → 1 | 1885 → 1885 | 2247 → 2247 | +| `agent-context --phase after_delegate_result` | 3 → 3 | 1612 → 1612 | 1612 → 1612 | +| 同一 result 阶段,带 Turn identity | 3 → 3 | 2594 → 2594 | 2594 → 2594 | +| `quota should-run` | 75 → 74 | 25520 → 25520 | 25904 → 25904 | + +预估的 73 次目标**尚未达到**:首次规划针对空 User 来源,没有展示行,因此原本 +就不存在可删除的前置展示 RPC。仅退役非空 Agent 规划的展示调用。不得通过删除 +第二次规划决策或合并无关调用方来凑足预估。调用减少不证明延迟改善;同条件 +25 次性能对照仍由外部独立验收。 + +退役前的合成 quota Todo 行及其 Python 展示结果固定在 +`tests/control_plane_ts/fixtures/todo_summary_display.json`。在外部隔离 runtime 中固定 Python/Node 墙钟后,八条完整 CLI 输出与重放基线 +Python owner 的输出逐字节一致(包括 `context_id`)。独立 TS 用例覆盖 +嵌套字段优先级、旧 identity、Unicode 与截断前凭据排除、有界且公开安全的阻塞 +原因、仅 open advancement 可见的 hint、声明规范化、同 scope 最后 outcome 优先、 +有序来源去重、显式 false readiness 以及终态 frontier 字段。Python 保留真实 +File/SQLite CLI 恢复、可变行 identity、大/超大传输及 fail-closed 适配测试, +包括 tuple/set/float 事实。 + +聚焦验证:89 个 TS 测试通过;typecheck、变更 Python 的 ruff、配置内 mypy +(17 个文件)、diff 空白检查和语义 advisory 通过。要求的 Python 集合为 467 个通过、1 个临时路径深度限制的 skip,并存在四个 +既有 legacy 路径失败(三个 quota legacy-provider 用例,以及 +`test_absent_fence_preserves_legacy_path_without_starting_typescript`),在相同 +环境加载基线 Python owner 后复现。它们仍是失败,不计为通过的 skip。mypy +配置文件列表不覆盖本次修改的 Python 模块。真实 installed-wheel、最低 Node +版本及隔离 PostgreSQL CI 尚未验证;未修改 PostgreSQL 代码。本源码检查点不 +认证完整 CI、已安装交付或延迟收益。 + +审查修正:摘要展示复用 completion writer 的共享常量,将 `decision_scope_outcomes[*].schema_version` 保持为 canonical 记录中的 `todo_decision_scope_outcome_v0`,替换未注册的 `decision_scope_outcome_v0`;新增 TS 回归测试及 File/SQLite `quota should-run` 覆盖。 diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md new file mode 100644 index 0000000000..363c580a35 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md @@ -0,0 +1,73 @@ +# Todo summary-display follow-up qualification (2026-10-27) + +This entry supplements the [original retirement record](2026-10-26-todo-summary-display-projection.md). +The production boundary remains summary display through `todo.context.page` and +in-process quota planning; both complete planning decisions remain necessary. +There is no new provider, format, permission or RPC owner. + +## Preserve the earlier evidence + +The independent review of head `c242ca5f439c269c313329c883299a28d95a55dc` +against `be1dc37a008be7017b64b622f653b6707df5c544` found: + +- the registered `todo_decision_scope_outcome_v0` correction passed canonical + File/SQLite source and installed wheel/sdist checks; +- one SQLite CLI fixture used 83 → 82 requests, including 22 → 21 + `todo.context.page` calls. Its workload differs from the original N1/N3 + 75 → 74 trace; neither trace permits removing another planning decision; +- a fixed 32-pair CLI cohort had base/head p50/p95 336.97/521.98 and + 345.96/836.79 ms, a 60.31% candidate p95 increase; +- the predeclared 64-pair rerun had 301.56/400.98 and 305.88/391.08 ms. + This later result does not erase the earlier failed cohort. The difference + did not establish a repeatable regression or explain the earlier tail; +- cold startup, warm typed requests and idle/burst daemon RSS were still missing. + Python thread profiling did not measure Node CPU or establish tail causality. + +These historical cohorts keep their original status. They are not relabelled +as passes, and the default SQLite limits remain unchanged. + +## Rebase and real recovery + +The follow-up baseline is `ff8d9932294e9004b9ffeb2fb473d00354397eb8`. +Six File/SQLite recovery test cases initially stopped on +`canonical_authority_required`, before their intended assertions. The fixture +now initializes the real provider from authored source facts **before** calling +the production CLI; it does not restore a legacy read fallback. All eight +receipt/next-action cases pass, including version preservation, rejection of +wrong Todo step updates, legitimate original-Turn settlement and replay. +The earlier six failures remain recorded as fixture failures, not proof of +previously passing recovery behavior. + +## Final-head measurement contract + +[PR #7411](https://github.com/loopx-project/loopx/pull/7411) records the immutable +final head, execution commands, fixture fingerprint, environment and results. +Its bounded offline qualification uses the same synthetic canonical SQLite +Todo and interpreter on both source revisions, with no active Goal mutation: + +- 24 managed-process cold-start pairs, distinguishing warm filesystem/compile + cache from a fresh process; +- 64 complete `quota should-run` CLI pairs with alternating AB/BA order, all + samples retained and nearest-rank p50/p95; +- 256 warm typed requests and 256 complete quota-planning projections per + revision, separating transport from the Python facade; +- idle, sampled burst and post-burst daemon RSS over 2,000 bounded projections; +- real CLI identity/result assertions, focused TS/Python coverage and packaged + readback, reported separately from timing. + +Do not overlap owned heavy builds/tests with timed cohorts. Keep private raw +captures outside Git and publish only synthetic aggregates. A single synthetic +workload does not establish sustained large-Goal performance. Any miss retains +an owner-review hold; a faster microbenchmark cannot waive a full-CLI failure. + +## Rebase onto the subsequently merged main + +The final integration base advances to +`17b2a0f9b818dc6e6fe77ccc567b20ff91b3bc40`. Main now includes #7459's +canonical fixture repair and changes to the real quota/context path. The +conflict resolution keeps that shared fixture repair instead of duplicating it; +this PR retains the File/SQLite receipt-version regression. The earlier +`ff8d9932294e9004b9ffeb2fb473d00354397eb8` comparison and +`e7a54b8f32a648124133b02e11404b75a421fdac` head remain historical evidence. +The final PR reply records fresh exact-head correctness, package and matched +performance results for the new base. No earlier cohort is rewritten. diff --git a/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md new file mode 100644 index 0000000000..6dbd947ba0 --- /dev/null +++ b/docs/architecture/rfcs/ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md @@ -0,0 +1,57 @@ +# Todo 摘要展示后续验收(2026-10-27) + +本记录补充[原退役记录](2026-10-26-todo-summary-display-projection.zh-CN.md)。生产边界仍是 +`todo.context.page` 的摘要展示,以及 quota planning 内的直接组合;两个完整规划决策 +都仍有必要。不新增 provider、格式、权限或 RPC owner。 + +## 保留此前证据 + +独立评审对照 `be1dc37a008be7017b64b622f653b6707df5c544` 与 +`c242ca5f439c269c313329c883299a28d95a55dc` 得到: + +- 已注册 `todo_decision_scope_outcome_v0` 的修正通过真实 File/SQLite 来源及 + wheel/sdist 安装验证; +- 一个 SQLite CLI fixture 请求为 83 → 82,其中 `todo.context.page` 为 22 → 21。 + 它与原 N1/N3 的 75 → 74 负载不同;两者均不支持删除另一个规划决策; +- 固定 32 对 CLI 样本的基线/候选 p50/p95 为 336.97/521.98、345.96/836.79 ms, + 候选 p95 增加 60.31%; +- 预先声明的 64 对复测为 301.56/400.98、305.88/391.08 ms。后一次结果不能抹掉 + 前一次失败;不一致尚未证明可重复回归,也没有解释先前尾延迟; +- 冷启动、温态 typed request、daemon 空闲/突发 RSS 仍缺失。Python 线程 profile + 没有测得 Node CPU,也未证明尾延迟成因。 + +上述历史批次保留原结论,不改写成通过,默认 SQLite 限值也不放宽。 + +## Rebase 与真实恢复 + +本次后续工作的基线为 `ff8d9932294e9004b9ffeb2fb473d00354397eb8`。六个 File/SQLite +恢复用例最初在目标断言前因 `canonical_authority_required` 失败。现在 fixture 先用 +已写好的合成来源初始化真实 provider,再调用生产 CLI;没有恢复旧读取 fallback。 +八个 receipt/next-action 用例全部通过,覆盖版本保留、错误 Todo 步骤更新拒绝、 +原 Turn 合法结算与重放。之前六项保留为 fixture 失败,不声称先前已验证恢复。 + +## 最终 head 测量契约 + +[PR #7411](https://github.com/loopx-project/loopx/pull/7411) 记录不可变最终 head、执行命令、 +fixture 指纹、环境与结果。有界离线验收在两个修订上使用相同解释器和合成 canonical +SQLite Todo,不修改活跃 Goal: + +- 24 对受管进程冷启动,区分已温热的文件系统/编译缓存和新进程; +- 64 对完整 `quota should-run` CLI,AB/BA 交错、不删样本,按 nearest-rank 算 p50/p95; +- 每版本 256 次温态 typed request、256 次完整 quota-planning 投影,区分 transport + 与 Python facade; +- 2,000 次有界投影的 daemon 空闲、突发采样峰值和突发后 RSS; +- 真实 CLI 身份/结果断言、聚焦 TS/Python 与安装回读,与耗时分别报告。 + +计时期间不并行执行本 Agent 的重型构建/测试。原始私有记录留在 Git 外,只发布合成 +汇总。单个合成负载不能证明持续大 Goal 性能。任何未达标保留 owner-review hold; +微基准更快不能豁免完整 CLI 失败。 + +## 同步随后合入的主线 + +最终集成基线更新为 `17b2a0f9b818dc6e6fe77ccc567b20ff91b3bc40`。主线已包含 +#7459 的 canonical fixture 修复,也改动了真实 quota/context 路径。冲突解决沿用 +共同 fixture 修复,不重复补丁;本 PR 保留 File/SQLite 的 receipt-version 回归。 +此前 `ff8d9932294e9004b9ffeb2fb473d00354397eb8` 对照和 +`e7a54b8f32a648124133b02e11404b75a421fdac` head 保留为历史证据。最终 PR 回复 +记录新基线与新精确 head 的正确性、安装和匹配性能复测,不改写旧批次。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 769e313214..2d9ac52f66 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -35,6 +35,20 @@ real PostgreSQL server, Node 22.22.3, Windows and browser lanes, plus inherited main failures tracked by #7433–#7436 and #7389. See the [ownership, economics and validation ledger](ledger/typescript-control-plane-migration-v0/2026-10-27-primary-action-instruction-selection.md) for negative cases, retained-code deletion conditions and code-only rollback. +### Todo summary-display projection has one TypeScript owner (2026-10-26) + +The existing `todo.context.page` owns standalone summary/planning source +selection, display normalization and handoff composition. Quota planning +composes display in process; Python retains JSON/Python-value compatibility, +bounded transport, mutable-row attachment and the text-revision codec. +Completion and display share the registered scoped-decision receipt version; +no persisted format or authority boundary changes. Both planning decisions +remain. The [original source checkpoint](ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.md) +retains its initial counts and unverified boundaries. The [follow-up qualification](ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md) +records subsequent schema, recovery, packaging and matched-cost evidence at +separate immutable revisions. Later results do not relabel historical failures +or certify unrelated platform, large-Goal or long-run behavior. + ### Goal agent-context delegation projection converges in TypeScript (2026-10-25) `capability_hook.agent_context.project` @@ -2793,3 +2807,5 @@ Event replay and the reconciled cutover inventory: [2026-09-25](ledger/shared-go The native query now owns recovery lookup and completion capability evidence, with shared write/read status-phase checks. See the [2026-10-02 checkpoint](ledger/typescript-control-plane-migration-v0/2026-10-02-turn-journal-readback.md) for compatibility, measured cost, validation boundaries and facade exit. + +- Summary-display final-head qualification and preserved tail cohorts: [2026-10-27 follow-up](ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.md). diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 13bea77cae..93799409a8 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -32,6 +32,17 @@ merge_gate_passed: `true`、manual_holds: `0`,失败数为 0;八条 benchmar 以及由 #7433–#7436 和 #7389 跟踪的 main 继承失败。负例、保留代码删除条件及纯代码回滚见 [归属、经济性与验证 ledger](ledger/typescript-control-plane-migration-v0/2026-10-27-primary-action-instruction-selection.zh-CN.md)。 +### Todo 摘要展示投影收敛至唯一 TypeScript owner(2026-10-26) + +既有 `todo.context.page` 统一负责独立摘要/规划来源选择、展示规范化及 handoff +组合。Quota planning 在进程内组合展示;Python 保留 JSON/Python 值兼容、 +有界传输、可变行 attachment 及文本 revision codec。Completion 和展示共享已注册的 +scoped-decision 回执版本,不改变持久化格式或 authority 边界,两次规划决策保留。 +[原始源码 checkpoint](ledger/typescript-control-plane-migration-v0/2026-10-26-todo-summary-display-projection.zh-CN.md) +保留当时的计数与未验证边界;[后续资格记录](ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md) +按各自不可变版本记录 schema、恢复、打包与匹配成本证据。后续结果不追认历史失败, +也不认证无关平台、大 Goal 或长期行为。 + ### Goal agent-context 委派投影收敛至 TypeScript(2026-10-25) `capability_hook.agent_context.project` @@ -2069,3 +2080,5 @@ SQLite 中位数略升,收益不能外推为全面加速。这只资格化本 ### settlement 定位的 Turn journal 回读 原生查询统一拥有恢复定位和 completion capability 证据,并共享写入/回读的状态阶段约束。兼容变更、实测代价、验证范围与 facade 退出条件见 [2026-10-02 检查点](ledger/typescript-control-plane-migration-v0/2026-10-02-turn-journal-readback.zh-CN.md)。 + +- 摘要展示最终 head 验收与历史尾延迟批次:[2026-10-27 后续记录](ledger/typescript-control-plane-migration-v0/2026-10-27-todo-summary-qualification.zh-CN.md)。 diff --git a/loopx/control_plane/todos/context_projection.ts b/loopx/control_plane/todos/context_projection.ts index 22beacb5e0..8a3cb02ffa 100644 --- a/loopx/control_plane/todos/context_projection.ts +++ b/loopx/control_plane/todos/context_projection.ts @@ -1,9 +1,16 @@ +import {decodeTodoReadSource, handoffReadSource, handoffSource, copyTodoPythonValues, pythonFieldText, + id, object, taskClass, readResume, readScope, readScopes, readExcludedAgents, writeScopes, compactTodoReadText} from "./read_codec.ts"; +import {stripPythonWhitespace} from "../coordination/todo_agents.ts"; +import {normalizeTodoRepository} from "./work_requirements.ts"; +import {INTERNAL_STATE_SHAPE_PATTERNS} from "../goals/vision_checkpoint.ts"; +import codec from "./read_legacy_codec.json" with {type: "json"}; import {projectTodoHandoffContext} from "./handoff_projection.ts"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, requireInteger, requireJsonObject } from "../runtime_decode.ts"; import { canonicalAuthorityJson } from "../coordination/authority_store_codec.ts"; import {compactThinTodoRead, compactTodoReadSummary, compactThinTodoReadSummary} from "./read_presentation.ts"; +import {TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION} from "./user_completion_types.ts"; // A read lens over canonical records, shared by global and Goal conversations. // These are existing Todo facts, not dependency evaluation or execution grants. @@ -16,6 +23,10 @@ const CONTEXT_FIELDS = [ /** Select before redaction; never convert nested scopes or booleans to prose. */ export function projectTodoContextPage(input: JsonObject): JsonObject { + if (input.summary_display !== undefined) { + if (Object.keys(input).length !== 1) throw new EffectRuntimeRequestError("summary display cannot be combined with other views"); + return {items: projectTodoSummaryDisplay(requireJsonObject(input.summary_display, "summary display"))}; + } if (input.thin_summary_payload !== undefined) { const details = requireJsonObject(input.details, "thin summary details"); return compactThinTodoReadSummary(requireJsonObject(input.thin_summary_payload, "thin Todo summary"), { @@ -35,7 +46,9 @@ export function projectTodoContextPage(input: JsonObject): JsonObject { if (Object.keys(input).some(key => key !== "handoff_sources" && key !== "handoff_followups")) { throw new EffectRuntimeRequestError("handoff context cannot be combined with list/detail/page inputs"); } - return {handoff_context: projectTodoHandoffContext(input.handoff_sources, input.handoff_followups)}; + return {handoff_context: projectTodoHandoffContext( + Array.isArray(input.handoff_sources) ? input.handoff_sources.map(row => handoffReadSource(requireJsonObject(row, "handoff source"))) : input.handoff_sources, + Array.isArray(input.handoff_followups) ? input.handoff_followups.map(row => row === null ? null : handoffReadSource(requireJsonObject(row, "handoff followup"))) : input.handoff_followups)}; } if (input.list_payload !== undefined) return projectTodoListPayload(input); if (input.detail_payload !== undefined) return projectTodoDetailPayload(input); @@ -158,3 +171,111 @@ function projectTodoDetailPayload(input: JsonObject): JsonObject { }; return result; } + +const SUMMARY_FIELDS = [ + "goal_acceptance_guard", "schema_version", "todo_id", "content_revision", "role", + "status", "priority", "title", "archive_state", "source_section", + "task_class", "action_kind", "task_domain", "capability_binding_ref", "task_repository", + "continuation_policy", "removed_continuation_policy", "required_write_scopes", "required_capabilities", "target_capabilities", + "decision_scope", "required_decision_scopes", "decision_outcome", "decision_scope_outcomes", "claimed_by", + "bound_agent", "goal_bound", "blocks_agent", "excluded_agents", "global_gate", + "unblocks_todo_id", "resume_when", "resume_monitor_generation", "resume_condition", "resume_ready", + "blocked_retry", "no_followup", "successor_todo_ids", "completion_continuation", "completion_recovery", + "replan_obligation_id", "target_key", "cadence", "next_due_at", "expires_at", + "watch_only", "last_checked_at", "result_hash", "consecutive_no_change", "material_change", + "material_change_generation", "max_no_change_before_replan", "route_continuation_replan_required", "route_continuation_reason", "route_id", + "route_key", "completed_at", "updated_at", "completion_turn_key", "superseded_by", "handoff_note", +]; + +const SUMMARY_SOURCES = [ + "active_next_action_items", "active_next_action_executable_items", "first_open_items", "backlog_items", "unclaimed_priority_open_items", + "claimed_open_items", "claimed_advancement_open_items", "claimed_monitor_open_items", "monitor_open_items", "current_agent_claimed_open_items", + "current_agent_claimed_advancement_items", "current_agent_claimed_monitor_items", "blocker_items", "resume_blocked_items", "monitor_blocked_resume_candidates", + "current_agent_monitor_blocked_resume_candidates", "unclaimed_monitor_blocked_resume_candidates", "items", +]; + +const PLANNING_SOURCES = ["items", "deferred_items", "blocker_items", "monitor_open_items"]; + +function compactSummaryRecord(item: JsonObject, body: unknown): JsonObject { + const row: JsonObject = {index: item.index ?? null, text: body}; + for (const key of SUMMARY_FIELDS) if (item[key] != null) row[key] = item[key]; + const token = (key: string) => stripPythonWhitespace(pythonFieldText(row, key)); + copyTodoPythonValues(item, row); + let repository: string | null = null; + try {repository = normalizeTodoRepository(token("task_repository"));} + catch (error) {if (!(error instanceof EffectRuntimeRequestError)) throw error;} + const outcome = (value: unknown) => ["approve", "reject", "cancel"].includes(String(value).trim().toLowerCase()) ? String(value).trim().toLowerCase() : null; + const outcomes: JsonObject[] = []; + const raw = row.decision_scope_outcomes; + for (const value of Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [raw]) { + const parts = typeof value === "string" ? value.split("|", 3) : []; + const source = object(value), decision = outcome(source.outcome ?? parts[0]); + const scope = readScope(source.decision_scope || source.scope || parts[1]), todo = id(source.source_todo_id ?? parts[2]); + if (!decision || !scope || !todo) continue; + const record = {schema_version: TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION, outcome: decision, decision_scope: scope, source_todo_id: todo}; + const position = outcomes.findIndex(row => {const prior = object(row.decision_scope); return prior.kind === scope.kind && prior.granularity === scope.granularity && prior.scope_key === scope.scope_key;}); + if (position < 0) outcomes.push(record); else outcomes[position] = record; + } + const normalized: JsonObject = { + task_domain: /^[a-z][a-z0-9_.-]{0,63}$/.test(token("task_domain").toLowerCase()) ? token("task_domain").toLowerCase() : null, + required_write_scopes: writeScopes(Array.isArray(row.required_write_scopes) ? row.required_write_scopes : token("required_write_scopes")), task_repository: repository, + decision_scope: readScope(row.decision_scope), required_decision_scopes: readScopes(row.required_decision_scopes), + decision_outcome: outcome(token("decision_outcome")), decision_scope_outcomes: outcomes, + excluded_agents: readExcludedAgents(row.excluded_agents), + removed_continuation_policy: ["primary_review", "review_handoff"].includes(token("removed_continuation_policy").toLowerCase()) ? token("removed_continuation_policy").toLowerCase() : null, + replan_obligation_id: /^replan-[a-f0-9]{16}$/.test(token("replan_obligation_id")) ? token("replan_obligation_id") : null}; + for (const [key, value] of Object.entries(normalized)) { + if (value !== null && (!Array.isArray(value) || value.length)) row[key] = value; else delete row[key]; + } + const classify = (item: JsonObject) => taskClass({...item, text: ["title", "text"].map(key => pythonFieldText(item, key)).join(" ")}); + row.task_class = classify(row); + if (classify(item) === "blocker" || stripPythonWhitespace(pythonFieldText(item, "status")).toLowerCase() === "blocked") { + const reason = compactTodoReadText(pythonFieldText(item, "reason"), 220); + if (reason && !INTERNAL_STATE_SHAPE_PATTERNS.some(rule => rule.test(reason))) row.reason = reason; + } + return row; +} + +/** Standalone compatibility display, also composed inside quota planning. */ +export function projectTodoSummaryDisplay(input: JsonObject): JsonObject[] { + const planning = input.include_terminal !== undefined, terminal = input.include_terminal === true; + const source = input.source === undefined ? null : decodeTodoReadSource(requireJsonObject(input.source, "summary source")); + const ready = new Set((Array.isArray(input.handoff_gates) ? input.handoff_gates : []).flatMap(value => { + const gate = object(value); + return gate.gate_state === "cleared_with_successor" && Array.isArray(gate.successor_todo_ids) ? gate.successor_todo_ids.map(id).filter(Boolean) : []; + })); + const selected: {item: JsonObject; text: unknown}[] = [], seen = new Set(); + if (source) { + for (const key of planning ? PLANNING_SOURCES : SUMMARY_SOURCES) { + for (const value of Array.isArray(source[key]) ? source[key] as unknown[] : []) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const item = value as JsonObject, body = stripPythonWhitespace(pythonFieldText(item, "text")); + let todo = id(item.todo_id); + if (terminal && !todo) {const legacy = stripPythonWhitespace(pythonFieldText(item, "todo_id")); if (legacy.startsWith("todo_")) todo = legacy;} + if (!body || (planning ? !todo || !terminal && item.status === "done" || seen.has(todo!) : item.done === true)) continue; + if (!planning && selected.some(({item: prior, text}) => item.todo_id && prior.todo_id ? item.todo_id === prior.todo_id : prior.index === item.index && text === body)) continue; + if (todo) seen.add(todo); + selected.push({item, text: body}); + } + } + } else { + if (!Array.isArray(input.items)) throw new EffectRuntimeRequestError("summary items must be an array"); + for (const value of input.items) { + const encoded = requireJsonObject(value, "summary item"), item = decodeTodoReadSource(encoded); + selected.push({item, text: encoded.text ?? (input.strip_text && !Object.hasOwn(encoded, "text") ? stripPythonWhitespace(pythonFieldText(item, "text")) : item.text ?? null)}); + } + } + return selected.map(({item, text}) => { + const row = compactSummaryRecord(item, text); + const hint = projectTodoHandoffContext([handoffSource(item)])[0]!.continuation_hint; + const note = projectTodoHandoffContext([handoffSource(row)])[0]!.note; + if (row.task_class === "advancement_task" && row.status === "open" && hint) row.continuation_hint = hint; + if (note) row.handoff_note = note; + if (source && !planning && ready.has(id(row.todo_id)) && readResume(row.resume_when) && !Object.hasOwn(row, "resume_ready")) { + row.resume_ready = true; + row.resume_condition = {schema_version: "todo_resume_condition_v0", resume_when: row.resume_when, satisfied: true, source: "handoff_gate_cleared_with_successor"}; + } + if (terminal) for (const key of [...codec.frontier_fields, "evidence", "note", "last_actor_agent_id"]) if (item[key] != null) row[key] = item[key]; + return row; + }); +} diff --git a/loopx/control_plane/todos/handoff_note.py b/loopx/control_plane/todos/handoff_note.py index 2e4f780f29..e1cb43415f 100644 --- a/loopx/control_plane/todos/handoff_note.py +++ b/loopx/control_plane/todos/handoff_note.py @@ -6,71 +6,33 @@ """ from __future__ import annotations -import hashlib import json +import math from typing import Any -from .contract import ( - normalize_todo_action_kind, normalize_todo_claimed_by, normalize_todo_decision_scope, - normalize_todo_excluded_agents, normalize_todo_id, normalize_todo_id_list, - normalize_todo_required_decision_scopes, normalize_todo_resume_when, -) - TODO_HANDOFF_NOTE_SCHEMA_VERSION = "handoff_note_v0" TODO_CONTINUATION_HINT_MAX_CHARS = 280 -_TEXT_FIELDS = ("continuation_hint", "suggested_next_action", "note", "reason", "summary", - "intent", "blocked_on", "evidence", "title", "text", "task_class", "goal_id", - "source", "latest_event_kind") - - -def _refs(value: Any) -> dict[str, Any]: - values = [str(raw or "") for raw in value] if isinstance(value, (list, tuple, set)) else [] - return {**({"truthy": True} if value else {}), **({"values": values} if values else {})} - - -def handoff_context_source(item: dict[str, Any], *, goal_id: str | None = None, - source: str | None = None) -> dict[str, Any]: - """Encode language-native scalar facts; selection/compaction belongs to TS.""" - nested: dict[str, Any] = {} - nested_meta: dict[str, Any] = {} - for name in ("handoff", "handoff_note"): - value = item.get(name) - row = value if isinstance(value, dict) else {} - if value: - nested[name] = {"truthy": True, "fields": - {key: str(val or "") for key, val in row.items()} if isinstance(value, dict) else None} - nested_meta[name] = { - "from_agent": normalize_todo_claimed_by(row.get("from_agent") or item.get("agent_id")), - "to_agent": normalize_todo_claimed_by(row.get("to_agent")), - "evidence_refs": _refs(row.get("evidence_refs")), - } - todo_id = normalize_todo_id(item.get("todo_id")) - legacy_id = None if todo_id else "handoff_" + hashlib.sha1(str(( - item.get("goal_id"), item.get("role"), item.get("index"), item.get("text") or item.get("title"), - )).encode("utf-8")).hexdigest()[:12] - result = {**nested, "nested_metadata": nested_meta, - "texts": {key: str(item[key]) for key in _TEXT_FIELDS if item.get(key)}, - "metadata": {"todo_id": todo_id, - "claimed_by": normalize_todo_claimed_by(item.get("claimed_by")), - "excluded_agents": normalize_todo_excluded_agents(item.get("excluded_agents")), - "successor_todo_ids": normalize_todo_id_list(item.get("successor_todo_ids")), - "unblocks_todo_id": normalize_todo_id(item.get("unblocks_todo_id")), - "superseded_by": normalize_todo_id(item.get("superseded_by")), - "action_kind": normalize_todo_action_kind(item.get("action_kind")), - "resume_when": normalize_todo_resume_when(item.get("resume_when")), - "required_decision_scopes": normalize_todo_required_decision_scopes(item.get("required_decision_scopes")), - "decision_scope": normalize_todo_decision_scope(item.get("decision_scope"))}, - "evidence_refs": _refs(item.get("evidence_refs")), - "has_evidence": bool(item.get("evidence")), "has_note": bool(item.get("note")), - "goal_id": str(goal_id or ""), "source": str(source or ""), "legacy_id": legacy_id} - - # Omitted empty scalar/list facts have the codec's explicit empty default. - # Nested text keys are kept, including "", because presence changes priority. - for name, row in nested_meta.items(): - nested_meta[name] = {key: value for key, value in row.items() if value} - result["metadata"] = {key: value for key, value in result["metadata"].items() if value} - result["nested_metadata"] = {key: value for key, value in nested_meta.items() if value} - return {key: value for key, value in result.items() if value} + + +def todo_read_source(item: dict[str, Any], **overrides: Any) -> dict[str, Any]: + """Carry raw values plus Python-only string/repr facts, without field rules.""" + facts: list[Any] = [] + + def encode(value: Any, path: list[Any]) -> Any: + if isinstance(value, dict): + result = {str(key): encode(val, [*path, str(key)]) for key, val in value.items()} + elif isinstance(value, (list, tuple, set)): + result = [encode(val, [*path, index]) for index, val in enumerate(value)] + elif value is None or isinstance(value, (str, bool, int, float)): + result = str(value) if isinstance(value, float) and not math.isfinite(value) else value + else: + result = str(value or "") + if isinstance(value, (tuple, set, float)) or not isinstance(value, (dict, list, str, bool, int, type(None))): + facts.append([path, str(value or ""), repr(value)]) + return result + + record = encode(item, []) + return {"record": record, **({"python_values": facts} if facts else {}), **overrides} def validate_handoff_context(value: Any, count: int) -> list[dict[str, Any]]: @@ -124,14 +86,14 @@ def project_handoff_context(sources: list[dict[str, Any]], *, def compact_todo_continuation_hint(item: dict[str, Any]) -> str | None: - return project_handoff_context([handoff_context_source(item)])[0]["continuation_hint"] + return project_handoff_context([todo_read_source(item)])[0]["continuation_hint"] def build_todo_handoff_note(item: dict[str, Any], *, goal_id: str | None = None, source: str | None = None) -> dict[str, Any] | None: if not isinstance(item, dict): return None - return project_handoff_context([handoff_context_source(item, goal_id=goal_id, source=source)])[0]["note"] + return project_handoff_context([todo_read_source(item, goal_id=goal_id, source=source)])[0]["note"] def attach_todo_handoff_note(item: dict[str, Any], *, goal_id: str | None = None, @@ -143,7 +105,7 @@ def attach_todo_handoff_note(item: dict[str, Any], *, goal_id: str | None = None def attach_todo_handoff_notes(items: list[dict[str, Any]]) -> list[dict[str, Any]]: - results = project_handoff_context([handoff_context_source(item) for item in items]) + results = project_handoff_context([todo_read_source(item) for item in items]) for item, result in zip(items, results, strict=True): if result["note"]: item["handoff_note"] = result["note"] diff --git a/loopx/control_plane/todos/quota_selection.py b/loopx/control_plane/todos/quota_selection.py index 66cc50a392..2553f51f81 100644 --- a/loopx/control_plane/todos/quota_selection.py +++ b/loopx/control_plane/todos/quota_selection.py @@ -22,7 +22,7 @@ todo_summary_monitor_writeback_supported, ) from .resume_planning import build_todo_resume_planning_request -from .summary_item import compact_todo_summary_items +from .handoff_note import todo_read_source from .user_gate import is_user_gate_todo_item from .route_continuation import build_todo_route_continuation_facts from .handoff_gate import todo_summary_handoff_gates @@ -123,17 +123,11 @@ def project_quota_planning( observed_at = observed_now.timestamp() handoff_gates = todo_summary_handoff_gates(value) - display_sources = [*all_open_items, *(item for key in ("active_next_action_items", "active_next_action_executable_items") - for item in (value.get(key) if isinstance(value.get(key), list) else []) if isinstance(item, dict))] - displays = {id(item): display for item, display in zip(display_sources, - compact_todo_summary_items(display_sources, strip_text=True), strict=True)} - def encode(item: dict[str, Any]) -> dict[str, Any]: priority, index = todo_projection_sort_key(item) - display = displays[id(item)] due, expires = todo_item_next_due_at(item), todo_item_expires_at(item) return { - "payload": item, **({"display": display} if display != item else {}), + "payload": item, "display_source": todo_read_source(item), "claim": normalize_todo_claimed_by(item.get("claimed_by")), "bound": normalize_todo_bound_agent(item.get("bound_agent")), "blocks": normalize_todo_blocks_agent(item.get("blocks_agent")), diff --git a/loopx/control_plane/todos/quota_selection.ts b/loopx/control_plane/todos/quota_selection.ts index 234a15ee40..8545b72fa2 100644 --- a/loopx/control_plane/todos/quota_selection.ts +++ b/loopx/control_plane/todos/quota_selection.ts @@ -1,3 +1,4 @@ +import {projectTodoSummaryDisplay} from "./context_projection.ts"; import {isBoundedBlockedRetry} from "../quota/settlement_phase.ts"; import {parseTodoTimestampMicros} from "../runtime_timestamp.ts"; import {countTodoWork, decodeMonitorSchedule, monitorIsDue, monitorHasScheduleGap, @@ -417,7 +418,15 @@ export function projectTodoQuotaPlanning(value: unknown): JsonObject { "todo_quota_planning_request_v3", "todo_quota_planning_request_v4"].includes(String(request.schema_version))) { throw new EffectRuntimeRequestError("quota planning schema mismatch"); } - const selection = requireJsonObject(request.selection, "selection"); + const selection = {...requireJsonObject(request.selection, "selection")}; + for (const key of ["items", "active_items", "active_executable_items"]) { + if (!Array.isArray(selection[key])) continue; + selection[key] = (selection[key] as unknown[]).map(value => { + const row = requireJsonObject(value, "selection display source"); + return row.display_source === undefined ? row : {...row, + display: projectTodoSummaryDisplay({items: [row.display_source], strip_text: true})[0]}; + }); + } if (request.schema_version !== "todo_quota_planning_request_v0") { requireStringArray(selection.available, "available"); } diff --git a/loopx/control_plane/todos/read_codec.ts b/loopx/control_plane/todos/read_codec.ts index 1ad58bbcf1..9a8086deeb 100644 --- a/loopx/control_plane/todos/read_codec.ts +++ b/loopx/control_plane/todos/read_codec.ts @@ -68,7 +68,7 @@ export function readExcludedAgents(value: unknown): string[] { } export const readNoFollowup = (value: unknown): boolean | null => normalizeTodoNoFollowup(typeof value === "boolean" ? value : text(value || "")); export function readResume(value: unknown): string | null { - const candidate = compactPythonWhitespace(text(value || "")); + const candidate = compactPythonWhitespace(scalarText(value)); const known = normalizeTodoResumeWhen({schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, resume_when: candidate}); if (known) return known; const lowered = candidate.toLowerCase(); @@ -78,12 +78,12 @@ export function readResume(value: unknown): string | null { export function readScope(value: unknown): JsonObject | null { if (value == null || value === "") return null; const raw = typeof value === "string" ? value : object(value); - const selected = typeof raw === "string" ? raw : {kind: raw.kind, granularity: raw.granularity, scope_key: raw.scope_key, + const selected = typeof raw === "string" ? raw : {kind: pythonFieldText(raw, "kind"), granularity: pythonFieldText(raw, "granularity"), scope_key: pythonFieldText(raw, "scope_key"), ...(id(raw.decision_id) ? {decision_id: id(raw.decision_id)} : {})}; try {return normalizeTodoDecisionScope(selected);} catch (error) {if (error instanceof EffectRuntimeRequestError) return null; throw error;} } -function readScopes(value: unknown): JsonObject[] { +export function readScopes(value: unknown): JsonObject[] { const values = listValues(value), result: JsonObject[] = []; for (const raw of values) { const scope = readScope(raw); @@ -104,8 +104,8 @@ function readCapabilities(value: unknown): string[] { } return result; } -function writeScopes(value: unknown): string[] { - const scopes = listValues(value).map(raw => compactPythonWhitespace(scalarText(raw))).filter(scope => scope && +export function writeScopes(value: unknown): string[] { + const scopes = listValues(value).map((raw, i) => compactPythonWhitespace(Array.isArray(value) ? pythonFieldText(value, i) : scalarText(raw))).filter(scope => scope && Array.from(scope).length <= 160 && !scope.startsWith("/") && !scope.startsWith("~") && !scope.split("/").includes("..") && !/[ <>]/u.test(scope)); return [...new Set(scopes)]; } @@ -114,8 +114,8 @@ const truthy = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : v !== function repr(value: unknown): string { if (value == null) return "None"; if (typeof value === "boolean") return value ? "True" : "False"; - if (Array.isArray(value)) return "[" + value.map(repr).join(", ") + "]"; - if (typeof value === "object") return "{" + Object.entries(value).map(([k, v]) => `${repr(k)}: ${repr(v)}`).join(", ") + "}"; + if (Array.isArray(value)) return "[" + value.map((_, i) => fieldRepr(value, i)).join(", ") + "]"; + if (typeof value === "object") return "{" + Object.entries(value).map(([k, v]) => `${repr(k)}: ${fieldRepr(value as JsonObject, k)}`).join(", ") + "}"; if (typeof value !== "string") return String(value); const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; return quote + Array.from(value).map(point => { @@ -133,22 +133,50 @@ function repr(value: unknown): string { } const scalarText = (value: unknown): string => !truthy(value) ? "" : typeof value === "string" ? value : repr(value); +// Request-local Python scalar facts. These are codecs, never persisted metadata. +const pythonValues = new WeakMap>(); +export function decodeTodoReadSource(value: JsonObject): JsonObject { + const record = object(value.record); + for (const fact of (value.python_values ?? []) as unknown[][]) { + const path = fact[0] as (string | number)[]; + let parent: JsonObject = record; + for (const key of path.slice(0, -1)) parent = parent[key] as JsonObject; + const fields = pythonValues.get(parent) ?? new Map(); + fields.set(String(path.at(-1)), fact.slice(1)); pythonValues.set(parent, fields); + } + return record; +} +export function pythonFieldText(row: JsonObject | unknown[], key: string | number): string { + return pythonValues.get(row)?.get(String(key))?.[0] as string ?? scalarText((row as JsonObject)[key]); +} +function fieldRepr(row: JsonObject | unknown[], key: string | number): string { + return pythonValues.get(row)?.get(String(key))?.[1] as string ?? repr((row as JsonObject)[key]); +} +export function copyTodoPythonValues(source: JsonObject, target: JsonObject): void { + const fields = pythonValues.get(source); + if (fields) pythonValues.set(target, new Map([...fields].filter(([key]) => target[key] === source[key]))); +} +export function handoffReadSource(value: JsonObject): JsonObject { + if (value.record === undefined) return value; // retained already-encoded transport callers + return {...handoffSource(decodeTodoReadSource(value)), goal_id: value.goal_id ?? "", source: value.source ?? ""}; +} + export function handoffSource(item: JsonObject): JsonObject { const refs = (value: unknown): JsonObject => ({...(truthy(value) ? {truthy: true} : {}), - ...(Array.isArray(value) && value.length ? {values: value.map(scalarText)} : {})}); + ...(Array.isArray(value) && value.length ? {values: value.map((_, i) => pythonFieldText(value, i))} : {})}); const nestedMetadata: JsonObject = {}, nested: JsonObject = {}; for (const key of ["handoff", "handoff_note"]) { const value = item[key], row = object(value); if (truthy(value)) nested[key] = {truthy: true, fields: value !== null && typeof value === "object" && !Array.isArray(value) - ? Object.fromEntries(Object.entries(row).map(([k, v]) => [k, scalarText(v)])) : null}; + ? Object.fromEntries(Object.entries(row).map(([k, v]) => [k, pythonFieldText(row, k)])) : null}; nestedMetadata[key] = {from_agent: readAgent(truthy(row.from_agent) ? row.from_agent : item.agent_id), to_agent: readAgent(row.to_agent), evidence_refs: refs(row.evidence_refs)}; } const fields = ["continuation_hint", "suggested_next_action", "note", "reason", "summary", "intent", "blocked_on", "evidence", "title", "text", "task_class", "goal_id", "source", "latest_event_kind"]; const action = compactPythonWhitespace(scalarText(item.action_kind)).toLowerCase(); - const legacy = "(" + [item.goal_id, item.role, item.index, truthy(item.text) ? item.text : item.title].map(repr).join(", ") + ")"; + const legacy = "(" + ["goal_id", "role", "index", truthy(item.text) ? "text" : "title"].map(key => fieldRepr(item, key)).join(", ") + ")"; return {...nested, nested_metadata: nestedMetadata, - texts: Object.fromEntries(fields.filter(key => truthy(item[key])).map(key => [key, scalarText(item[key])])), + texts: Object.fromEntries(fields.filter(key => truthy(item[key])).map(key => [key, pythonFieldText(item, key)])), metadata: {todo_id: id(item.todo_id), claimed_by: readAgent(item.claimed_by), excluded_agents: readExcludedAgents(item.excluded_agents), successor_todo_ids: ids(item.successor_todo_ids), unblocks_todo_id: id(item.unblocks_todo_id), superseded_by: id(item.superseded_by), action_kind: /^[a-z][a-z0-9_-]{0,63}$/.test(action) ? action : null, resume_when: readResume(item.resume_when), diff --git a/loopx/control_plane/todos/summary_item.py b/loopx/control_plane/todos/summary_item.py index 1842de9e4b..a730f5de6a 100644 --- a/loopx/control_plane/todos/summary_item.py +++ b/loopx/control_plane/todos/summary_item.py @@ -1,90 +1,11 @@ +"""Compatibility transport for the single TypeScript Todo display owner.""" from __future__ import annotations +import json from hashlib import sha256 from typing import Any -from .contract import ( - normalize_removed_todo_continuation_policy, - normalize_required_write_scopes, - normalize_todo_decision_outcome, - normalize_todo_decision_scope, - normalize_todo_decision_scope_outcomes, - normalize_todo_excluded_agents, - normalize_todo_id, - normalize_todo_required_decision_scopes, - normalize_todo_replan_obligation_id, - normalize_todo_resume_when, - normalize_todo_task_domain, - normalize_todo_task_repository, -) -from .handoff_gate import handoff_ready_successor_todo_ids -from .handoff_note import handoff_context_source, project_handoff_context -from .todo_semantics import todo_blocker_reason, todo_item_task_class -from .frontier_revision import FRONTIER_REVISION_FIELDS - -TODO_SUMMARY_COMPACT_FIELDS = ( - "goal_acceptance_guard", - "schema_version", - "todo_id", - "content_revision", - "role", - "status", - "priority", - "title", - "archive_state", - "source_section", - "task_class", - "action_kind", - "task_domain", - "capability_binding_ref", - "task_repository", - "continuation_policy", - "removed_continuation_policy", - "required_write_scopes", - "required_capabilities", - "target_capabilities", - "decision_scope", - "required_decision_scopes", - "decision_outcome", - "decision_scope_outcomes", - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "excluded_agents", - "global_gate", - "unblocks_todo_id", - "resume_when", - "resume_monitor_generation", - "resume_condition", - "resume_ready", - "blocked_retry", - "no_followup", - "successor_todo_ids", - "completion_continuation", - "completion_recovery", - "replan_obligation_id", - "target_key", - "cadence", - "next_due_at", - "expires_at", - "watch_only", - "last_checked_at", - "result_hash", - "consecutive_no_change", - "material_change", - "material_change_generation", - "max_no_change_before_replan", - "route_continuation_replan_required", - "route_continuation_reason", - "route_id", - "route_key", - "completed_at", - "updated_at", - "completion_turn_key", - "superseded_by", - "handoff_note", -) +from .handoff_note import todo_read_source TODO_SUMMARY_SOURCE_KEYS = ( "active_next_action_items", @@ -124,105 +45,41 @@ def todo_text_content_revision(value: Any) -> str | None: return "sha256:" + sha256(normalized.encode("utf-8")).hexdigest() -def _compact_todo_summary_item_record( - item: dict[str, Any], - *, - text: str | None = None, -) -> dict[str, Any]: - compact: dict[str, Any] = { - "index": item.get("index"), - "text": text if text is not None else item.get("text"), - } - content_revision = item.get("content_revision") - if content_revision: - compact["content_revision"] = content_revision - for key in TODO_SUMMARY_COMPACT_FIELDS: - if item.get(key) is not None: - compact[key] = item.get(key) - required_write_scopes = normalize_required_write_scopes( - compact.get("required_write_scopes") - ) - task_domain = normalize_todo_task_domain(compact.get("task_domain")) - if task_domain: - compact["task_domain"] = task_domain - else: - compact.pop("task_domain", None) - if required_write_scopes: - compact["required_write_scopes"] = required_write_scopes - else: - compact.pop("required_write_scopes", None) - task_repository = normalize_todo_task_repository(compact.get("task_repository")) - if task_repository: - compact["task_repository"] = task_repository - else: - compact.pop("task_repository", None) - decision_scope = normalize_todo_decision_scope(compact.get("decision_scope")) - if decision_scope: - compact["decision_scope"] = decision_scope - else: - compact.pop("decision_scope", None) - required_decision_scopes = normalize_todo_required_decision_scopes( - compact.get("required_decision_scopes") - ) - if required_decision_scopes: - compact["required_decision_scopes"] = required_decision_scopes - else: - compact.pop("required_decision_scopes", None) - decision_outcome = normalize_todo_decision_outcome(compact.get("decision_outcome")) - if decision_outcome: - compact["decision_outcome"] = decision_outcome - else: - compact.pop("decision_outcome", None) - decision_scope_outcomes = normalize_todo_decision_scope_outcomes( - compact.get("decision_scope_outcomes") - ) - if decision_scope_outcomes: - compact["decision_scope_outcomes"] = decision_scope_outcomes - else: - compact.pop("decision_scope_outcomes", None) - excluded_agents = normalize_todo_excluded_agents(compact.get("excluded_agents")) - if excluded_agents: - compact["excluded_agents"] = excluded_agents - else: - compact.pop("excluded_agents", None) - removed_continuation_policy = normalize_removed_todo_continuation_policy( - compact.get("removed_continuation_policy") - ) - if removed_continuation_policy: - compact["removed_continuation_policy"] = removed_continuation_policy - else: - compact.pop("removed_continuation_policy", None) - replan_obligation_id = normalize_todo_replan_obligation_id( - compact.get("replan_obligation_id") - ) - if replan_obligation_id: - compact["replan_obligation_id"] = replan_obligation_id - else: - compact.pop("replan_obligation_id", None) - compact["task_class"] = todo_item_task_class(compact) - reason = todo_blocker_reason(item) - if reason: - compact["reason"] = reason - return compact +def _project(request: dict[str, Any]) -> list[dict[str, Any]]: + from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + + try: + result = effect_runtime_result("todo.context.page", {"summary_display": request}, + large_local_snapshot=True) + except EffectRuntimeRejected as error: + raise ValueError(str(error)) from error + rows = result.get("items") if isinstance(result, dict) else None + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError("invalid typed Todo summary display") + return rows def compact_todo_summary_items( items: list[dict[str, Any]], *, texts: list[str | None] | None = None, strip_text: bool = False, ) -> list[dict[str, Any]]: - if texts is None: - texts = [str(item.get("text") or "").strip() for item in items] if strip_text else [None] * len(items) - records = [_compact_todo_summary_item_record(item, text=text) - for item, text in zip(items, texts, strict=True)] - contexts = project_handoff_context([ - handoff_context_source(item) for pair in zip(items, records, strict=True) for item in pair - ]) - for index, record in enumerate(records): - hint, note = contexts[2 * index]["continuation_hint"], contexts[2 * index + 1]["note"] - if record["task_class"] == "advancement_task" and record.get("status") == "open" and hint: - record["continuation_hint"] = hint - if note: - record["handoff_note"] = note - return records + from ..effect_runtime import MAX_REQUEST_BYTES + + if texts is not None and len(texts) != len(items): + raise ValueError("summary text cardinality mismatch") + sources = [todo_read_source(item, **({"text": texts[i]} if texts is not None else {})) + for i, item in enumerate(items)] + result: list[dict[str, Any]] = [] + start, size = 0, 0 + for i in range(len(sources) + 1): + row_size = 0 if i == len(sources) else len(json.dumps(sources[i], separators=(",", ":")).encode()) + 2 + if i > start and (i == len(sources) or size + row_size > MAX_REQUEST_BYTES - 4096): + rows = _project({"items": sources[start:i], "strip_text": strip_text}) + if len(rows) != i - start: + raise ValueError("summary display cardinality mismatch") + result.extend(rows) + start, size = i, 0 + size += row_size + return result def compact_todo_summary_item(item: dict[str, Any], *, text: str | None = None) -> dict[str, Any]: @@ -230,100 +87,14 @@ def compact_todo_summary_item(item: dict[str, Any], *, text: str | None = None) def todo_summary_source_items(value: dict[str, Any]) -> list[dict[str, Any]]: - ready_successor_todo_ids = handoff_ready_successor_todo_ids(value) - open_items: list[dict[str, Any]] = [] - originals: list[dict[str, Any]] = [] - for key in TODO_SUMMARY_SOURCE_KEYS: - raw_items = value.get(key) - source_items = raw_items if isinstance(raw_items, list) else [] - for item in source_items: - if not isinstance(item, dict) or item.get("done") is True: - continue - text = str(item.get("text") or "").strip() - if not text: - continue - duplicate = any( - existing.get("todo_id") == item.get("todo_id") - if item.get("todo_id") and existing.get("todo_id") - else existing.get("index") == item.get("index") - and str(existing.get("text") or "").strip() == text - for existing in open_items - ) - if duplicate: - continue - compact = _compact_todo_summary_item_record(item, text=text) - todo_id = normalize_todo_id(compact.get("todo_id")) - if ( - todo_id - and todo_id in ready_successor_todo_ids - and normalize_todo_resume_when(compact.get("resume_when")) - and "resume_ready" not in compact - ): - compact["resume_ready"] = True - compact["resume_condition"] = { - "schema_version": "todo_resume_condition_v0", - "resume_when": compact.get("resume_when"), - "satisfied": True, - "source": "handoff_gate_cleared_with_successor", - } - open_items.append(compact) - originals.append(item) - projected = compact_todo_summary_items(originals, texts=[row["text"] for row in open_items]) - for compact, prepared in zip(projected, open_items, strict=True): - for key in ("resume_ready", "resume_condition"): - if key in prepared: - compact[key] = prepared[key] - return projected - + from .handoff_gate import todo_summary_handoff_gates -def todo_planning_source_items( - value: dict[str, Any], - *, - include_terminal: bool = False, -) -> list[dict[str, Any]]: - """Return canonical Todo state rows shared by planning read models. + if not any(value.get(key) for key in TODO_SUMMARY_SOURCE_KEYS): + return [] + return _project({"source": todo_read_source(value), "handoff_gates": todo_summary_handoff_gates(value)}) - Unlike presentation-lane expansion, these four sources correspond to - distinct Todo domain states. They preserve waiting/blocking/scheduled nodes - that may sit outside the status ``items`` budget without making every - consumer rediscover visibility-lane policy. - """ - planning_items: list[dict[str, Any]] = [] - originals: list[dict[str, Any]] = [] - seen: set[str] = set() - for key in TODO_PLANNING_SOURCE_KEYS: - raw_items = value.get(key) - source_items = raw_items if isinstance(raw_items, list) else [] - for item in source_items: - if not isinstance(item, dict) or ( - not include_terminal and item.get("status") == "done" - ): - continue - todo_id = normalize_todo_id(item.get("todo_id")) - if include_terminal and todo_id is None: - legacy_todo_id = str(item.get("todo_id") or "").strip() - todo_id = ( - legacy_todo_id - if legacy_todo_id.startswith("todo_") - else None - ) - text = str(item.get("text") or "").strip() - if not todo_id or not text or todo_id in seen: - continue - seen.add(todo_id) - compact = _compact_todo_summary_item_record(item, text=text) - if include_terminal: - # Terminal-inclusive planning also proves exact frontier causality. - for field in (*FRONTIER_REVISION_FIELDS, "evidence", "note", "last_actor_agent_id"): - if item.get(field) is not None: - compact[field] = item[field] - planning_items.append(compact) - originals.append(item) - projected = compact_todo_summary_items(originals, texts=[row["text"] for row in planning_items]) - if include_terminal: - for compact, prepared in zip(projected, planning_items, strict=True): - for field in (*FRONTIER_REVISION_FIELDS, "evidence", "note", "last_actor_agent_id"): - if field in prepared: - compact[field] = prepared[field] - return projected +def todo_planning_source_items(value: dict[str, Any], *, include_terminal: bool = False) -> list[dict[str, Any]]: + if not any(value.get(key) for key in TODO_PLANNING_SOURCE_KEYS): + return [] + return _project({"source": todo_read_source(value), "include_terminal": include_terminal}) diff --git a/loopx/control_plane/todos/todo_index.py b/loopx/control_plane/todos/todo_index.py index 37a1af10b8..5e224c8c23 100644 --- a/loopx/control_plane/todos/todo_index.py +++ b/loopx/control_plane/todos/todo_index.py @@ -6,7 +6,7 @@ from ...rollout_event_log import load_rollout_events, rollout_event_log_path from .contract import normalize_todo_id, normalize_todo_status, todo_done_for_status -from .handoff_note import handoff_context_source, project_handoff_context +from .handoff_note import todo_read_source, project_handoff_context from .todo_summary import _compact_todo_item_record TODO_INDEX_SCHEMA_VERSION = "todo_index_v0" @@ -179,8 +179,8 @@ def build_todo_index( source="attention_queue", public_safe_compact_text=public_safe_compact_text) if row is not None: status_rows.append(row) - context_sources.append(handoff_context_source(compact)) - followups.append(handoff_context_source(row)) + context_sources.append(todo_read_source(compact)) + followups.append(todo_read_source(row)) current_count = len(status_rows) rollout_event_count = 0 @@ -216,7 +216,7 @@ def build_todo_index( continue event_rows.append(event_item) contexts = project_handoff_context([ - *context_sources, *(handoff_context_source(row) for row in event_rows)], + *context_sources, *(todo_read_source(row) for row in event_rows)], followups=[*followups, *([None] * len(event_rows))]) for row, context in zip([*status_rows, *event_rows], contexts, strict=True): if context["note"]: diff --git a/loopx/control_plane/todos/user_completion.ts b/loopx/control_plane/todos/user_completion.ts index 95743d9dce..96114cba53 100644 --- a/loopx/control_plane/todos/user_completion.ts +++ b/loopx/control_plane/todos/user_completion.ts @@ -8,6 +8,7 @@ import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtim import {decisionScopeCovers} from "./decision_scope.ts"; import {normalizeTodoDecisionScope, normalizeTodoRequiredDecisionScopes} from "./decision_metadata.ts"; import type {DecisionOutcome, ResumeState} from "./user_completion_types.ts"; +import {TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION} from "./user_completion_types.ts"; interface ResumeReceipt extends JsonObject { readonly state: ResumeState; } @@ -79,7 +80,7 @@ export function planUserCompletion( const updates: JsonObject = {}; if (gate && outcome !== "approve") { if (!scope) return {...empty, unblock_resume: {...base, state: "target_or_decision_scope_not_found"}}; - const recorded = {schema_version: "todo_decision_scope_outcome_v0", outcome, + const recorded = {schema_version: TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION, outcome, decision_scope: scope, source_todo_id: source.todo_id}; // Match the legacy codec: the newest decision replaces the same exact // scope in place, while independent scopes retain their order and evidence. diff --git a/loopx/control_plane/todos/user_completion_types.ts b/loopx/control_plane/todos/user_completion_types.ts index 0971c2b730..70c309e8c4 100644 --- a/loopx/control_plane/todos/user_completion_types.ts +++ b/loopx/control_plane/todos/user_completion_types.ts @@ -1,4 +1,5 @@ /** Shared vocabulary for canonical completion and its effect-free presentation. */ +export const TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION = "todo_decision_scope_outcome_v0"; export type DecisionOutcome = "approve" | "reject" | "cancel"; export type ResumeState = "target_not_found" | "target_or_decision_scope_not_found" | "target_not_active" | "target_not_blocked" | "explicit_blocker_repair_required" | "other_user_blockers_active" | diff --git a/tests/control_plane/test_agent_task_visibility.py b/tests/control_plane/test_agent_task_visibility.py index 20ebfe5c79..c8d4336a32 100644 --- a/tests/control_plane/test_agent_task_visibility.py +++ b/tests/control_plane/test_agent_task_visibility.py @@ -8,7 +8,6 @@ quota_todo_item, quota_todo_summary, ) -from loopx.control_plane.todos.summary_item import compact_todo_summary_item from loopx.quota import build_quota_should_run GOAL_ID = "agent-task-visibility-fixture" @@ -87,38 +86,6 @@ def test_quota_projects_goal_scoped_read_and_execution_boundary() -> None: ) -def test_todo_continuation_hint_drops_secret_bearing_source() -> None: - credential_values = tuple( - f"{name}=must-not-project; next run the canary." - for name in ("api" + "_key", "tok" + "en", "pass" + "word") - ) + ( - ("Author" + "ization") - + ": " - + ("Bear" + "er") - + " must-not-project; next run the canary.", - ) - for field in ("note", "continuation_hint"): - for index, value in enumerate(credential_values): - item = quota_todo_item( - todo_id=f"todo_secret_{field}_{index}", - title="Resume the current task.", - **{field: value}, - ) - - assert "continuation_hint" not in compact_todo_summary_item(item) - - -def test_todo_continuation_hint_only_projects_open_advancement_work() -> None: - item = quota_todo_item( - todo_id="todo_monitor", - title="Poll the current target.", - task_class="continuous_monitor", - continuation_hint="Next inspect the material transition.", - ) - - assert "continuation_hint" not in compact_todo_summary_item(item) - - def test_turn_envelope_retains_agent_task_visibility_contract() -> None: decision = _decision() diff --git a/tests/control_plane/test_blocked_transition_notice.py b/tests/control_plane/test_blocked_transition_notice.py index 4aacc608ea..2f512f0ddb 100644 --- a/tests/control_plane/test_blocked_transition_notice.py +++ b/tests/control_plane/test_blocked_transition_notice.py @@ -202,21 +202,6 @@ def test_compact_blocked_advancement_keeps_cause_for_fallback_notice() -> None: assert "private path" not in str(fallback) -def test_compact_open_advancement_does_not_project_unneeded_reason() -> None: - open_item = {**_fallback(), "reason": "not an active blocker"} - - assert "reason" not in compact_todo_summary_item(open_item) - - -def test_compact_blocked_advancement_omits_unsafe_reason() -> None: - blocked = { - **_agent_owned_blocker(), - "reason": "/" + "Users/example/private/plan.md", - } - - assert "reason" not in compact_todo_summary_item(blocked) - - def test_project_asset_blocked_advancement_cause_is_public_safe() -> None: blocked = _agent_owned_blocker() summary = {"items": [blocked], "first_open_items": [blocked], "open_count": 1} diff --git a/tests/control_plane/test_handoff_context_projection.py b/tests/control_plane/test_handoff_context_projection.py index eab7c2892c..55223657ee 100644 --- a/tests/control_plane/test_handoff_context_projection.py +++ b/tests/control_plane/test_handoff_context_projection.py @@ -8,94 +8,10 @@ import pytest from loopx.control_plane.todos.handoff_note import ( - attach_todo_handoff_note, build_todo_handoff_note, compact_todo_continuation_hint, + build_todo_handoff_note, ) -def test_handoff_keeps_source_identity_and_does_not_invent_completion(): - source = { - "todo_id": "todo_original", "role": "agent", "status": "open", - "claimed_by": "Agent A", "text": "Read the complete requirement", - "note": "Keep the unfinished validation", "evidence": "opaque evidence", - "successor_todo_ids": ["todo_successor"], - "resume_when": "todo_done:todo_dependency", - "excluded_agents": ["agent-b"], - } - before = deepcopy(source) - note = build_todo_handoff_note(source, goal_id="goal-a", source="canonical_provider") - assert source == before - assert note == { - "schema_version": "handoff_note_v0", "handoff_id": "handoff_original", - "todo_id": "todo_original", "goal_id": "goal-a", "to_agent": "agent-a", - "intent": "continue", "summary": "Keep the unfinished validation", - "evidence_refs": ["todo:todo_original:evidence", "todo:todo_original:note"], - "blocked_on": "todo_done:todo_dependency", - "suggested_next_action": "Read the complete requirement", - "source": "canonical_provider", "successor_todo_ids": ["todo_successor"], - "excluded_agents": ["agent-b"], - } - assert attach_todo_handoff_note(source) is source - assert {key: source[key] for key in before} == before - assert "status" not in source["handoff_note"] - assert "resume_ready" not in source["handoff_note"] - - -def test_plain_work_has_no_handoff_and_nested_fields_remain_authoritative(): - assert build_todo_handoff_note({"todo_id": "todo_plain", "note": "continue"}) is None - item = {"todo_id": "todo_original", "note": "top-level fallback", "claimed_by": "agent-a", - "handoff": {"to_agent": "agent-b", "note": "", "reason": "nested reason"}} - note = build_todo_handoff_note(item) - assert note["to_agent"] == "agent-b" - assert note["summary"] == "nested reason" - assert compact_todo_continuation_hint(item) == "nested reason" - - -def test_legacy_handoff_identity_does_not_depend_on_continuation_edits(): - item = {"goal_id": "goal-a", "role": "agent", "index": 7, "text": "Legacy work", - "handoff": {"note": "First continuation"}} - first = build_todo_handoff_note(item, goal_id="goal-a") - item["handoff"]["note"] = "Revised continuation" - revised = build_todo_handoff_note(item, goal_id="goal-a") - # Golden compatibility identity for the historical tuple encoding; do not - # derive the expected ID from the function being characterized. - assert first["handoff_id"] == revised["handoff_id"] == "handoff_7e32bf123341" - assert first["summary"] != revised["summary"] - - -@pytest.mark.parametrize("label", ["ak", "sk", "api_key", "access_key_id", "secret_key", "token", "password"]) -def test_credential_assignment_is_rejected_before_display_truncation(label): - # Values are synthetic and deliberately short. Even when the assignment - # follows the display cap, the entire candidate must be rejected. - item = {"handoff": {"note": "x" * 400 + " " + label + "=" + "v"}} - assert compact_todo_continuation_hint(item) is None - assert "summary" not in build_todo_handoff_note(item) - - -def test_unicode_compaction_and_bounded_evidence_keep_existing_semantics(): - assert compact_todo_continuation_hint({"note": "a\u0085b\u001cc"}) == "a b c" - assert compact_todo_continuation_hint({"note": "token budget for validation"}) == "token budget for validation" - assert compact_todo_continuation_hint({"note": "😀" * 281}) == "😀" * 279 + "..." - item = {"todo_id": "todo_original", "successor_todo_ids": ["todo_next"], - "handoff": {"evidence_refs": ["ref:one", "ref:one", "ref:two", "ref:three", "ref:four"]}, - "evidence": "raw evidence is not copied", "note": "continuation", - "latest_event_kind": "todo_update"} - assert build_todo_handoff_note(item)["evidence_refs"] == [ - "ref:one", "ref:two", "ref:three", "ref:four", "todo:todo_original:evidence", "todo:todo_original:note"] - - -# Synthetic values exercise Unicode filtering without resembling credentials. -_DOTTED_I_ASSIGNMENT = "apİ_key" + "=" + "v" -_UNICODE_TOKEN_ASSIGNMENT = "令token" + "=" + "v" -@pytest.mark.parametrize("text, expected", [ - (_UNICODE_TOKEN_ASSIGNMENT, _UNICODE_TOKEN_ASSIGNMENT), # Python Unicode word boundary: no boundary before token. - (_DOTTED_I_ASSIGNMENT, None), - ("bearer\ufeffv", "bearer\ufeffv"), # BOM is not Python whitespace. - ("bearer\u0085v", None), -]) -def test_credential_filter_keeps_unicode_boundary_and_whitespace_contract(text, expected): - assert compact_todo_continuation_hint({"note": text}) == expected - - def test_summary_and_index_use_batches_without_mutating_the_source(monkeypatch, tmp_path): from loopx.control_plane import effect_runtime from loopx.control_plane.todos.todo_summary import compact_todo_group @@ -252,7 +168,7 @@ def track(method, request, **kwargs): @pytest.mark.parametrize("credential", [False, True]) def test_oversized_single_source_reuses_private_snapshot_without_pretruncation(monkeypatch, credential): from loopx.control_plane import effect_runtime - from loopx.control_plane.todos.handoff_note import handoff_context_source, project_handoff_context + from loopx.control_plane.todos.handoff_note import todo_read_source, project_handoff_context calls, original = [], effect_runtime.effect_runtime_result def track(method, request, **kwargs): @@ -265,8 +181,35 @@ def track(method, request, **kwargs): text += " " + "token" + "=" + "v" rows = [{"todo_id": f"todo_snapshot_{i}", "text": "Work", "successor_todo_ids": ["todo_next"], "note": text if i == 1 else "Continue"} for i in range(3)] - contexts = project_handoff_context([handoff_context_source(row) for row in rows]) + contexts = project_handoff_context([todo_read_source(row) for row in rows]) assert [row["note"]["todo_id"] for row in contexts] == [row["todo_id"] for row in rows] assert contexts[1]["continuation_hint"] == (None if credential else "a" * 279 + "...") assert contexts[1]["note"]["summary"] == ("Work" if credential else "a" * 279 + "...") assert calls == [("todo.context.page", False), ("todo.context.page", True), ("todo.context.page", False)] + + +def test_python_sequence_and_scalar_facts_cross_the_retained_facade(): + from loopx.control_plane.todos.summary_item import compact_todo_summary_items + + row = {"todo_id": "todo_compat", "text": ("Check", True), "status": "open", + "note": 1.0, "required_write_scopes": (True, 1.0, "docs/**", "../bad"), + "excluded_agents": {"Agent B"}, "successor_todo_ids": ("todo_next",), + "decision_scope": {"kind": "other", "granularity": "action", "scope_key": 1.0}} + result = compact_todo_summary_items([row], strip_text=True)[0] + assert result["text"] == "('Check', True)" + assert result["continuation_hint"] == "1.0" + assert result["required_write_scopes"] == ["True", "1.0", "docs/**"] + assert result["excluded_agents"] == ["agent-b"] + assert result["decision_scope"]["scope_key"] == "1.0" + assert result["handoff_note"]["successor_todo_ids"] == ["todo_next"] + assert row["excluded_agents"] == {"Agent B"} + assert row["text"] == ("Check", True) + + +def test_explicit_null_text_override_preserves_raw_text_even_with_strip_requested(): + from loopx.control_plane.todos.summary_item import compact_todo_summary_items + + row = {"text": " Work ", "status": "open"} + assert compact_todo_summary_items([row], texts=[None], strip_text=True)[0]["text"] == " Work " + with pytest.raises(ValueError, match="cardinality"): + compact_todo_summary_items([row], texts=[]) diff --git a/tests/control_plane/test_receipt_next_action_cli.py b/tests/control_plane/test_receipt_next_action_cli.py index e513fe2b5b..e8f4f7215e 100644 --- a/tests/control_plane/test_receipt_next_action_cli.py +++ b/tests/control_plane/test_receipt_next_action_cli.py @@ -16,6 +16,42 @@ from loopx.cli import main as cli_main from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.quota.settlement import read_heartbeat_settlement +from loopx.control_plane.todos.contract import TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_quota_next_action_preserves_canonical_decision_receipt_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = fixture._write_fixture(tmp_path) + state = fixture._configure_read_only_todo(project) + rows = read_fixture_todos(state) + target = next(row for row in rows if row["todo_id"] == fixture.TODO_ID) + target["decision_scope_outcomes"] = [{ + "schema_version": TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION, + "outcome": "reject", + "decision_scope": {"schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "action", "scope_key": "previous_route"}, + "source_todo_id": "todo_previous_gate", + }] + initialize_canonical_authority(runtime, fixture.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=fixture.GOAL_ID, todos=rows, + handoff_mode="soft_claim", leases=[]), state_path=state, provider=provider) + code, source = fixture._run_cli(registry, runtime, "todo", "list", + "--goal-id", fixture.GOAL_ID, cwd=project) + assert code == 0, source + canonical = next(row for row in source["todos"] if row["todo_id"] == fixture.TODO_ID) + version = canonical["decision_scope_outcomes"][0]["schema_version"] + assert version == TODO_DECISION_SCOPE_OUTCOME_SCHEMA_VERSION + code, guard = fixture._run_cli(registry, runtime, "quota", "should-run", + "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, "--codex-app", + cwd=project) + assert code == 0, guard + action = guard["agent_lane_next_action"] + assert action["todo_id"] == canonical["todo_id"] + assert action["decision_scope_outcomes"][0]["schema_version"] == version + @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/control_plane/test_todo_summary_projection.py b/tests/control_plane/test_todo_summary_projection.py index d1bbe59500..c53c2b0bb2 100644 --- a/tests/control_plane/test_todo_summary_projection.py +++ b/tests/control_plane/test_todo_summary_projection.py @@ -31,29 +31,6 @@ def test_user_request_creator_survives_summary_and_compact_workspace_projection( assert "created_by" not in project_asset_todo_summary(legacy, role="user")["items"][0] -def test_recent_completions_compare_instants_not_offset_strings(): - items = [row(1, status="done", no_followup=True, completed_at="2026-01-01T10:00:00+08:00"), - row(2, status="done", no_followup=True, completed_at="2026-01-01T03:00:00Z")] - result = summarize(items) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == [ - "todo_summary_2", "todo_summary_1"] - - -def test_recent_completions_preserve_microseconds_across_offsets(): - items = [row(2, status="done", no_followup=True, completed_at="2026-01-01T10:00:00.000001+08:00"), - row(1, status="done", no_followup=True, completed_at="2026-01-01T02:00:00.000002Z")] - result = summarize(items) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == [ - "todo_summary_1", "todo_summary_2"] - - -def test_invalid_completion_time_does_not_displace_known_recent_work(): - result = summarize([row(1, status="done", no_followup=True, completed_at="unknown"), - row(2, status="done", no_followup=True, completed_at="2026-01-01T03:00:00Z")]) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == ["todo_summary_2"] - assert result["advancement_done_count"] == 2 # Still retained as completed work. - - def test_summary_caps_do_not_change_work_counts_or_hide_a_peer(): items = [row(i, claimed_by="agent-a" if i < 24 else "agent-b") for i in range(32)] result = summarize(items, item_limit=1) @@ -63,15 +40,6 @@ def test_summary_caps_do_not_change_work_counts_or_hide_a_peer(): assert result["claimed_open_count"] == 32 -def test_late_edit_does_not_make_an_old_completion_recent(): - result = summarize([row(1, status="done", no_followup=True, - completed_at="2026-01-01T00:00:00Z", updated_at="2026-09-01T00:00:00Z"), - row(2, status="done", no_followup=True, - completed_at="2026-02-01T00:00:00Z")]) - assert [item["todo_id"] for item in result["recent_completed_advancement_items"]] == [ - "todo_summary_2", "todo_summary_1"] - - def test_a_selection_cannot_restore_a_lost_full_source_proof(): from loopx.control_plane.todos.todo_summary import compact_evaluated_todo_group source = summarize([row(1, status="done", no_followup=True)], item_limit=None) diff --git a/tests/control_plane_ts/fixtures/todo_summary_display.json b/tests/control_plane_ts/fixtures/todo_summary_display.json new file mode 100644 index 0000000000..802bb2f5df --- /dev/null +++ b/tests/control_plane_ts/fixtures/todo_summary_display.json @@ -0,0 +1,292 @@ +{ + "_comment": "Pre-cutover Python summary-display projections of synthetic quota CLI rows at c6462f902; characterization only.", + "scenarios": [ + { + "name": "quota-n1", + "items": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "index": 1, + "role": "agent", + "priority": "P1", + "status": "open", + "task_class": "advancement_task", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "priority": "P1", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "text": "[P1] Inspect delegated evidence", + "priority": "P1" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ], + "expected": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_466a1d03137cb098dbef1ee1", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ] + }, + { + "name": "quota-n3", + "items": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "index": 1, + "role": "agent", + "priority": "P1", + "status": "open", + "task_class": "advancement_task", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "priority": "P1", + "text": "[P1] Inspect delegated evidence" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "text": "[P1] Inspect delegated evidence", + "priority": "P1" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "todo_id": "todo_63b0e0ccdfa833376b996112", + "index": 1, + "text": "[P1] Inspect delegated evidence", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ], + "expected": [ + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "role": "agent", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": null, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + }, + { + "index": 1, + "text": "[P1] Inspect delegated evidence", + "todo_id": "todo_63b0e0ccdfa833376b996112", + "status": "open", + "priority": "P1", + "task_class": "advancement_task" + } + ] + } + ], + "source": { + "items": [ + { + "todo_id": "todo_first", + "index": 1, + "text": " First work ", + "status": "open", + "note": "Continue validation" + }, + { + "todo_id": "todo_done", + "text": "Done", + "status": "done", + "done": true + } + ], + "blocker_items": [ + { + "todo_id": "todo_blocked", + "text": "Blocked", + "status": "blocked", + "reason": "Input pending" + } + ], + "handoff_gates": [] + }, + "summary": [ + { + "index": null, + "text": "Blocked", + "todo_id": "todo_blocked", + "status": "blocked", + "task_class": "advancement_task", + "reason": "Input pending" + }, + { + "index": 1, + "text": "First work", + "todo_id": "todo_first", + "status": "open", + "task_class": "advancement_task", + "continuation_hint": "Continue validation" + } + ], + "planning": [ + { + "index": 1, + "text": "First work", + "todo_id": "todo_first", + "status": "open", + "task_class": "advancement_task", + "continuation_hint": "Continue validation" + }, + { + "index": null, + "text": "Blocked", + "todo_id": "todo_blocked", + "status": "blocked", + "task_class": "advancement_task", + "reason": "Input pending" + } + ], + "terminal": [ + { + "index": 1, + "text": " First work ", + "todo_id": "todo_first", + "status": "open", + "task_class": "advancement_task", + "continuation_hint": "Continue validation", + "note": "Continue validation" + }, + { + "index": null, + "text": "Done", + "todo_id": "todo_done", + "status": "done", + "task_class": "advancement_task", + "done": true + }, + { + "index": null, + "text": "Blocked", + "todo_id": "todo_blocked", + "status": "blocked", + "task_class": "advancement_task", + "reason": "Input pending" + } + ] +} diff --git a/tests/control_plane_ts/quota_selection.test.ts b/tests/control_plane_ts/quota_selection.test.ts index 5c0e939d9a..f071fd5e5d 100644 --- a/tests/control_plane_ts/quota_selection.test.ts +++ b/tests/control_plane_ts/quota_selection.test.ts @@ -500,3 +500,19 @@ test("receipt retries precede later full-source caches without losing hidden ear assert.equal(planningDeadline([], [], {frontier_deadline: cache}, [invalid]).blocked_retry_wait, undefined); } }); + +test("quota planning composes raw display before lane projection and keeps each decision independent", () => { + const payload = {todo_id: "todo_work", text: " Work ", status: "open", task_class: "advancement_task", note: "Run validation"}; + const item = row("todo_work", {payload, display_source: {record: payload}, required: [], targets: [], due_at: null, expires_at: null}); + const input = clockRequest([item]), before = structuredClone(input); + const first = projectTodoQuotaPlanning(input).lanes as JsonObject; + const projected = (first.active_next_action_items as JsonObject[])[0]!; + assert.equal(projected.text, "Work"); + assert.equal(projected.continuation_hint, "Run validation"); + assert.equal(projected.note, undefined); + assert.deepEqual(input, before); + const changed = {...payload, note: "Read revised evidence"}; + const second = projectTodoQuotaPlanning(clockRequest([{...item, display_source: {record: changed}}])).lanes as JsonObject; + assert.equal((second.active_next_action_items as JsonObject[])[0]!.continuation_hint, "Read revised evidence"); + assert.equal(projected.continuation_hint, "Run validation"); +}); diff --git a/tests/control_plane_ts/todo_context_projection.test.ts b/tests/control_plane_ts/todo_context_projection.test.ts index afaecb948c..85d7bf9d0d 100644 --- a/tests/control_plane_ts/todo_context_projection.test.ts +++ b/tests/control_plane_ts/todo_context_projection.test.ts @@ -167,3 +167,84 @@ test("handoff context is an exclusive read lens with aligned stages", () => { // No execution grant or source record is manufactured by this projection. assert.deepEqual(Object.keys(result), ["handoff_context"]); }); + +// Captured before the cutover; independent rule cases below constrain its meaning. +import displayFixture from "./fixtures/todo_summary_display.json" with {type: "json"}; +import {projectTodoSummaryDisplay} from "../../loopx/control_plane/todos/context_projection.ts"; +const display = (item: JsonObject) => projectTodoSummaryDisplay({items: [{record: item}]})[0]!; +const handoff = (item: JsonObject, extras: JsonObject = {}) => + (projectTodoContextPage({handoff_sources: [{record: item, ...extras}]}).handoff_context as JsonObject[])[0]!; + +test("summary display keeps the canonical scoped-decision receipt version verbatim", () => { + const receipt = {schema_version: "todo_decision_scope_outcome_v0", outcome: "reject", + decision_scope: {schema_version: "decision_scope_v0", kind: "direction", + granularity: "action", scope_key: "publish_release"}, source_todo_id: "todo_gate"}; + const compacted = display({todo_id: "todo_work", text: "Review the next action", + decision_scope_outcomes: [receipt]}); + const projected = (compacted.decision_scope_outcomes as JsonObject[])[0]!; + assert.equal(projected.schema_version, receipt.schema_version); + assert.notEqual(projected.schema_version, "decision_scope_outcome_v0"); + assert.deepEqual(projected, receipt); +}); + +test("summary display matches pre-cutover synthetic CLI characterization", () => { + for (const scenario of displayFixture.scenarios) { + assert.deepEqual(projectTodoSummaryDisplay({items: scenario.items.map(record => ({record})), strip_text: true}), scenario.expected); + } + for (const [options, expected] of [[{handoff_gates: []}, displayFixture.summary], + [{include_terminal: false}, displayFixture.planning], [{include_terminal: true}, displayFixture.terminal]] as const) { + assert.deepEqual(projectTodoSummaryDisplay({source: {record: displayFixture.source}, ...options}), expected); + } +}); + +test("handoff preserves identity and nested precedence without granting completion", () => { + const item = {todo_id: "todo_original", role: "agent", status: "open", claimed_by: "Agent A", + text: "Read the complete requirement", note: "Keep the unfinished validation", evidence: "opaque evidence", + successor_todo_ids: ["todo_successor"], resume_when: "todo_done:todo_dependency", excluded_agents: ["agent-b"]}; + const before = structuredClone(item); + assert.deepEqual(handoff(item, {goal_id: "goal-a", source: "canonical_provider"}).note, { + schema_version: "handoff_note_v0", handoff_id: "handoff_original", todo_id: "todo_original", goal_id: "goal-a", + to_agent: "agent-a", intent: "continue", summary: "Keep the unfinished validation", + evidence_refs: ["todo:todo_original:evidence", "todo:todo_original:note"], blocked_on: "todo_done:todo_dependency", + suggested_next_action: "Read the complete requirement", source: "canonical_provider", successor_todo_ids: ["todo_successor"], excluded_agents: ["agent-b"]}); + assert.deepEqual(item, before); + assert.equal(handoff({todo_id: "todo_plain", note: "continue"}).note, null); + const nested = handoff({...item, handoff: {to_agent: "agent-b", note: "", reason: "nested reason"}}); + assert.equal(nested.continuation_hint, "nested reason"); + assert.equal((nested.note as JsonObject).to_agent, "agent-b"); + const legacy = {goal_id: "goal-a", role: "agent", index: 7, text: "Legacy work"}; + for (const note of ["First continuation", "Revised continuation"]) { + assert.equal((handoff({...legacy, handoff: {note}}).note as JsonObject).handoff_id, "handoff_7e32bf123341"); + } +}); + +test("handoff excludes credentials before truncation and preserves Unicode boundaries", () => { + // Assemble synthetic assignments to keep source safe for the public-boundary scan. + const tokenLabel = "tok" + "en"; + for (const label of ["ak", "sk", "api_key", "access_key_id", "secret_key", tokenLabel, "password"]) { + const context = handoff({handoff: {note: "x".repeat(400) + " " + label + "=v"}}); + assert.equal(context.continuation_hint, null); + assert.equal((context.note as JsonObject).summary, undefined); + } + for (const [note, expected] of [["a\u0085b\u001cc", "a b c"], ["token budget for validation", "token budget for validation"], + ["😀".repeat(281), "😀".repeat(279) + "..."], ["令" + tokenLabel + "=v", "令" + tokenLabel + "=v"], ["apİ_key=v", null], + ["bearer\ufeffv", "bearer\ufeffv"], ["bearer\u0085v", null]]) assert.equal(handoff({note}).continuation_hint, expected); + const context = handoff({todo_id: "todo_original", successor_todo_ids: ["todo_next"], + handoff: {evidence_refs: ["ref:one", "ref:one", "ref:two", "ref:three", "ref:four"]}, evidence: "opaque", + note: "continuation", latest_event_kind: "todo_update"}); + assert.deepEqual((context.note as JsonObject).evidence_refs, + ["ref:one", "ref:two", "ref:three", "ref:four", "todo:todo_original:evidence", "todo:todo_original:note"]); +}); + +test("only open advancement projects hints; blocked cause is bounded and public safe", () => { + for (const task_class of ["advancement_task", "continuous_monitor"]) for (const status of ["open", "blocked", "done"]) { + const row = display({text: "Work", status, task_class, note: "Continue validation", reason: "Input pending"}); + assert.equal(row.continuation_hint, task_class === "advancement_task" && status === "open" ? "Continue validation" : undefined); + assert.equal(row.reason, status === "blocked" ? "Input pending" : undefined); + assert.equal(row.note, undefined); assert.equal(row.evidence, undefined); + } + assert.equal(display({status: "blocked", reason: "/" + "home/example/plan.md"}).reason, undefined); + for (const field of ["note", "continuation_hint"]) for (const label of ["api_key", "token", "password"]) { + assert.equal(display({text: "Work", status: "open", [field]: label + "=v"}).continuation_hint, undefined); + } +}); diff --git a/tests/control_plane_ts/todo_summary_lanes.test.ts b/tests/control_plane_ts/todo_summary_lanes.test.ts index 5e0d196a72..88a76ccd99 100644 --- a/tests/control_plane_ts/todo_summary_lanes.test.ts +++ b/tests/control_plane_ts/todo_summary_lanes.test.ts @@ -96,3 +96,39 @@ test("conflicting display fragments cannot certify a complete source", () => { {identity: "todo_same", task_class: "advancement_task", actionable: true}]}); assert.equal(result.complete, false); }); + +import {projectTodoSummaryDisplay} from "../../loopx/control_plane/todos/context_projection.ts"; + +test("display source lanes preserve first identity, legacy coordinates and explicit readiness", () => { + const record = {active_next_action_items: [{todo_id: "todo_first", text: " First ", status: "open", resume_when: "todo_done:todo_gate"}], + items: [{todo_id: "todo_first", text: "Duplicate identity"}, {index: 7, text: " Legacy "}, + {index: 7, text: "Legacy"}, {text: "Completed", done: true}, {text: " "}, + {todo_id: "todo_explicit", text: "Explicit wait", resume_when: "todo_done:todo_gate", resume_ready: false}], + blocker_items: [{todo_id: "todo_blocker", text: "Blocked", status: "blocked"}]}; + const before = structuredClone(record); + const rows = projectTodoSummaryDisplay({source: {record}, handoff_gates: [{gate_state: "cleared_with_successor", + successor_todo_ids: ["todo_first", "todo_explicit"]}]}); + assert.deepEqual(rows.map(row => row.text), ["First", "Blocked", "Legacy", "Explicit wait"]); + assert.equal(rows[0]!.resume_ready, true); + assert.deepEqual(rows[0]!.resume_condition, {schema_version: "todo_resume_condition_v0", resume_when: "todo_done:todo_gate", + satisfied: true, source: "handoff_gate_cleared_with_successor"}); + assert.equal(rows[3]!.resume_ready, false); + assert.deepEqual(record, before); +}); + +test("planning keeps distinct domain states and terminal frontier evidence, without presentation-lane expansion", () => { + const record = {items: [{todo_id: "todo_open", text: "Open", depends_on_todo_ids: ["todo_dependency"]}, + {todo_id: "todo_done", text: "Complete", status: "done", no_followup: true, note: "Evidence tail"}, + {todo_id: "todo_x", text: "Legacy short id", status: "done"}], + deferred_items: [{todo_id: "todo_wait", text: "Wait", status: "deferred"}], + blocker_items: [{todo_id: "todo_open", text: "Duplicate"}], + monitor_open_items: [{todo_id: "todo_monitor", text: "Monitor", task_class: "continuous_monitor"}], + first_open_items: [{todo_id: "todo_display", text: "Only a display lane"}]}; + const plain = projectTodoSummaryDisplay({source: {record}, include_terminal: false}); + assert.deepEqual(plain.map(row => row.todo_id), ["todo_open", "todo_wait", "todo_monitor"]); + assert.equal(plain[0]!.depends_on_todo_ids, undefined); + const terminal = projectTodoSummaryDisplay({source: {record}, include_terminal: true}); + assert.deepEqual(terminal.map(row => row.todo_id), ["todo_open", "todo_done", "todo_x", "todo_wait", "todo_monitor"]); + assert.deepEqual(terminal[0]!.depends_on_todo_ids, ["todo_dependency"]); + assert.equal(terminal[1]!.note, "Evidence tail"); +}); diff --git a/tests/control_plane_ts/todo_summary_projection.test.ts b/tests/control_plane_ts/todo_summary_projection.test.ts index ed1ce4dd6f..daeacaa471 100644 --- a/tests/control_plane_ts/todo_summary_projection.test.ts +++ b/tests/control_plane_ts/todo_summary_projection.test.ts @@ -229,3 +229,43 @@ test("large native and imported corpora preserve source coverage across every di } } }); + +import {projectTodoSummaryDisplay} from "../../loopx/control_plane/todos/context_projection.ts"; + +test("summary display normalizes declarations and the last scoped outcome wins in place", () => { + const scope = {kind: "direction", granularity: "action", scope_key: "publish"}; + const record = {text: "Work", status: "open", task_domain: " Engineering ", + task_repository: "https://example.com/team/project.git", required_write_scopes: ["docs/**", "../bad", "docs/**", "two words"], + required_decision_scopes: [scope, {...scope, decision_id: "todo_other"}], + decision_scope_outcomes: ["approve|direction:action:publish|todo_first", "reject|direction:action:publish|todo_second"], + replan_obligation_id: "invalid", removed_continuation_policy: " REVIEW_HANDOFF ", excluded_agents: ["Agent B", "agent-b"]}; + const before = structuredClone(record); + const projected = projectTodoSummaryDisplay({items: [{record}]})[0]!; + assert.equal(projected.task_domain, "engineering"); + assert.equal(projected.task_repository, "git:example.com/team/project"); + assert.deepEqual(projected.required_write_scopes, ["docs/**"]); + assert.deepEqual(projected.required_decision_scopes, [{schema_version: "decision_scope_v0", ...scope}]); + assert.deepEqual(projected.decision_scope_outcomes, [{schema_version: "todo_decision_scope_outcome_v0", outcome: "reject", + decision_scope: {schema_version: "decision_scope_v0", ...scope}, source_todo_id: "todo_second"}]); + assert.equal(projected.replan_obligation_id, undefined); + assert.equal(projected.removed_continuation_policy, "review_handoff"); + assert.deepEqual(projected.excluded_agents, ["agent-b"]); + assert.deepEqual(record, before); +}); + +test("recent completion compares offsets and never promotes an invalid completion date", () => { + const result = projectTodoSummary(request([ + done({completed_at: "2026-01-01T10:00:00+08:00", updated_at: "2026-09-01T00:00:00Z"}), + done({completed_at: "2026-01-01T03:00:00Z"}), done({completed_at: "unknown"}), + ])); + assert.deepEqual(result.lanes.recent_completed_advancement_items.indices, [1, 0]); + assert.equal(result.fields.advancement_done_count, 3); +}); + + +test("compatibility scopes use Python scalar spelling and sequence waits do not become tokens", () => { + const projected = projectTodoSummaryDisplay({items: [{record: {text: "Work", status: "open", decision_scope: { + kind: "other", granularity: "action", scope_key: true}, successor_todo_ids: ["todo_next"], resume_when: ["owner_review"]}}]})[0]!; + assert.deepEqual(projected.decision_scope, {schema_version: "decision_scope_v0", kind: "other", granularity: "action", scope_key: "true"}); + assert.equal((projected.handoff_note as JsonObject).blocked_on, undefined); +});