Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,11 @@ provider 当前正文等于已知前文或目标正文。正文改变、效果
和活动 item 的原生中断清单。原生 dynamic-tool 调用同样属于效果条目;稳定的终态
清单只允许这次只读复核,不授予业务重放权。实际复核前再次核验清单摘要。

provider 在这次复核与已完成工作的读回中复用同一组被动原生条目类型,包含
`imageView`。读取图片不会使清单变成未知,也不算业务效果已结束的证明。
图片元数据仍绑定原 Turn 和完整稳定清单的摘要;变化、重复、外国 Turn 或未知图片
条目类型继续被原检查拒绝。原有效果终态、没有最终答案和没有活动工作的要求保留。

复核沿用原来源、本人、grant、停止检查及同一 `review-1` 队列预留。受理及派发前
都须重新核验已配置的 Codex 额度。新受限 thread 只保留原授权管家读取,禁用写入、
网络效果、委派及业务续办;基于现有证据答复,或给出具体的部分答案。原失败 Turn
Expand Down
9 changes: 5 additions & 4 deletions loopx/capabilities/native_chat/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)):
Expand Down Expand Up @@ -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):
Expand Down
12 changes: 10 additions & 2 deletions tests/test_chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions tests/test_chat_codex_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"}]
Expand All @@ -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)
Expand Down Expand Up @@ -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"}
Expand Down
15 changes: 14 additions & 1 deletion tests/test_steward_interrupted_answer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading