From 9c4ac1cf77706dc2352ced247fea8b3698fd48ce Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:42:27 +0800 Subject: [PATCH] fix(chat): retain native image reads in interrupted answer review Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/app-conversation-and-async-inbox-v0.md | 7 +++++++ .../app-conversation-and-async-inbox-v0.zh-CN.md | 5 +++++ loopx/capabilities/native_chat/codex_adapter.py | 9 +++++---- tests/test_chat_agent.py | 12 ++++++++++-- tests/test_chat_codex_history.py | 13 +++++++++---- tests/test_steward_interrupted_answer.py | 15 ++++++++++++++- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 42fcf4466d..f004d67c41 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -1825,6 +1825,13 @@ native inventory with no final answer or live items. Native dynamic-tool calls are effect items too; a stable terminal inventory permits only this read-only review, never business replay. Its digest is rechecked before review execution. +The provider recognizes the same passive native item types in this review and +completed-work readback, including `imageView`. Reading an image neither makes +the inventory unknown nor counts as a settled business effect. Image metadata +remains bound to its original Turn and the stable full-inventory digest; changed, +duplicate, foreign or unknown image item types still fail the existing checks. +The original terminal-effect, no-final-answer and no-live-work requirements remain. + The same source, owner, grant, stop checks and `review-1` queue reservation apply. Fresh configured Codex capacity must verify before admission and again before dispatch. The new restricted thread retains only authorized manager reads; writes, diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index ec2f2a6c9d..04c1c845bb 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -1192,6 +1192,11 @@ provider 当前正文等于已知前文或目标正文。正文改变、效果 和活动 item 的原生中断清单。原生 dynamic-tool 调用同样属于效果条目;稳定的终态 清单只允许这次只读复核,不授予业务重放权。实际复核前再次核验清单摘要。 +provider 在这次复核与已完成工作的读回中复用同一组被动原生条目类型,包含 +`imageView`。读取图片不会使清单变成未知,也不算业务效果已结束的证明。 +图片元数据仍绑定原 Turn 和完整稳定清单的摘要;变化、重复、外国 Turn 或未知图片 +条目类型继续被原检查拒绝。原有效果终态、没有最终答案和没有活动工作的要求保留。 + 复核沿用原来源、本人、grant、停止检查及同一 `review-1` 队列预留。受理及派发前 都须重新核验已配置的 Codex 额度。新受限 thread 只保留原授权管家读取,禁用写入、 网络效果、委派及业务续办;基于现有证据答复,或给出具体的部分答案。原失败 Turn diff --git a/loopx/capabilities/native_chat/codex_adapter.py b/loopx/capabilities/native_chat/codex_adapter.py index 5969f5978d..6bb08d0d6d 100644 --- a/loopx/capabilities/native_chat/codex_adapter.py +++ b/loopx/capabilities/native_chat/codex_adapter.py @@ -31,6 +31,7 @@ # and let the native compactor carry its own opaque state into the next prefix; # a single oversized injection can fail before automatic compaction can start. HISTORY_RESTORE_BATCH_BYTES = 128 * 1024 +_READ_ONLY_ITEM_TYPES = frozenset({"reasoning", "webSearch", "imageView", "contextCompaction"}) @dataclass @@ -335,7 +336,7 @@ def turns(): elif kind in {"commandExecution", "fileChange", "mcpToolCall"}: if not _settled_work_effect(item): return None # Declined/live/unobserved effects cannot request writable continuation. - elif kind not in {"reasoning", "webSearch", "imageView", "contextCompaction"}: + elif kind not in _READ_ONLY_ITEM_TYPES: return None if (users != 1 or len(finals) != 1 or turns() != inventory or list(session._control_items("thread/items/list", {"turnId": turn_id}, deadline)) != items @@ -357,8 +358,8 @@ def turns(): return None for row in rows: item = row["item"] - if (item.get("type") not in {"userMessage", "agentMessage", "commandExecution", "fileChange", - "mcpToolCall", "reasoning", "webSearch", "imageView", "contextCompaction"} + if (item.get("type") not in _READ_ONLY_ITEM_TYPES | {"userMessage", "agentMessage", + "commandExecution", "fileChange", "mcpToolCall"} or item.get("type") == "agentMessage" and item.get("phase") not in {"commentary", "final_answer"} or item.get("type") in {"commandExecution", "fileChange", "mcpToolCall"} and not _settled_work_effect(item)): @@ -417,7 +418,7 @@ def turns() -> list[dict[str, Any]]: if item.get("status") not in {"completed", "failed", "declined"}: return None effects += 1 - elif kind not in {"reasoning", "webSearch", "contextCompaction"}: + elif kind not in _READ_ONLY_ITEM_TYPES: return None if (users != 1 or not effects or turns() != inventory or list(session._control_items("thread/items/list", {"turnId": turn_id}, deadline)) != items): diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index 2f3781aafb..d141867cd1 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -202,7 +202,8 @@ def write(packet): @pytest.mark.parametrize("fault", [None, "active", "completed", "final", "missing-phase", "correction", - "unknown", "foreign", "duplicate", "live-tool", "late-items", "late-turn", "dynamic-tool", "live-dynamic-tool", "later-completed"]) + "unknown", "foreign", "duplicate", "live-tool", "late-items", "late-turn", "dynamic-tool", "live-dynamic-tool", "later-completed", + "image-view", "moving-image-view", "unknown-image-view", "image-without-effect"]) def test_interrupted_result_review_requires_stable_full_inventory_without_final_or_live_effects(tmp_path, monkeypatch, fault): session = chat_agent.CodexChatAgentSession(process=SimpleNamespace(poll=lambda: None), messages=queue.Queue(), thread_id="original", work_dir=tmp_path, response_timeout_sec=.2) @@ -238,6 +239,13 @@ def write(packet): item.update(type="dynamicToolCall", status="completed" if fault == "dynamic-tool" else "inProgress") rows = [{"turnId": "foreign" if fault == "foreign" else "interrupted", "item": item}, {"turnId": "interrupted", "item": {"id": "comment", "type": "agentMessage", "phase": "commentary"}}] + if fault in {"image-view", "moving-image-view", "unknown-image-view", "image-without-effect"}: + rows.append({"turnId": "interrupted", "item": {"id": "image", + "type": "futureImageView" if fault == "unknown-image-view" else "imageView", + "path": "/synthetic/changed.png" if fault == "moving-image-view" and items_read > 1 + else "/synthetic/figure.png"}}) + if fault == "image-without-effect": + rows.pop(0) if fault in {"final", "missing-phase", "correction"} or fault == "late-items" and items_read > 1: rows.append({"turnId": "interrupted", "item": {"id": "extra", "type": "userMessage" if fault == "correction" else "agentMessage", @@ -247,7 +255,7 @@ def write(packet): monkeypatch.setattr(session, "_write", write) proof = interrupted_result_readback(session, "interrupted") - if fault in {None, "dynamic-tool", "later-completed"}: + if fault in {None, "dynamic-tool", "later-completed", "image-view"}: digest = proof.pop("native_inventory_sha256") assert len(digest) == 64 and set(digest) <= set("0123456789abcdef") assert proof == {"thread_id": "original", "turn_id": "interrupted", "read_only_review": True, diff --git a/tests/test_chat_codex_history.py b/tests/test_chat_codex_history.py index 03f933c9ad..2f87fbb7c3 100644 --- a/tests/test_chat_codex_history.py +++ b/tests/test_chat_codex_history.py @@ -131,6 +131,7 @@ def native_start(**kwargs): @pytest.mark.parametrize("fault", [None, "image-view", "known-later", "settled-command", "later-settled-command", + "later-image-view", "later-image-moved", "command-no-exit", "command-open", "command-invalid-clock", "command-signal", "command-timeout", "command-bool-exit", "command-no-output", "command-bool-clock", "command-zero-exit", "later-user", "later-failed-effect", "later-moving-items", @@ -154,7 +155,7 @@ def items(method, params, deadline): counts[method] += 1 if method == "thread/turns/list": turns = [{"id": "original-turn", "status": "failed" if fault == "failed" else "completed"}] - if fault in {"known-later", "later-settled-command", "later-user", "later-failed-effect", "later-moving-items", "later-unknown", "live"} or fault == "moving-turn" and counts[method] > 1: + if fault in {"known-later", "later-settled-command", "later-image-view", "later-image-moved", "later-user", "later-failed-effect", "later-moving-items", "later-unknown", "live"} or fault == "moving-turn" and counts[method] > 1: turns.insert(0, {"id": "later", "status": "inProgress" if fault == "live" else "completed"}) if fault == "missing-original": turns = [{"id": "later", "status": "completed"}] @@ -169,6 +170,10 @@ def items(method, params, deadline): if fault == "later-settled-command": rows[1]["item"].update(type="commandExecution", status="failed", exitCode=1, startedAtMs=1000, completedAtMs=1001, aggregatedOutput="Process exited with code 1.") + if fault in {"later-image-view", "later-image-moved"}: + rows.append({"turnId": "later", "item": {"id": "later-image", "type": "imageView", + "path": "/synthetic/changed.png" if fault == "later-image-moved" and later_reads > 1 + else "/synthetic/figure.png"}}) if fault == "later-unknown" or fault == "later-moving-items" and later_reads > 1: rows.append({"turnId": "later", "item": {"id": "unknown", "type": "futureEffect"}}) return iter(rows) @@ -215,13 +220,13 @@ def items(method, params, deadline): native = SimpleNamespace(thread_id="native", work_dir=Path("/synthetic/notes"), response_timeout_sec=.1, _control_items=items, _background_terminals=lambda deadline: [{"processId": "live"}] if fault == "background" else []) proof = completed_work_readback(native, "original-turn", known_turn_ids={"later"} - if fault in {"known-later", "later-settled-command", "later-failed-effect", "later-moving-items", "later-unknown"} else None) - if fault in {None, "image-view", "known-later", "settled-command", "later-settled-command"}: + if fault in {"known-later", "later-settled-command", "later-image-view", "later-image-moved", "later-failed-effect", "later-moving-items", "later-unknown"} else None) + if fault in {None, "image-view", "known-later", "settled-command", "later-settled-command", "later-image-view"}: digest = proof.pop("native_inventory_sha256") assert len(digest) == 64 and set(digest) <= set("0123456789abcdef") assert proof == {"thread_id": "native", "turn_id": "original-turn", "completed_work": True, "all_effects_settled": True, "unfinished_work": work} - assert counts == {"thread/turns/list": 3, "thread/items/list": 4 if fault in {"known-later", "later-settled-command"} else 2} + assert counts == {"thread/turns/list": 3, "thread/items/list": 4 if fault in {"known-later", "later-settled-command", "later-image-view"} else 2} else: assert proof is None assert set(calls) <= {"thread/turns/list", "thread/items/list"} diff --git a/tests/test_steward_interrupted_answer.py b/tests/test_steward_interrupted_answer.py index bea53e4d68..ce3e6ef480 100644 --- a/tests/test_steward_interrupted_answer.py +++ b/tests/test_steward_interrupted_answer.py @@ -5,6 +5,7 @@ import json from pathlib import Path import time +from types import SimpleNamespace import pytest from test_antigravity_steward_adapter import language_server # noqa: F401 @@ -15,7 +16,7 @@ from loopx.extensions.lark.conversation_identity import identity_ref -@pytest.mark.parametrize("fault", [None, "stop", "source-changed", "unknown-native", "no-capacity", "native-changed", "review-action", "post-enqueue-crash"]) +@pytest.mark.parametrize("fault", [None, "stop", "source-changed", "unknown-native", "no-capacity", "native-changed", "review-action", "post-enqueue-crash", "image-view"]) def test_restarted_codex_steward_gets_one_original_read_only_answer(steward, monkeypatch, fault): from loopx.capabilities.native_chat.codex_adapter import CodexAppServerAdapter from loopx.chat_agent import CodexChatAgentError @@ -92,6 +93,18 @@ def observe(**kwargs): probes.append(kwargs) if fault == "unknown-native": return None + if fault == "image-view": + from loopx.capabilities.native_chat.codex_adapter import interrupted_result_readback + def items(method, params, _deadline): + if method == "thread/turns/list": + return iter([{"id": failed["upstream_turn_id"], "status": "interrupted"}]) + assert method == "thread/items/list" and params == {"turnId": failed["upstream_turn_id"]} + return iter([{"turnId": failed["upstream_turn_id"], "item": item} for item in [ + {"id": "original-user", "type": "userMessage"}, + {"id": "original-command", "type": "commandExecution", "status": "completed"}, + {"id": "original-image", "type": "imageView", "path": str(artifact)}]]) + return interrupted_result_readback(SimpleNamespace(thread_id="durable-thread", + response_timeout_sec=1, _control_items=items), failed["upstream_turn_id"]) changed = fault == "native-changed" and bool((transport.core.read_request(ref).get("context_recovery") or {}).get("answer_review")) return {"thread_id": "durable-thread", "turn_id": failed["upstream_turn_id"], "read_only_review": True, "no_final_answer": True, "effects_observed": True,