From 040478227c871bf38a8653132dcea32f5c87e9cf Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Sun, 27 Sep 2026 21:22:31 -0700 Subject: [PATCH 1/3] fix(history): preserve long-turn output and completion state - Restore public Codex messages, source clocks and bounded long-turn detail pages. - Preserve side-chat user boundaries and complete item snapshots across replay. - Load open process details reliably and retain live output during hydration. - Match Claude terminal receipts to consumed human inputs with service compatibility. - Cover history, replay, cursor boundaries and completion routing with regressions. --- cc_remote/claude_service/server.py | 10 +- cc_remote/claude_steering.py | 48 ++- cc_remote/wrapper/btw_history.py | 350 +++++++++++++++++++++ cc_remote/wrapper/codex_detail_pages.py | 202 ++++++++++++ cc_remote/wrapper/codex_handle.py | 13 + cc_remote/wrapper/codex_history.py | 11 +- cc_remote/wrapper/codex_stream.py | 237 ++++++++++---- cc_remote/wrapper/history_store.py | 13 +- cc_remote/wrapper/machine.py | 131 ++++++-- cc_remote/wrapper/sdk.py | 22 +- cc_remote/wrapper/session_ctx.py | 2 + docs/claude-session-service.md | 10 + tests/test_btw_history.py | 275 ++++++++++++++++ tests/test_claude_native_task_merge.py | 10 +- tests/test_claude_queued_prompt_history.py | 7 +- tests/test_claude_steering.py | 87 +++++ tests/test_codex_detail_pages.py | 206 ++++++++++++ tests/test_codex_history.py | 46 ++- tests/test_codex_rollout_lifecycle.py | 127 ++++++++ tests/test_codex_steer_pagination.py | 54 ++++ tests/test_history.py | 16 +- tests/test_history_store.py | 56 +++- web/package.json | 2 +- web/src/components/BtwPanel.css | 1 + web/src/components/BtwPanel.tsx | 3 + web/src/components/ChatView.tsx | 5 +- web/src/components/ProcessTimeline.tsx | 26 +- web/src/history-detail-projection.ts | 14 +- web/src/history-merge.ts | 37 ++- web/src/reducer.ts | 24 +- web/tests/btw-history.test.ts | 102 ++++++ web/tests/history-browser.fixture.tsx | 5 +- web/tests/history-browser.spec.ts | 19 ++ web/tests/process-detail.test.ts | 93 ++++++ web/vite.config.ts | 8 +- 35 files changed, 2122 insertions(+), 150 deletions(-) create mode 100644 cc_remote/wrapper/btw_history.py create mode 100644 cc_remote/wrapper/codex_detail_pages.py create mode 100644 tests/test_btw_history.py create mode 100644 tests/test_codex_detail_pages.py create mode 100644 tests/test_codex_rollout_lifecycle.py create mode 100644 web/tests/btw-history.test.ts diff --git a/cc_remote/claude_service/server.py b/cc_remote/claude_service/server.py index eabab751..ff674069 100644 --- a/cc_remote/claude_service/server.py +++ b/cc_remote/claude_service/server.py @@ -19,7 +19,9 @@ from pathlib import Path from uuid import uuid4 -from cc_remote.claude_steering import PendingSteers, background_end_ids, is_managed_input, steer_message +from cc_remote.claude_steering import ( + PendingSteers, background_end_ids, is_human_result, is_managed_input, steer_message, +) from .wire import ( ControllerLeaseConflict, decode_sdk, encode_sdk, private_directory, @@ -79,9 +81,7 @@ def close(self) -> None: def _human_result(data: dict) -> bool: - origin = data.get("origin") - kind = origin.get("kind") if isinstance(origin, dict) else None - return data.get("type") == "result" and kind in (None, "human") + return is_human_result(data) class Session: @@ -282,6 +282,7 @@ def description(self) -> dict: "native_steering": True, "background_steering": True, "background_activity_steering": True, + "human_result_receipts": True, "pending_steers": {uid: {"id": data["id"]} for uid, data in self.steers.pending.items()}, } @@ -407,6 +408,7 @@ async def stream(): self.turn = {**params["turn"], "start_seq": self.journal.seq, "started_at": time.time(), "previous_origin_id": self.origin_id} self.managed_input_seen = False + self.steers.begin_turn() self.pending_compact = (isinstance(prompt, str) and prompt.split(maxsplit=1)[:1] == ["/compact"]) self.terminal_seq = None diff --git a/cc_remote/claude_steering.py b/cc_remote/claude_steering.py index 423f9e3e..8f18c163 100644 --- a/cc_remote/claude_steering.py +++ b/cc_remote/claude_steering.py @@ -68,6 +68,17 @@ def is_managed_input(value: dict, *, pending_compact: bool = False) -> bool: return False +def is_human_result(value: dict) -> bool: + """Use an exact consumption receipt before falling back to legacy origin.""" + if (value.get("type") != "result" or value.get("parent_tool_use_id") + or value.get("parentToolUseID")): + return False + if "__cc_managed_result" in value: + return value["__cc_managed_result"] is True + origin = value.get("origin") + return not isinstance(origin, dict) or origin.get("kind") in (None, "human") + + class PendingSteers: """Fence accepted inputs against their exact replayed human UUIDs. @@ -83,6 +94,10 @@ def __init__(self): self.background_origin: str | None = None self.background_origin_data: dict | None = None self._backgrounds: dict[str, dict] = {} + self._consumed_user_id: str | None = None + + def begin_turn(self) -> None: + self._consumed_user_id = None def handoff_background(self, identity: str | None) -> None: self._backgrounds.pop(identity, None) @@ -96,10 +111,31 @@ def add(self, native_id: str, metadata: dict) -> None: raise ClaudeSteerRejected("Claude steering capacity reached") self.pending[native_id] = metadata - def annotate(self, value: dict, *, managed_active: bool = False) -> dict: + def annotate(self, value: dict, *, managed_active: bool = False, + result_receipts: bool = True) -> dict: origin = value.get("origin") kind = origin.get("kind") if isinstance(origin, dict) else None child = value.get("parent_tool_use_id") or value.get("parentToolUseID") + uid = value.get("uuid") + if (is_managed_input(value) and value.get("type") == "user" + and isinstance(uid, str) and uid + and (managed_active or uid in self.pending or value.get("__cc_steer"))): + self._consumed_user_id = uid + if value.get("type") == "result" and not child: + receipts = value.get("user_message_uuids") + receipts = receipts if isinstance(receipts, list) else [] + receipts = [uid for uid in receipts if isinstance(uid, str) and uid] + receipt = value.get("user_message_uuid") + if isinstance(receipt, str) and receipt: + receipts.append(receipt) + if receipts and result_receipts and "__cc_managed_result" not in value: + # Task notifications can be absorbed into the human response. + # The Result must consume the latest echoed input, not merely + # an older input in the same session. Preserve this decision in + # the journal: the pinned SDK parser drops the receipt fields. + value = {**value, "__cc_managed_result": + self._consumed_user_id is not None + and self._consumed_user_id in receipts} if not child: if managed_active and is_managed_input(value, pending_compact=True): # A native request can precede its replayed human input. That @@ -150,7 +186,7 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict: elif value.get("type") == "result": ended = background_end_ids(value) local_ends = () - if kind in (None, "human"): + if is_human_result(value): # An unattributed Result closes the physical response, # including its in-turn task inputs. An older autonomous # response must not be consumed by a new human terminal. @@ -159,7 +195,7 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict: if entry["managed"]) elif kind is None: local_ends = tuple(self._backgrounds) - else: + elif isinstance(origin, dict) and kind not in (None, "human"): # A precise unrelated origin remains authoritative. local_ends = tuple(key for key, entry in self._backgrounds.items() if entry["origin_key"] in (None, _origin_key(origin))) @@ -184,14 +220,16 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict: if metadata is not None: return {**value, "type": "system", "subtype": "cc_remote_steer_cancelled", "__cc_steer_cancelled": metadata} + if value.get("type") == "result" and is_human_result(value): + self._consumed_user_id = None + if self.pending: + return {**value, "__cc_steer_intermediate": True} if kind not in (None, "human") or child: return value if value.get("type") == "user": metadata = self.pending.pop(value.get("uuid"), None) if metadata is not None: return {**value, "__cc_steer": value.get("__cc_steer", metadata)} - elif value.get("type") == "result" and self.pending: - return {**value, "__cc_steer_intermediate": True} return value async def interrupt(self, client) -> None: diff --git a/cc_remote/wrapper/btw_history.py b/cc_remote/wrapper/btw_history.py new file mode 100644 index 00000000..6a796541 --- /dev/null +++ b/cc_remote/wrapper/btw_history.py @@ -0,0 +1,350 @@ +"""Bounded, process-local presentation snapshots for ephemeral side chats. + +Native Codex ephemeral threads cannot read persisted turns. Keep public wire +items, rather than token frames, until the side chat closes. Replay cuts only +between items and always carries the exact human/native owner boundary. +""" +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass, field + +from cc_remote.protocol import ( + AssistantMsgEnd, AssistantMsgStart, Delta, Error, ProcessEvent, ReplayEnd, + ReplayStart, ToolDelta, ToolResult, ToolUse, TurnBinding, TurnEnd, + TurnDiff, TurnFileChanges, TurnPlan, TurnSteered, UserMsg, +) +from cc_remote.wrapper.ringbuffer import RingBuffer + + +def item_key(event) -> str | None: + if isinstance(event, (AssistantMsgStart, Delta, AssistantMsgEnd)): + return "message:" + event.message_id + if isinstance(event, (ToolUse, ToolDelta, ToolResult)): + return "tool:" + event.tool_use_id + if isinstance(event, (ProcessEvent, TurnPlan, TurnDiff)): + return "process:" + event.item_id + if isinstance(event, TurnFileChanges): + return "changes:" + event.turn_id + if isinstance(event, Error) and event.msg_id: + return "error:" + event.msg_id + return None + + +@dataclass +class _Item: + events: OrderedDict[str, object] = field(default_factory=OrderedDict) + size: int = 0 + sealed: bool = False + + def append(self, event) -> None: + if ((isinstance(event, Delta) and "assistant_msg_end" in self.events) + or (isinstance(event, ToolDelta) and "tool_result" in self.events)): + return + if (self.sealed and isinstance(event, ProcessEvent) + and event.phase in {"start", "update"} + and event.status in {"unknown", "pending", "running"}): + return + key = event.type + if isinstance(event, ToolDelta): + key += ":" + event.stream + if isinstance(event, Delta) and event.replace: + for stored in list(self.events): + if stored == key or stored.startswith(key + ":"): + self.size -= RingBuffer._size(self.events.pop(stored)) + if isinstance(event, (Delta, ToolDelta)): + key = next((stored for stored in reversed(self.events) + if stored == key or stored.startswith(key + ":")), key) + previous = self.events.get(key) + if isinstance(event, Delta) and previous is not None and not event.replace: + # Coalesce small chunks, not the entire message on every token. + # This bounds copy/JSON sizing work on the live reader path. + if len(previous.text) + len(event.text) <= 32 * 1024: + event = event.model_copy(update={ + "text": previous.text + event.text, + "replace": previous.replace, + "channel": previous.channel if event.channel == "unknown" else event.channel, + }) + else: + key += ":" + str(len(self.events)) + previous = None + elif isinstance(event, ToolDelta) and previous is not None: + if len(previous.delta) + len(event.delta) <= 32 * 1024: + event = event.model_copy(update={"delta": previous.delta + event.delta}) + else: + key += ":" + str(len(self.events)) + previous = None + elif isinstance(event, ProcessEvent): + if previous is not None: + event = previous.model_copy(update=event.model_dump(exclude_none=True)) + if event.append_to and event.delta: + field = event.append_to + limit = {"summary": 65536, "detail": 262144, + "output": 2097152, "diff": 2097152, "progress": 65536}[field] + value = (getattr(event, field) or "") + event.delta + event = event.model_copy(update={ + field: value[-limit:], "append_to": None, "delta": None, + "truncated": event.truncated or len(value) > limit, + }) + self.events[key] = event + self.size += RingBuffer._size(event) - ( + RingBuffer._size(previous) if previous is not None else 0) + + +@dataclass +class _Turn: + user: UserMsg | TurnSteered + binding: TurnBinding | None = None + items: OrderedDict[str, _Item] = field(default_factory=OrderedDict) + end: TurnEnd | None = None + + def envelope(self) -> tuple[list, list]: + return ([self.user, *([self.binding] if self.binding else [])], + [self.end] if self.end else []) + + +class BtwHistory: + def __init__(self, max_bytes: int, max_items: int): + self.max_bytes = max_bytes + self.max_items = max_items + self.turns: OrderedDict[str, _Turn] = OrderedDict() + self.truncated = False + self._bytes = 0 + self._items = 0 + self._owners: dict[str, str] = {} + self._omitted: OrderedDict[str, None] = OrderedDict() + + def observe(self, event) -> None: + if isinstance(event, (UserMsg, TurnSteered)): + if event.msg_id not in self.turns: + self.turns[event.msg_id] = _Turn(event) + self._bytes += RingBuffer._size(event) + elif isinstance(event, TurnBinding): + turn = self.turns.get(event.msg_id) + if turn is not None: + self._bytes -= RingBuffer._size(turn.binding) if turn.binding else 0 + turn.binding = event + self._bytes += RingBuffer._size(event) + elif self.turns: + key = item_key(event) + if key in self._omitted: + # A later suffix of an evicted item is not a whole message. + # Only an exact completed snapshot may restore it below. + return + owner = self._owners.get(key) if key else None + parent = getattr(event, "parent_id", None) + if owner is None and parent: + owner = next((self._owners[prefix + parent] + for prefix in ("tool:", "process:", "message:") + if prefix + parent in self._owners), None) + turn = self.turns.get(owner) if owner else next(reversed(self.turns.values())) + explicit = event.msg_id if isinstance(event, Error) else getattr(event, "turn_id", None) + if explicit and owner is None: + matches = [row for row in self.turns.values() + if explicit in {row.user.msg_id, + getattr(row.user, "turn_id", None), + row.binding.turn_id if row.binding else None}] + turn = matches[-1] if matches else None + if turn is not None: + if isinstance(event, TurnEnd): + self._bytes -= RingBuffer._size(turn.end) if turn.end else 0 + turn.end = event + self._bytes += RingBuffer._size(event) + elif key: + group = turn.items.setdefault(key, _Item()) + if key not in self._owners: + self._owners[key] = turn.user.msg_id + self._items += 1 + old_size = group.size + group.append(event) + self._bytes += group.size - old_size + self._trim() + + def _trim(self) -> None: + while self.turns and (self._bytes > self.max_bytes + or self._items > self.max_items + or len(self.turns) > 64): + turn_id, turn = next(iter(self.turns.items())) + self.truncated = True + if len(self.turns) > 1 or not turn.items: + self.turns.pop(turn_id) + for event in sum(turn.envelope(), []): + self._bytes -= RingBuffer._size(event) + groups = list(turn.items.items()) + else: + groups = [turn.items.popitem(last=False)] + for key, group in groups: + self._bytes -= group.size + self._items -= 1 + self._owners.pop(key, None) + self._omitted[key] = None + while len(self._omitted) > 10000: + self._omitted.popitem(last=False) + + def repair(self, native_turn_id: str, snapshots: list[tuple[str | None, list]], + *, item_order: list[str] | None = None) -> None: + """Merge complete native items only into a proven visible segment.""" + rows = [row for row in self.turns.values() + if native_turn_id in { + row.binding.turn_id if row.binding else None, + getattr(row.user, "turn_id", None)}] + ordered: dict[str, list[str]] = {} + original = {owner: list(row.items) for owner, row in self.turns.items()} + for client_id, events in snapshots: + if not events: + continue + key = item_key(events[0]) + owner = self._owners.get(key) + turn = self.turns.get(owner or client_id) + if turn is None and client_id is None and len(rows) == 1: + turn = rows[0] + if turn is None or not any(turn is row for row in rows) or not key: + continue + group = _Item() + for event in events: + group.append(event) + group.sealed = True + previous = turn.items.get(key) + if previous is None: + self._items += 1 + self._bytes += group.size - (previous.size if previous else 0) + turn.items[key] = group + self._owners[key] = turn.user.msg_id + self._omitted.pop(key, None) + ordered.setdefault(turn.user.msg_id, []).append(key) + for owner, keys in ordered.items(): + turn = self.turns[owner] + if item_order is not None: + by_native_id = {key.split(":", 1)[1]: key for key in turn.items} + keys = [by_native_id[native_id] for native_id in item_order + if native_id in by_native_id] + # Insert missing completed items before the next known native item. + # A bounded cache can omit an older prefix still in the live row; + # never move that prefix behind the retained cache suffix. + order = original[owner] + pending = [] + for key in dict.fromkeys(keys): + if key in order: + at = order.index(key) + order[at:at] = pending + pending = [] + else: + pending.append(key) + order.extend(pending) + # A translator may normalize a malformed native id. Its complete + # public snapshot still belongs here even without an order anchor. + order.extend(key for key in turn.items if key not in order) + turn.items = OrderedDict((key, turn.items[key]) for key in order) + self._trim() + + def replay(self, *, tail_seq: int, generation: str, max_bytes: int, + max_events: int, turn_usage: list) -> list: + selected: list = [] + used = 0 + truncated = self.truncated + for turn in reversed(self.turns.values()): + opening, closing = turn.envelope() + envelope = opening + closing + size = sum(RingBuffer._size(e) for e in envelope) + if used + size > max_bytes or len(selected) + len(envelope) > max_events: + truncated = True + break + body: list = [] + used += size + for group in reversed(turn.items.values()): + events = list(group.events.values()) + if (used + group.size > max_bytes + or len(selected) + len(envelope) + len(body) + len(events) > max_events): + truncated = True + break + body[0:0] = events + used += group.size + selected[0:0] = opening + body + closing + if len(body) < sum(len(group.events) for group in turn.items.values()): + break + # These are complete item snapshots, not a cursor suffix. Sequence is + # committed once at ReplayEnd; no reconstructed item changes live seq. + frames = [ReplayStart(from_seq=0, to_seq=tail_seq, truncated=truncated, + rebuild=True, generation=generation)] + frames.extend(e.model_copy(deep=True, update={"seq": None}) for e in selected) + frames.append(ReplayEnd(to_seq=tail_seq, truncated=truncated, turn_usage=turn_usage)) + return frames + + +class CodexBtwCompletions: + """Capture public completion snapshots before a bounded live queue sheds. + + No raw reasoning, model context or disk transcript is retained. At most two + turns survive, allowing the previous terminal repair to race the next input. + """ + def __init__(self, max_bytes: int, max_items: int, tool_result_max: int): + self.max_bytes, self.max_items = max_bytes, max_items + self.tool_result_max = tool_result_max + self.turns: OrderedDict[str, OrderedDict] = OrderedDict() + self.owners: OrderedDict[str, str | None] = OrderedDict() + self._last_users: dict[str, str] = {} + self.initial_owner: str | None = None + self._bytes = 0 + self.truncated: set[str] = set() + + def observe(self, message: dict) -> None: + from cc_remote.wrapper.codex_stream import CodexStreamTranslator, codex_live_user_message + + params = message.get("params") + if not isinstance(params, dict): + return + turn_id = params.get("turnId") + item = params.get("item") + if (message.get("method") not in {"item/started", "item/completed"} + or not isinstance(turn_id, str) or not turn_id or len(turn_id) > 256 + or not isinstance(item, dict) or not isinstance(item.get("id"), str) + or not item["id"] or len(item["id"]) > 256): + return + user = codex_live_user_message(message) + if user is not None: + # An unattributed steer is deliberately ambiguous, never assumed to + # belong to the last accepted browser input. + if self._last_users.get(turn_id) != user.message_id: + self.owners[turn_id] = user.client_id or ( + self.initial_owner if turn_id not in self._last_users else None) + self._last_users[turn_id] = user.message_id + elif user.client_id: + self.owners[turn_id] = user.client_id + while len(self.owners) > 64: + old_id, _ = self.owners.popitem(last=False) + self._last_users.pop(old_id, None) + return + rows = self.turns.setdefault(turn_id, OrderedDict()) + self.owners.setdefault(turn_id, self.initial_owner) + key = item["id"] + if key not in rows: + rows[key] = (self.owners[turn_id], [], 0) + if message["method"] == "item/completed": + try: + events = CodexStreamTranslator(self.tool_result_max).feed(message) + except (TypeError, ValueError, KeyError): + # Optional recovery must not break the native stdout reader. + return + events = [e for e in events if item_key(e)] + size = sum(RingBuffer._size(e) for e in events) + owner, _, previous = rows[key] + rows[key] = (owner, events, size) + self._bytes += size - previous + while self.turns and (len(self.turns) > 2 or self._bytes > self.max_bytes + or sum(len(rows) for rows in self.turns.values()) > self.max_items): + first_id, first = next(iter(self.turns.items())) + if len(self.turns) > 1 or not first: + self.turns.pop(first_id) + self.owners.pop(first_id, None) + self._last_users.pop(first_id, None) + self.truncated.discard(first_id) + self._bytes -= sum(entry[2] for entry in first.values()) + else: + self.truncated.add(first_id) + self._bytes -= first.popitem(last=False)[1][2] + + def snapshots(self, turn_id: str) -> list[tuple[str | None, list]]: + return [(owner, events) for owner, events, _ in self.turns.get(turn_id, {}).values()] + + def item_order(self, turn_id: str) -> list[str]: + return list(self.turns.get(turn_id, {})) diff --git a/cc_remote/wrapper/codex_detail_pages.py b/cc_remote/wrapper/codex_detail_pages.py new file mode 100644 index 00000000..8f137d21 --- /dev/null +++ b/cc_remote/wrapper/codex_detail_pages.py @@ -0,0 +1,202 @@ +"""Bounded, read-only intra-turn pages for oversized Codex rollout segments. + +Conversation cursors still navigate between human inputs. These private cursors +walk source windows *inside* one input, then reuse the ordinary display-group +pager. No source path or caller-supplied byte offset crosses the wire. +""" +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass, field +import os +import threading +from typing import Callable +from uuid import uuid4 + +from cc_remote.protocol import TurnBinding, UserMsg +from cc_remote.wrapper.codex_stream import ( + _history_boundary_records, + _next_jsonl_offset, + _previous_jsonl_record_offset, + codex_history_boundary_user, + codex_next_user_boundary_ts, + codex_translate_history, +) +from cc_remote.wrapper.history_store import ( + HistorySourceFingerprint, + history_source_extends, +) + +PREFIX = "cd1." +_MAX_SNAPSHOTS = 32 +_MAX_PAGES = 8192 +_Position = tuple[int, str | None] +_Page = tuple[list[dict], bool, str | None, bool, str | None] + + +class CodexDetailCursorExpired(ValueError): + """The exact source/page chain is no longer available; restart explicitly.""" + + +@dataclass +class _Snapshot: + sid: str + turn_id: str + revision: str + source: HistorySourceFingerprint + start: int + end: int + native_turn_id: str | None + user: UserMsg + window_bytes: int + limit: int + max_bytes: int + tool_result_max: int + segment_end_ts: float | None = None + # Every accepted coordinate was issued by this pager, never supplied by + # the browser. Parent links keep reverse navigation exact across windows. + pages: dict[_Position, _Position | None] = field(default_factory=dict) + + +class CodexDetailPages: + def __init__(self) -> None: + self._snapshots: OrderedDict[str, _Snapshot] = OrderedDict() + self._lock = threading.Lock() + + @staticmethod + def _cursor(key: str, position: _Position) -> str: + end, before = position + return f"{PREFIX}{key}.{end:x}.{before if before is not None else 'n'}" + + def read( + self, path: str, sid: str, turn_id: str, revision: str, + *, before: str | None, limit: int, window_bytes: int, + max_bytes: int, tool_result_max: int, paginate: Callable[..., _Page], + ) -> _Page | None: + """Return None for ordinary turns; reject stale private cursors.""" + with self._lock: + return self._read( + path, sid, turn_id, revision, before=before, limit=limit, + window_bytes=max(1024 * 1024, window_bytes), + max_bytes=max_bytes, tool_result_max=tool_result_max, + paginate=paginate, + ) + + def _read( + self, path: str, sid: str, turn_id: str, revision: str, + *, before: str | None, limit: int, window_bytes: int, + max_bytes: int, tool_result_max: int, paginate: Callable[..., _Page], + ) -> _Page | None: + if before is not None: + if not before.startswith(PREFIX): + return None + try: + _, key, encoded_end, encoded_before = before.split(".") + position = (int(encoded_end, 16), + None if encoded_before == "n" else encoded_before) + snapshot = self._snapshots[key] + except (ValueError, KeyError): + raise CodexDetailCursorExpired("expired Codex detail cursor") from None + if (snapshot.sid != sid or snapshot.turn_id != turn_id + or snapshot.revision != revision or snapshot.limit != limit + or position not in snapshot.pages + or self._cursor(key, position) != before): + raise CodexDetailCursorExpired("Codex detail cursor scope changed") + else: + if os.path.getsize(path) <= window_bytes: + return None + source = HistorySourceFingerprint.capture(path) + end = source.size + # Never freeze a half-written JSONL record into an immutable page. + with open(path, "rb") as stream: + stream.seek(end - 1) + if stream.read(1) != b"\n": + end = _previous_jsonl_record_offset(path, end) + found = None + for boundary in _history_boundary_records( + path, use_turns=True, end_offset=end, + ): + if turn_id in (boundary.cursor, boundary.compatibility_cursor): + found = boundary + break + end = boundary.offset + if found is None or end - found.offset <= window_bytes: + return None + user = codex_history_boundary_user(path, found.offset, found.cursor) + if user is None: + # An assistant-only native task has a proven boundary too. + user = UserMsg(msg_id=found.cursor, prompt="", ts=0) + user = user.model_copy(update={"msg_id": turn_id}) + snapshot = _Snapshot( + sid, turn_id, revision, source, found.offset, end, + found.native_turn_id, user, window_bytes, limit, max_bytes, + tool_result_max, + segment_end_ts=(codex_next_user_boundary_ts( + path, end, found.native_turn_id, end_offset=source.size) + if end < source.size else None), + ) + key = uuid4().hex + position = (end, None) + snapshot.pages[position] = None + self._snapshots[key] = snapshot + self._snapshots.move_to_end(key) + while len(self._snapshots) > _MAX_SNAPSHOTS: + self._snapshots.popitem(last=False) + + current = HistorySourceFingerprint.capture(path) + if not history_source_extends(snapshot.source, current): + self._snapshots.pop(key, None) + raise CodexDetailCursorExpired("Codex rollout was replaced or truncated") + end, group_before = position + start = snapshot.start + if end - start > snapshot.window_bytes: + start = _next_jsonl_offset(path, end - snapshot.window_bytes, end) + if start == end: + # A single oversized record is skipped by the bounded reader; + # still advance past it instead of issuing the same page forever. + start = max(snapshot.start, _previous_jsonl_record_offset(path, end)) + events, _ = codex_translate_history( + path, snapshot.tool_result_max, start_offset=start, end_offset=end, + source_continuation="authoritative_page", + source_turn_id=snapshot.native_turn_id, + segment_end_ts=snapshot.segment_end_ts if end == snapshot.end else None, + snapshot_in_progress=True, + ) + # The source window may omit its user/start. Always repeat the proven + # envelope; never project the chunk as a new question or a new task. + if start > snapshot.start: + events.insert(0, snapshot.user) + if snapshot.native_turn_id: + events.insert(1, TurnBinding( + msg_id=turn_id, turn_id=snapshot.native_turn_id, + ts=snapshot.user.ts, + )) + rows = [event.model_dump(mode="json") for event in events] + for row in rows: + row["sid"] = sid + if row.get("type") == "user_msg": + row["msg_id"] = turn_id + page, has_more, older, _has_newer, _newer = paginate( + rows, before=group_before, limit=snapshot.limit, + max_bytes=snapshot.max_bytes, + ) + # Validate again after parsing, so a concurrent rollback cannot publish + # an apparently authoritative page from a different source generation. + if not history_source_extends( + snapshot.source, HistorySourceFingerprint.capture(path), + ): + self._snapshots.pop(key, None) + raise CodexDetailCursorExpired("Codex rollout changed during detail read") + older_position = ((end, older) if has_more else + (start, None) if start > snapshot.start else None) + if older_position is not None: + if len(snapshot.pages) >= _MAX_PAGES and older_position not in snapshot.pages: + raise CodexDetailCursorExpired("Codex detail page chain expired") + snapshot.pages.setdefault(older_position, position) + newer_position = snapshot.pages[position] + return ( + page, older_position is not None, + self._cursor(key, older_position) if older_position is not None else None, + newer_position is not None, + self._cursor(key, newer_position) if newer_position is not None else None, + ) diff --git a/cc_remote/wrapper/codex_handle.py b/cc_remote/wrapper/codex_handle.py index 29692235..735dfba5 100644 --- a/cc_remote/wrapper/codex_handle.py +++ b/cc_remote/wrapper/codex_handle.py @@ -37,6 +37,7 @@ from cc_remote import __version__ from cc_remote.log import logger from cc_remote.wrapper.token_usage import CodexUsageTracker +from cc_remote.wrapper.btw_history import CodexBtwCompletions from cc_remote.protocol import ( MAX_SAFE_WIRE_INTEGER, MAX_SAFE_WIRE_TIMESTAMP_SECONDS, @@ -1527,6 +1528,7 @@ def __init__(self, cfg, cwd: Optional[str] = None, self._ephemeral_server_identity: Optional[CodexServerIdentity] = None self._ephemeral_private_generation: Optional[int] = None self._ephemeral_thread_gone = False + self.btw_completions: CodexBtwCompletions | None = None # A shared daemon can publish every subscribed thread immediately after # initialize, before thread/resume returns and assigns ``thread_id``. # Freeze the requested resume id across that bind window so only the @@ -2389,6 +2391,11 @@ async def connect( self.thread_id = _thread_id_of(res) bound_thread_id = self.thread_id self._ephemeral_thread_id = self.thread_id + self.btw_completions = CodexBtwCompletions( + min(getattr(self.cfg, "ring_max_bytes", 24 * 1024 * 1024), 24 * 1024 * 1024), + min(getattr(self.cfg, "ring_max_events", 10000), 10000), + self.cfg.tool_result_max, + ) self._ephemeral_server_identity = self._daemon_process_identity self._ephemeral_private_generation = ( None if daemon_proxy else self._generation) @@ -2648,6 +2655,8 @@ async def query( assert self.proc is not None and self.thread_id, "connect() first" thread_id = self.thread_id self._open_managed_stream() + if self.btw_completions is not None: + self.btw_completions.initial_owner = client_user_message_id queue = self._turn_q params = { "threadId": thread_id, @@ -3817,6 +3826,8 @@ def _queue_spontaneous_notification( turn_id = self._spontaneous_queue_turn_id if q is None or turn_id is None: return False + if self.btw_completions is not None: + self.btw_completions.observe(message) method = message.get("method") terminal = method == "turn/completed" size = ( @@ -3857,6 +3868,8 @@ def _queue_managed_notification( q = self._turn_q if not isinstance(q, _SpontaneousNotificationQueue): return False + if self.btw_completions is not None: + self.btw_completions.observe(message) method = message.get("method") terminal = method == "turn/completed" size = ( diff --git a/cc_remote/wrapper/codex_history.py b/cc_remote/wrapper/codex_history.py index a3a9218c..b644a8d5 100644 --- a/cc_remote/wrapper/codex_history.py +++ b/cc_remote/wrapper/codex_history.py @@ -78,6 +78,10 @@ class CodexHistoryInvalidResponse(CodexHistoryError): """The app-server returned malformed or internally inconsistent history.""" +class CodexHistoryProjectionTooLarge(CodexHistoryInvalidResponse): + """Valid item pagination reached our bounded projection budget.""" + + class CodexHistoryCursorError(CodexHistoryInvalidResponse): """A browser-safe cursor has no mapping in this wrapper generation.""" @@ -1295,7 +1299,7 @@ async def _items_for_turn( page_count = 0 while True: if page_count >= _MAX_ITEM_PAGES: - raise CodexHistoryInvalidResponse( + raise CodexHistoryProjectionTooLarge( "Codex item pagination exceeded its page limit") page_count += 1 response = await self._call("thread/items/list", { @@ -1336,7 +1340,7 @@ async def _items_for_turn( seen_items.add(item_id) items.append(normalized_item) if len(items) > _MAX_DETAIL_ITEMS: - raise CodexHistoryUnsupported( + raise CodexHistoryProjectionTooLarge( "Codex turn exceeds the bounded official item projection") if next_cursor is None: return items @@ -1416,6 +1420,9 @@ async def turn_events( try: items = await self._items_for_turn( thread_id, locator.native_turn_id) + except CodexHistoryProjectionTooLarge as exc: + raise CodexHistoryUnsupported( + "official Codex turn detail exceeds projection budget") from exc except CodexRpcRejected as exc: if not _unsupported(exc): raise diff --git a/cc_remote/wrapper/codex_stream.py b/cc_remote/wrapper/codex_stream.py index 1e62a632..ce638d11 100644 --- a/cc_remote/wrapper/codex_stream.py +++ b/cc_remote/wrapper/codex_stream.py @@ -629,7 +629,7 @@ def _legacy_user_item_before( path: str, offset: int, ) -> tuple[int, str | None]: - """Return the preceding record offset and its legacy native user id.""" + """Include a preceding legacy user envelope, never a preceding tool row.""" if offset <= 0: return 0, None try: @@ -651,8 +651,12 @@ def _legacy_user_item_before( except (json.JSONDecodeError, ValueError): return previous, None if not isinstance(row, dict) or row.get("type") != "response_item": - return previous, None - return previous, _legacy_response_user_item_id(row.get("payload")) + return offset, None + payload = row.get("payload") + if (not isinstance(payload, dict) or payload.get("type") != "message" + or payload.get("role") != "user"): + return offset, None + return previous, _legacy_response_user_item_id(payload) def _fallback_history_id(path: str, kind: str, offset: int, raw_ts: str, @@ -2025,6 +2029,7 @@ def codex_history_boundary_user( msg_id=message_id, client_msg_id=client_id, prompt=user.prompt, + ts=0, ) if pending_images: event.images = pending_images @@ -2041,7 +2046,7 @@ def codex_history_boundary_user( if goal_prompt is None or user_index != 0: return None prompt, timestamp = goal_prompt - event = UserMsg(msg_id=cursor, prompt=prompt) + event = UserMsg(msg_id=cursor, prompt=prompt, ts=0) if timestamp is not None: event.ts = timestamp return event @@ -4015,6 +4020,43 @@ def is_turn_terminal(msg: dict) -> bool: # ---- on-disk Codex rollout -> wire events (session history) ---- +def codex_next_user_boundary_ts( + path: str, offset: int, native_turn_id: str | None, *, end_offset: int | None = None, +) -> float | None: + """Read only an adjacent user boundary, never infer completion from EOF. + + Page ends can also be arbitrary source windows or frozen active tails. + Only an actual following user record proves a visible segment is closed. + Legacy response_item/user must be paired with its immediately next replay. + """ + try: + with open(path, "rb") as stream: + stream.seek(offset) + for index in range(2): + limit = _MAX_HISTORY_BOUNDARY_RECORD_BYTES + 1 + if end_offset is not None: + limit = min(limit, max(0, end_offset - stream.tell())) + if limit == 0: + return None + line = stream.readline(limit) + if not line.endswith(b"\n") or len(line) > _MAX_HISTORY_BOUNDARY_RECORD_BYTES: + return None + row = json.loads(line) + payload = row.get("payload") or {} + if (index == 0 and row.get("type") == "response_item" + and payload.get("type") == "message" and payload.get("role") == "user"): + continue + user = codex_rollout_user_message(payload) if row.get("type") == "event_msg" else None + if (user is None or not user.prompt + or is_codex_account_switch_message(user.raw_text) + or (user.turn_id and native_turn_id and user.turn_id != native_turn_id)): + return None + return datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")).timestamp() - 0.001 + except (OSError, ValueError, KeyError, TypeError, AttributeError): + pass + return None + + def codex_translate_history( path: str, tool_result_max: int, @@ -4022,6 +4064,8 @@ def codex_translate_history( start_offset: int = 0, end_offset: int | None = None, source_continuation: str | None = None, + source_turn_id: str | None = None, + segment_end_ts: float | None = None, snapshot_in_progress: bool = False, active_task_ids: set[str] | frozenset[str] | tuple[str, ...] = (), client_message_ids: dict[str, str] | None = None, @@ -4039,7 +4083,7 @@ def codex_translate_history( turn_open = False active_turn_id: str | None = None active_msg_id: str | None = None - pending_turn_id: str | None = None + pending_turn_id: str | None = source_turn_id turn_visible = False turn_text_visible = False turn_final_visible = False @@ -4051,6 +4095,7 @@ def codex_translate_history( cur_mid: str | None = None cur_channel = "unknown" last_ts = None + source_stamp = 0.0 pending_images: list = [] # input_image blocks seen before the next user_message pending_legacy_user_item_id: str | None = None pending_compactions: list[ @@ -4084,6 +4129,7 @@ def codex_translate_history( seen_process_items: set[str] = set() history_tools: dict[str, tuple[str, str, str | None, str | None, dict]] = {} seen_agent_messages: set[tuple[str, str, str]] = set() + seen_agent_item_ids: set[str] = set() seen_reasoning: set[tuple[str, str]] = set() def _ts(iso: str): @@ -4092,6 +4138,14 @@ def _ts(iso: str): except Exception: return None + def append_event(event) -> None: + # History is a projection of source time, never reconstruction time. + # Explicit timestamps (users, deferred messages and native terminals) + # win; other events inherit their current JSONL record's timestamp. + if "ts" not in event.model_fields_set: + event.ts = source_stamp + events.append(event) + def _stable_id(kind: str, line_no: int, raw_ts: str = "", identity=None) -> str: """Deterministic fallback for rollout records that carry no item id.""" stable_identity = str(identity or active_turn_id or "") @@ -4136,13 +4190,14 @@ def ensure_assistant( cur_mid = _history_id(item_id, "assistant", line_no, raw_ts) assistant_open = True cur_channel = channel - events.append(AssistantMsgStart( - message_id=cur_mid, channel=channel)) + append_event(AssistantMsgStart( + message_id=cur_mid, channel=channel, + ts=_ts(raw_ts) if _ts(raw_ts) is not None else source_stamp)) def close_assistant(**message_fields): nonlocal assistant_open, cur_mid, cur_channel if assistant_open and cur_mid: - events.append(AssistantMsgEnd( + append_event(AssistantMsgEnd( message_id=cur_mid, channel=cur_channel, **message_fields)) assistant_open = False cur_mid = None @@ -4163,7 +4218,7 @@ def append_compaction( ) if stamp is not None: event.ts = stamp - events.append(event) + append_event(event) turn_visible = True def flush_pending_compactions(target_owner: str | None) -> None: @@ -4197,18 +4252,19 @@ def upsert_tool_use( nonlocal turn_visible history_tools[tool_id] = ( tool, category, title, server, tool_input) - for event in reversed(events): - if isinstance(event, ToolUse) and event.tool_use_id == tool_id: - event.tool = tool - event.category = category - event.title = title - event.server = server - event.input = tool_input - seen_tool_uses.add(tool_id) - turn_visible = True - return + if tool_id in seen_tool_uses: + for event in reversed(events): + if isinstance(event, ToolUse) and event.tool_use_id == tool_id: + event.tool = tool + event.category = category + event.title = title + event.server = server + event.input = tool_input + seen_tool_uses.add(tool_id) + turn_visible = True + return ensure_assistant(line_no, raw_ts) - events.append(ToolUse( + append_event(ToolUse( message_id=cur_mid or "", tool_use_id=tool_id, tool=tool, @@ -4222,15 +4278,18 @@ def upsert_tool_use( def upsert_tool_result(result: ToolResult) -> None: nonlocal turn_visible - for index in range(len(events) - 1, -1, -1): - event = events[index] - if (isinstance(event, ToolResult) - and event.tool_use_id == result.tool_use_id): - events[index] = result - seen_tool_results.add(result.tool_use_id) - turn_visible = True - return - events.append(result) + if "ts" not in result.model_fields_set: + result.ts = source_stamp + if result.tool_use_id in seen_tool_results: + for index in range(len(events) - 1, -1, -1): + event = events[index] + if (isinstance(event, ToolResult) + and event.tool_use_id == result.tool_use_id): + events[index] = result + seen_tool_results.add(result.tool_use_id) + turn_visible = True + return + append_event(result) seen_tool_results.add(result.tool_use_id) turn_visible = True @@ -4264,7 +4323,7 @@ def materialize_pending_goal_turn() -> bool: user = UserMsg(msg_id=uid, prompt=prompt) if prompt_ts is not None: user.ts = prompt_ts - events.append(user) + append_event(user) turn_open = True turn_visible = False turn_text_visible = False @@ -4310,7 +4369,7 @@ def open_assistant_only_turn( # continuation. Bind it before its terminal arrives so history # cannot invent a message id or a parser-time start timestamp. active_msg_id = str(active_turn_id) - events.append(TurnBinding( + append_event(TurnBinding( msg_id=active_msg_id, turn_id=active_msg_id, ts=pending_task_started[1] or 0, )) @@ -4347,6 +4406,9 @@ def emit_agent_message( item_id=None, ) -> None: nonlocal turn_visible, turn_text_visible, turn_final_visible + native_id = item_id or payload.get("id") or payload.get("message_id") + if isinstance(native_id, str) and native_id in seen_agent_item_ids: + return open_assistant_only_turn() text = payload.get("message") or "" channel = _assistant_channel(payload.get("phase")) @@ -4356,8 +4418,10 @@ def emit_agent_message( if payload.get("delivery") == "async" else channel), text, ) - if not text or key in seen_agent_messages: + if not text or (native_id is None and key in seen_agent_messages): return + if isinstance(native_id, str): + seen_agent_item_ids.add(native_id) seen_agent_messages.add(key) close_assistant() ensure_assistant( @@ -4370,9 +4434,11 @@ def emit_agent_message( turn_text_visible = True if channel == "final" and payload.get("delivery") != "async": turn_final_visible = True - events.append(Delta( - message_id=cur_mid, text=text, channel=channel)) - close_assistant(**_async_message_fields(payload)) + append_event(Delta( + message_id=cur_mid, text=text, channel=channel, + ts=_ts(raw_ts) if _ts(raw_ts) is not None else source_stamp)) + close_assistant(ts=_ts(raw_ts) if _ts(raw_ts) is not None else source_stamp, + **_async_message_fields(payload)) def emit_completed_plan_answer( line_no: int, @@ -4391,7 +4457,7 @@ def emit_completed_plan_answer( channel="final", force_new=True, ) - events.append(Delta( + append_event(Delta( message_id=cur_mid, text=text, channel="final")) close_assistant() seen_agent_messages.add((turn_key, "final", text)) @@ -4460,7 +4526,7 @@ def close_turn( terminal_ts = completed_ts if completed_ts is not None else last_ts if terminal_ts is not None: te.ts = terminal_ts - events.append(te) + append_event(te) turn_open = False pending_turn_id = None active_turn_id = None @@ -4497,6 +4563,7 @@ def close_turn( p = d.get("payload") if isinstance(d.get("payload"), dict) else {} raw_ts = d.get("timestamp", "") ts = _ts(raw_ts) + source_stamp = ts if ts is not None else last_ts or 0.0 payload_type = p.get("type") paired_legacy_user_item_id = None if pending_legacy_user_item_id is not None: @@ -4694,6 +4761,7 @@ def close_turn( if steered_same_task or active_mid_task_steer: close_turn( "steered", 0, False, + completed_ts=ts - 0.001 if ts is not None else last_ts, authoritative_boundary=False) else: close_turn( @@ -4748,7 +4816,7 @@ def close_turn( um.images = pending_images if ts is not None: um.ts = ts - events.append(um) + append_event(um) turn_open = True turn_has_user = True turn_continuation_reason = None @@ -4777,7 +4845,7 @@ def close_turn( plan_tool_ids.add(tool_id) seen_tool_uses.add(tool_id) turn_visible = True - events.append(plan_event) + append_event(plan_event) else: ensure_assistant(line_no, raw_ts) tool, category, title, server = _hist_tool_presentation( @@ -4787,7 +4855,7 @@ def close_turn( if tool_id not in seen_tool_uses: seen_tool_uses.add(tool_id) turn_visible = True - events.append(ToolUse( + append_event(ToolUse( message_id=cur_mid or "", tool_use_id=tool_id, tool=tool, @@ -4811,7 +4879,7 @@ def close_turn( ensure_assistant(line_no, raw_ts) tool, category, title, server, hist_input = tool_meta seen_tool_uses.add(tool_id) - events.append(ToolUse( + append_event(ToolUse( message_id=cur_mid or "", tool_use_id=tool_id, tool=tool, input=hist_input, category=category, title=title, server=server)) @@ -4843,7 +4911,7 @@ def close_turn( raw_output, tool_result_max) exit_code = _history_exit_code(output) is_error = structured_error or _exit_is_error(output) - events.append(ToolResult( + append_event(ToolResult( tool_use_id=tool_id, content=output, is_error=is_error, @@ -4851,7 +4919,21 @@ def close_turn( status="failed" if is_error else "succeeded", exit_code=exit_code, )) - elif t == "event_msg" and payload_type == "exec_command_end": + elif t == "event_msg" and ( + payload_type == "exec_command_end" + or (payload_type == "item_completed" and isinstance(p.get("item"), dict) + and str(p["item"].get("type")).replace("_", "").lower() == "commandexecution") + ): + if payload_type == "item_completed": + native_turn = active_turn_id or pending_turn_id + if p.get("turn_id") is not None and p["turn_id"] != native_turn: + continue + item = p["item"] + p = {**item, "call_id": item.get("id"), + "parsed_cmd": item.get("parsed_cmd", item.get("commandActions")), + "process_id": item.get("process_id", item.get("processId")), + "aggregated_output": item.get("aggregated_output", item.get("aggregatedOutput")), + "exit_code": item.get("exit_code", item.get("exitCode"))} open_assistant_only_turn() tool_id = _history_id( p.get("call_id"), "tool", line_no, raw_ts) @@ -4892,7 +4974,9 @@ def close_turn( truncated=True if truncated else None, status=status, exit_code=exit_code, - duration_ms=_legacy_duration_ms(p.get("duration")), + duration_ms=(_duration_ms(p.get("durationMs")) + if p.get("durationMs") is not None + else _legacy_duration_ms(p.get("duration"))), )) elif t == "event_msg" and payload_type == "mcp_tool_call_end": open_assistant_only_turn() @@ -4942,7 +5026,7 @@ def close_turn( ) if image_event is not None: image_event.ts = ts if ts is not None else 0 - events.append(image_event) + append_event(image_event) turn_visible = True elif ( (t == "response_item" and str(payload_type).lower() == "filechange") @@ -4975,16 +5059,45 @@ def close_turn( elif t == "event_msg" and payload_type == "item_completed": item = p.get("item") if isinstance(p.get("item"), dict) else {} item_type = str(item.get("type") or "").replace("_", "").lower() - if item_type == "subagentactivity": + if item_type == "agentmessage": + # New rollouts persist the public message here, using + # PascalCase Text parts. Raw response_item messages also + # contain private compaction summaries and are NOT a + # substitute for this public lifecycle record. + text = item.get("text") + if not isinstance(text, str): + content = item.get("content") + text = "".join( + part["text"] for part in (content if isinstance(content, list) else []) + if isinstance(part, dict) + and str(part.get("type", "")).lower() == "text" + and isinstance(part.get("text"), str) + ) + emit_agent_message({ + **item, "message": text, + }, line_no, raw_ts, item.get("id")) + elif item_type == "contextcompaction": + marker = ( + _history_id(item.get("id"), "compaction", line_no, raw_ts), + ts, + _history_optional_turn_id(p.get("turn_id") or active_turn_id or pending_turn_id), + ) + if turn_open: + append_compaction(marker) + else: + pending_compactions.append(marker) + if len(pending_compactions) > _MAX_PENDING_HISTORY_COMPACTIONS: + del pending_compactions[0] + elif item_type == "subagentactivity": open_assistant_only_turn() - events.append(_subagent_event({ + append_event(_subagent_event({ **item, "agentThreadId": item.get("agentThreadId") or item.get("agent_thread_id"), "agentPath": item.get("agentPath") or item.get("agent_path"), }, _history_optional_turn_id(p.get("turn_id") or active_turn_id or pending_turn_id), True)) turn_visible = True elif item_type == "collabagenttoolcall": open_assistant_only_turn() - events.append(_collab_event({ + append_event(_collab_event({ **item, "receiverThreadIds": item.get("receiverThreadIds") or item.get("receiver_thread_ids"), "senderThreadId": item.get("senderThreadId") or item.get("sender_thread_id"), @@ -5003,7 +5116,7 @@ def close_turn( ) if item_id not in seen_process_items: seen_process_items.add(item_id) - events.append(ProcessEvent( + append_event(ProcessEvent( item_id=item_id, kind="plan", phase="end", @@ -5022,7 +5135,7 @@ def close_turn( if summary and key not in seen_reasoning: seen_reasoning.add(key) open_assistant_only_turn() - events.append(ProcessEvent( + append_event(ProcessEvent( item_id=_history_id( p.get("id"), "reasoning", line_no, raw_ts), kind="reasoning", @@ -5039,7 +5152,7 @@ def close_turn( if summary and key not in seen_reasoning: seen_reasoning.add(key) open_assistant_only_turn() - events.append(ProcessEvent( + append_event(ProcessEvent( item_id=_history_id( p.get("id") or p.get("event_id"), "reasoning", line_no, raw_ts), @@ -5063,7 +5176,7 @@ def close_turn( if tool_id not in seen_tool_uses: seen_tool_uses.add(tool_id) turn_visible = True - events.append(ToolUse( + append_event(ToolUse( message_id=cur_mid or "", tool_use_id=tool_id, tool="apply_patch", @@ -5083,7 +5196,7 @@ def close_turn( output, output_truncated = bounded_text( p.get("stdout") or p.get("stderr") or "", tool_result_max) - events.append(ToolResult( + append_event(ToolResult( tool_use_id=tool_id, content=output, is_error=not success, @@ -5106,7 +5219,7 @@ def close_turn( if tool_id not in seen_tool_uses: seen_tool_uses.add(tool_id) turn_visible = True - events.append(ToolUse( + append_event(ToolUse( message_id=cur_mid or "", tool_use_id=tool_id, tool="webSearch", @@ -5118,7 +5231,7 @@ def close_turn( )) if tool_id not in seen_tool_results: seen_tool_results.add(tool_id) - events.append(ToolResult( + append_event(ToolResult( tool_use_id=tool_id, content="", is_error=False, @@ -5132,7 +5245,7 @@ def close_turn( "agentThreadId": p.get("agent_thread_id"), "agentPath": p.get("agent_path"), } - events.append(_subagent_event( + append_event(_subagent_event( item, _history_optional_turn_id( active_turn_id or pending_turn_id), @@ -5172,7 +5285,7 @@ def close_turn( close_assistant() ensure_assistant( line_no, raw_ts, channel="final", force_new=True) - events.append(Delta( + append_event(Delta( message_id=cur_mid, text=last, channel="final")) close_assistant() seen_agent_messages.add((turn_key, "final", last)) @@ -5183,7 +5296,7 @@ def close_turn( if terminal_error is None: emit_completed_plan_answer(line_no, raw_ts) if terminal_error is not None: - events.append(Error( + append_event(Error( code=ERR_CC_CRASH, message=_provider_failure_message(terminal_error), msg_id=active_msg_id, @@ -5194,7 +5307,7 @@ def close_turn( close_turn("success", _duration(p), False, _completed_ts(p, ts), p.get("turn_id")) else: - events.append(Error( + append_event(Error( code=ERR_CC_CRASH, message=_EMPTY_COMPLETED_MESSAGE, msg_id=active_msg_id, @@ -5225,13 +5338,17 @@ def close_turn( # session_meta / world_state / token_count / private reasoning : skipped if ts is not None: last_ts = ts - if pending_agent_message is not None and not snapshot_in_progress: + if pending_agent_message is not None and (not snapshot_in_progress or segment_end_ts is not None): payload, pending_line, pending_ts = pending_agent_message emit_agent_message(payload, pending_line, pending_ts) # A file can be read while Codex is still appending the current turn. Close # only its current text block; deliberately omit TurnEnd so the reducer keeps # the turn not-done instead of fabricating a completed status. close_assistant() + if segment_end_ts is not None: + # A following user record closes this visible segment, not the native + # task. Never emit a native terminal id/fork point for this boundary. + close_turn("steered", 0, False, segment_end_ts, authoritative_boundary=False) return events, model diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py index 193312d7..0ede588c 100644 --- a/cc_remote/wrapper/history_store.py +++ b/cc_remote/wrapper/history_store.py @@ -69,7 +69,10 @@ # v42 replaces recovered text prefixes and bounds summary answer block counts. # v43 makes manual /compact a visible turn owning its native boundary. # v44 restores consumed human queued_command attachments as visible inputs. -_SCHEMA_VERSION = 44 +# v45 restores public AgentMessage records in new Codex rollouts and the +# native owner of source-window tails. Old tools-only projections must rebuild. +# v46 restores native commands, source clocks and closed segment envelopes. +_SCHEMA_VERSION = 46 _FINGERPRINT_SAMPLE_BYTES = 64 * 1024 _DEFAULT_MAX_ENTRIES = 128 _DEFAULT_MAX_BYTES = 64 * 1024 * 1024 @@ -1465,7 +1468,7 @@ def _ensure_schema(self) -> None: for table in ("history_pages", "history_turn_details"): connection.execute( f"DELETE FROM {table} WHERE engine='codex'") - elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43): + elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45): # The independent v22-v44 invalidations above suffice. pass elif current not in (0, _SCHEMA_VERSION): @@ -1648,6 +1651,12 @@ def _ensure_schema(self) -> None: ) """ ) + if 0 < current < 46: + # v46 reads public command lifecycle records and source clocks in + # Codex rollouts. Rebuild only the derived Codex projections; + # native sources, other engines and binary assets stay valid. + for table in ("history_pages", "history_turn_details"): + connection.execute(f"DELETE FROM {table} WHERE engine='codex'") if current != _SCHEMA_VERSION: connection.execute(f"PRAGMA user_version={_SCHEMA_VERSION}") try: diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index 56e92299..f14bf70b 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -321,6 +321,7 @@ codex_history_file_changes, codex_history_process_append, codex_history_window_info, + codex_next_user_boundary_ts, codex_native_rollback_turns, codex_translate_history, ) @@ -328,9 +329,14 @@ CodexHistoryCursorError, CodexHistoryInvalidResponse, CodexHistoryPage, + CodexHistoryProjectionTooLarge, CodexHistoryUnsupported, CodexOfficialHistory, ) +from cc_remote.wrapper.codex_detail_pages import ( + CodexDetailPages, CodexDetailCursorExpired, PREFIX as CODEX_DETAIL_CURSOR_PREFIX, +) +from cc_remote.wrapper.btw_history import BtwHistory from cc_remote.wrapper.codex_sessions import ( CODEX_EXACT_CATALOG_MAX_IDS, list_codex_sessions, codex_exact_catalog_rows, codex_session_cwd, @@ -1724,12 +1730,10 @@ def _apply_codex_process_clocks( if ( turn.get("processDetailState") == "none" and turn.get("done") is True - and not turn.get("forkPointId") ): - # A completed segment without the native terminal/fork belongs to - # a steer boundary. An acceptance-time clock cannot contradict its - # exact empty source projection. The enclosing task's own clock can - # still recover work omitted from an opaque/compacted history tail. + # A clock proves activity, not public process content. Exact empty + # segments stay empty even when they own the native terminal. Opaque + # or bounded projections use unknown/present instead of none. continue visible_id = turn.get("id") client_message_id = turn.get("clientMsgId") @@ -1747,6 +1751,9 @@ def _apply_codex_process_clocks( ) if started_ms is None: continue + if (isinstance(turn.get("ts"), int) and started_ms < turn["ts"] + or isinstance(turn.get("doneTs"), int) and started_ms > turn["doneTs"]): + continue current = turn.get("processStartedTs") turn["processStartedTs"] = ( min(current, started_ms) @@ -2392,6 +2399,7 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): recover_user=self._recover_official_codex_user, recover_users=self._recover_official_codex_users, ) + self._codex_detail_pages = CodexDetailPages() # In-memory at-most-once window for client retries. The outer and inner # OrderedDicts are both bounded; wrapper process restart intentionally # resets this window (documented residual risk, not durable exactly-once). @@ -10399,6 +10407,8 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None: ) if is_downstream(msg): msg.seq = ctx.next_seq() + if ctx.btw: + self._btw_history(ctx).observe(msg) ctx.buffer.append(msg) self._observe_active_turn_binding(ctx, msg) live = ( @@ -13247,6 +13257,21 @@ async def _repair_codex_projection_after_overflow( if not sid: return try: + if ctx.btw: + # Ephemeral native threads reject both persisted-history APIs. + # Their private presentation snapshot is captured before the + # lossy queue and never goes through a normal session route. + if not ctx.owner_client_id: + return + async with ctx.emit_lock: + completions = getattr(ctx.sdk, "btw_completions", None) + if completions is not None and turn_id in completions.truncated: + self._btw_history(ctx).truncated = True + for frame in self._btw_replay(ctx): + await self.transport.send(frame.model_copy(update={ + "sid": ctx.key, "owner_id": ctx.owner_client_id, + })) + return await self._push_mirrored_history(sid) log.info( "codex overflow projection repaired", @@ -14528,6 +14553,12 @@ async def _build_history_source( and source_window_boundary_offset is not None else None ), + source_turn_id=source_window_native_turn_id, + segment_end_ts=(await asyncio.to_thread( + codex_next_user_boundary_ts, path, end_offset, + source_window.newest_native_turn_id, + end_offset=source_fingerprint.size if source_fingerprint is not None else None, + ) if before is not None else None), snapshot_in_progress=( in_progress and before is None ), @@ -14585,6 +14616,12 @@ async def _build_history_source( source_window_process_started_ms, ) events.insert(0, recovered_user) + if source_window_native_turn_id is not None: + events.insert(1, TurnBinding( + msg_id=recovered_user.msg_id, + turn_id=source_window_native_turn_id, + ts=recovered_user.ts, + )) except Exception as e: log.warning("codex get_history failed", session_id=sid, error=str(e)) history_error = "历史暂时不可用,请稍后重试" @@ -15719,6 +15756,19 @@ async def _build_official_codex_history( source=source_before, **alias_kwargs, ) + except CodexHistoryProjectionTooLarge as exc: + # The source already proved user segments missing from the summary. + # A bounded native item read cannot repair a very long steered turn. + # Switch the whole newest-page family only against that same frozen + # source; an older official cursor must never enter rollout paging. + if before is None and _minimum_user_segments and source_before is not None: + source_after = await asyncio.to_thread( + HistorySourceFingerprint.capture, source_before.path) + if source_after == source_before: + raise _CodexOfficialProjectionIncomplete( + "bounded official history omits source-proven user segments", + ) from exc + raise finally: take_identities = getattr( self._codex_history, @@ -16587,6 +16637,35 @@ async def send( or watch.get("engine") == "codex" ) raw_before = getattr(cmd, "before", None) + if is_codex and (raw_before is None or raw_before.startswith(CODEX_DETAIL_CURSOR_PREFIX)): + try: + path = await asyncio.to_thread(self._codex_rollout_for_wire, sid) + if path: + source_page = await asyncio.to_thread( + self._codex_detail_pages.read, + path, sid, cmd.turn_id, revision, + before=raw_before, limit=getattr(cmd, "limit", 192), + window_bytes=self.cfg.codex_history_window_max_bytes, + max_bytes=min(8 * 1024 * 1024, + max(512 * 1024, self.cfg.ws_max_size_bytes // 2)), + tool_result_max=self.cfg.tool_result_max, + paginate=_turn_detail_page, + ) + if self._history_revision(sid) != revision: + raise CodexDetailCursorExpired("history revision changed") + if source_page is not None: + page, has_more, oldest, has_newer, newer = source_page + return await send(page, has_more=has_more, oldest_cursor=oldest, + has_newer=has_newer, newer_cursor=newer) + elif raw_before is not None: + raise CodexDetailCursorExpired("rollout is unavailable") + except CodexDetailCursorExpired: + return await send(error="详细过程已更新,请重新加载该轮", reset_required=True) + except (OSError, ValueError): + if raw_before is not None: + return await send(error="详细过程已更新,请重新加载该轮", reset_required=True) + # An initial read can still use the ordinary official/indexed + # path when the optional local rollout is unavailable. try: snapshot_cursor = _decode_turn_detail_snapshot_cursor(raw_before) except ValueError: @@ -20123,6 +20202,32 @@ async def _handle_close_btw(self, cmd): log.info("btw closed", btw_sid=sid) return close_event + def _btw_history(self, ctx: SessionContext) -> BtwHistory: + if ctx.btw_history is None: + ctx.btw_history = BtwHistory( + min(self.cfg.ring_max_bytes, 24 * 1024 * 1024), + min(self.cfg.ring_max_events, 10000), + ) + # Compatibility for an already-populated ring/test fixture. New + # forks enter here on their first emit, before any token eviction. + for _, event in ctx.buffer._buf: + ctx.btw_history.observe(event) + return ctx.btw_history + + def _btw_replay(self, ctx: SessionContext) -> list: + history = self._btw_history(ctx) + completions = getattr(ctx.sdk, "btw_completions", None) + if completions is not None: + for native_id in tuple(completions.turns): + history.repair(native_id, completions.snapshots(native_id), + item_order=completions.item_order(native_id)) + return history.replay( + tail_seq=ctx.buffer.tail_seq, generation=self.instance_id, + max_bytes=self.BTW_REPLAY_MAX_BYTES, + max_events=self.BTW_REPLAY_MAX_EVENTS, + turn_usage=ctx.buffer.latest_turn_usage(), + ) + async def _handle_sync_btw(self, cmd: SyncBtw): """Hydrate one selected side chat without replaying every BTW on Hello.""" client_id = getattr(cmd, "client_id", None) @@ -20163,21 +20268,7 @@ async def send(message) -> None: await self._close_pending_ask_locked( ctx, ask_id, reason="timeout") - same_generation = cmd.generation == self.instance_id - cursor = cmd.cursor if same_generation else 0 - frames = ctx.buffer.replay_from_bounded( - cursor, - max_bytes=self.BTW_REPLAY_MAX_BYTES, - max_events=self.BTW_REPLAY_MAX_EVENTS, - rebuild=not same_generation, - generation=self.instance_id, - ) - frames = self._reseed_active_binding_for_hello( - ctx, - frames, - cursor=cursor, - same_generation=same_generation, - ) + frames = self._btw_replay(ctx) for frame in frames: if not self._hello_replay_frame_visible(frame, client_id): continue diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index c1f77401..bd861118 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -35,7 +35,7 @@ from cc_remote.config import WrapperConfig from cc_remote.claude_steering import ( - ClaudeSteerRejected, PendingSteers, background_end_ids, is_managed_input, steer_message, + ClaudeSteerRejected, PendingSteers, background_end_ids, is_human_result, is_managed_input, steer_message, ) from cc_remote.log import logger from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER @@ -1071,6 +1071,7 @@ async def query(self, prompt) -> None: self.next_turn_id = None self._turn_active = True self._managed_input_seen = False + self._steers.begin_turn() try: await client.query(prompt) except BaseException: @@ -1568,7 +1569,13 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: self._managed_input_seen or is_managed_input(data, pending_compact=( self._pending_compact and self._message_route_owner != "background"))) - data = self._steers.annotate(data, managed_active=managed_active) + data = self._steers.annotate( + data, managed_active=managed_active, + # An older service still owns its origin-only terminal + # ledger. Do not issue commits it cannot acknowledge. + result_receipts=(service_seq is None or getattr( + client, "description", {}).get("human_result_receipts") is True), + ) steer = data.get("__cc_steer") if parse_raw else None intermediate = bool(parse_raw and data.get("__cc_steer_intermediate")) message = self._parse_compat_message(data) if parse_raw else data @@ -1675,11 +1682,12 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: self._activate_pending_turn_route() self._message_route_owner = owner elif isinstance(message, ResultMessage): - # Result.origin is the authoritative boundary in the - # pinned SDK. A non-human result closes only the injected - # turn; the browser query remains pending for its later - # human/legacy-unattributed result on the same stream. - if origin_kind is not None and origin_kind != "human": + # Raw consumption receipts survive SDK parsing through + # the shared journal annotation. Without an exact match, + # an unrelated task Result cannot complete human work. + human_result = (is_human_result(data) if parse_raw + else origin_kind in (None, "human")) + if not human_result: owner = "background" elif (getattr(message, "_cc_background_ends", ()) and not self._managed_input_seen): diff --git a/cc_remote/wrapper/session_ctx.py b/cc_remote/wrapper/session_ctx.py index b6328604..5992a15d 100644 --- a/cc_remote/wrapper/session_ctx.py +++ b/cc_remote/wrapper/session_ctx.py @@ -19,6 +19,7 @@ from cc_remote.protocol import AskUser, BackgroundProcessItem, State from cc_remote.wrapper.ringbuffer import RingBuffer +from cc_remote.wrapper.btw_history import BtwHistory from cc_remote.wrapper.sdk import SdkHandle from cc_remote.wrapper.stream import StreamTranslator from cc_remote.wrapper.turn_changes import TurnChangeTracker @@ -234,6 +235,7 @@ class SessionContext: # inherits its context. Never persisted, excluded from the session list, and # discarded on close. Its turns reuse the normal _run_turn path. btw: bool = False + btw_history: BtwHistory | None = None claude_service_background_replay: object | None = None parent_sid: Optional[str] = None # Relay-authenticated account identity for a private side chat. The legacy diff --git a/docs/claude-session-service.md b/docs/claude-session-service.md index fbb19f68..d94dc4ee 100644 --- a/docs/claude-session-service.md +++ b/docs/claude-session-service.md @@ -73,6 +73,16 @@ retain their independent lifetime: after the main response ends, normal prompts are accepted while those children continue, and later native activity can start another main continuation. +Owners advertising `human_result_receipts` also recognize a Result whose +`user_message_uuid` / `user_message_uuids` includes the latest consumed human +input, even when native Code labels its origin `task-notification`. They journal +that exact ownership before SDK parsing. Old/foreign receipts and child Results +cannot finish the current input, and queued inputs still require their own echo. +Older owners retain their original terminal ledger; a Wrapper-only upgrade +does not activate this fix or make an unacknowledgeable terminal commit. Upgrade +the service with the drain procedure below. Never resubmit an accepted query or +restart a live owner just to clear its running indicator. + Native Code can also absorb a queued task notification between tool batches in an already-running human response. Its JSONL entry is a `queued_command` attachment; the SDK projects it as a replayed `UserMessage` with non-human origin diff --git a/tests/test_btw_history.py b/tests/test_btw_history.py new file mode 100644 index 00000000..58e63768 --- /dev/null +++ b/tests/test_btw_history.py @@ -0,0 +1,275 @@ +"""Ephemeral presentation recovery without a native history API/model call.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from cc_remote.protocol import ( + AssistantMsgEnd, Delta, Error, ProcessEvent, SyncBtw, ToolDelta, TurnBinding, + TurnDiff, TurnEnd, TurnPlan, TurnResult, + TurnSteered, UserMsg, +) +from cc_remote.wrapper.btw_history import BtwHistory, CodexBtwCompletions +from cc_remote.wrapper.codex_handle import CodexHandle +from cc_remote.wrapper.ringbuffer import RingBuffer +from tests.test_multisession import _mk_ctx, _mk_machine + + +def replay(history, *, max_bytes=1_000_000, max_events=1000): + return history.replay(tail_seq=100, generation="g", max_bytes=max_bytes, + max_events=max_events, turn_usage=[]) + + +def seed(history, user="human", native="native"): + history.observe(UserMsg(msg_id=user, prompt="read only")) + history.observe(TurnBinding(msg_id=user, turn_id=native)) + + +def completed(index, text=None): + return {"method": "item/completed", "params": { + "threadId": "fork", "turnId": "native", "item": { + "id": f"item-{index}", "type": "agentMessage", + "phase": "commentary", "text": text or f"message {index}", + }, + }} + + +def test_token_ring_eviction_does_not_cut_message_or_lose_user_boundary(): + history = BtwHistory(1_000_000, 100) + ring = RingBuffer(4, 4096) + frames = [UserMsg(msg_id="human", prompt="inspect"), + TurnBinding(msg_id="human", turn_id="native")] + frames += [Delta(message_id="reply", text=str(i) + ",", channel="commentary") + for i in range(100)] + frames += [AssistantMsgEnd(message_id="reply", channel="commentary"), + TurnEnd(turn_id="native", result=TurnResult( + subtype="success", duration_ms=123, is_error=False))] + for seq, event in enumerate(frames, 1): + event.seq = seq + ring.append(event) + history.observe(event) + assert ring.head_seq > 100 + restored = replay(history) + assert restored[0].rebuild and not restored[0].truncated + assert [e.msg_id for e in restored if e.type == "user_msg"] == ["human"] + assert [e.text for e in restored if e.type == "delta"] == [ + "".join(str(i) + "," for i in range(100))] + assert all(e.seq is None for e in restored) + assert len(restored) == 7 + + +def test_replay_budget_keeps_whole_items_and_their_exact_user(): + history = BtwHistory(20_000, 100) + seed(history) + history.observe(Delta(message_id="old", text="x" * 2000)) + history.observe(Delta(message_id="new", text="whole newest message")) + history.observe(AssistantMsgEnd(message_id="new")) + restored = replay(history, max_bytes=1300) + assert restored[0].truncated + assert restored[1].type == "user_msg" and restored[2].type == "turn_binding" + assert [e.text for e in restored if e.type == "delta"] == ["whole newest message"] + tiny = BtwHistory(1500, 2) + seed(tiny) + for index in range(20): + tiny.observe(Delta(message_id=str(index), text="safe")) + assert tiny.truncated and tiny._bytes <= 1500 and tiny._items <= 2 + assert replay(tiny)[1].type == "user_msg" + + +def test_native_completions_repair_pre_gap_text_and_missing_items(): + cfg = SimpleNamespace(cc_cwd="/tmp", tool_result_max=1000, + turn_reader_queue_cap=1) + handle = CodexHandle(cfg) + handle.btw_completions = CodexBtwCompletions(1_000_000, 100, 1000) + handle.btw_completions.initial_owner = "human" + handle._open_managed_stream() + handle.turn_id = "native" + history = BtwHistory(1_000_000, 100) + seed(history) + history.observe(Delta(message_id="item-0", text=",only the tail")) + history.observe(AssistantMsgEnd(message_id="item-0")) + for index in range(90): + handle._queue_managed_notification(completed(index)) + assert handle._managed_overflow + history.repair("native", handle.btw_completions.snapshots("native")) + values = [e.text for e in replay(history) if isinstance(e, Delta)] + assert values == [f"message {index}" for index in range(90)] + # A delayed pre-terminal queue delta cannot append to a complete snapshot. + history.observe(Delta(message_id="item-0", text="late suffix")) + assert [e.text for e in replay(history) if isinstance(e, Delta)] == values + + +def test_repair_never_assigns_an_ambiguous_steer_to_another_input(): + history = BtwHistory(1_000_000, 100) + seed(history, "first") + history.observe(TurnSteered(msg_id="second", turn_id="native", prompt="next")) + native = CodexBtwCompletions(1_000_000, 100, 1000) + native.initial_owner = "first" + native.observe(completed(1, "belongs to first")) + native.initial_owner = "second" + native.owners["native"] = "second" + native.observe(completed(2, "belongs to second")) + native.owners["native"] = None + native.observe(completed(3, "ambiguous")) + history.repair("native", native.snapshots("native")) + assert list(history.turns["first"].items) == ["message:item-1"] + assert list(history.turns["second"].items) == ["message:item-2"] + assert "ambiguous" not in str(replay(history)) + + +def test_duplicate_native_user_lifecycle_keeps_exact_completed_item_ownership(): + native = CodexBtwCompletions(10000, 10, 1000) + native.initial_owner = "human" + user = {"method": "item/started", "params": {"turnId": "native", "item": { + "type": "userMessage", "id": "native-user", "content": [{"type": "text", "text": "inspect"}], + }}} + native.observe(user) + native.observe({**user, "method": "item/completed"}) + native.observe(completed(1)) + assert native.snapshots("native")[0][0] == "human" + user["params"]["item"]["id"] = "another-user" + native.observe(user) + native.observe(completed(2)) + assert native.snapshots("native")[1][0] is None + + +def test_native_recovery_cache_is_bounded_and_excludes_private_reasoning(): + native = CodexBtwCompletions(5000, 3, 1000) + for index in range(50): + native.observe(completed(index, "x" * 300)) + assert native._bytes <= 5000 + assert sum(len(rows) for rows in native.turns.values()) <= 3 + event = completed(99) + event["params"]["item"] = { + "id": "private", "type": "reasoning", "summary": [], + "content": [{"text": "PRIVATE SECRET"}], "encryptedContent": "PRIVATE SECRET", + } + native.observe(event) + assert "PRIVATE SECRET" not in str(native.snapshots("native")) + + +def test_partial_completion_cache_does_not_reorder_the_live_prefix(): + history = BtwHistory(1_000_000, 100) + seed(history) + for index in [0, 1, 3]: + history.observe(Delta(message_id=f"item-{index}", text=f"message {index}")) + native = CodexBtwCompletions(10000, 3, 1000) + native.initial_owner = "human" + for index in range(5): + native.observe(completed(index)) + history.repair("native", native.snapshots("native")) + assert [e.text for e in replay(history) if e.type == "delta"] == [ + f"message {index}" for index in range(5)] + + +def test_repair_uses_running_items_as_order_anchors_without_replacing_their_text(): + history = BtwHistory(1_000_000, 100) + seed(history) + history.observe(Delta(message_id="item-0", text="first")) + history.observe(Delta(message_id="item-3", text="still streaming")) + native = CodexBtwCompletions(10000, 10, 1000) + native.initial_owner = "human" + for index in range(5): + item = completed(index) + if index == 3: + item["method"] = "item/started" + native.observe(item) + history.repair("native", native.snapshots("native"), item_order=native.item_order("native")) + assert [e.text for e in replay(history) if e.type == "delta"] == [ + "message 0", "message 1", "message 2", "still streaming", "message 4"] + + +def test_repair_retains_translator_normalized_ids_outside_native_order_map(): + history = BtwHistory(10000, 10) + seed(history) + history.repair("native", [("human", [Delta(message_id="normalized", text="whole")])], + item_order=["native-id"]) + assert [e.text for e in replay(history) if e.type == "delta"] == ["whole"] + + +def test_process_and_large_tool_streams_replay_the_same_content(): + history = BtwHistory(3_000_000, 100) + seed(history) + for delta in ["first ", "second"]: + history.observe(ProcessEvent(item_id="process", kind="command", phase="update", + title="Command", append_to="output", delta=delta)) + chunks = ["a" * 400_000, "b" * 200_000, "c" * 10_000] + for delta in chunks: + history.observe(ToolDelta(tool_use_id="tool", stream="output", delta=delta)) + restored = replay(history, max_bytes=3_000_000) + process = next(e for e in restored if e.type == "process") + assert process.output == "first second" and process.append_to is None + assert "".join(e.delta for e in restored if e.type == "tool_delta") == "".join(chunks) + assert all(len(e.delta) <= 512 * 1024 for e in restored if e.type == "tool_delta") + + +def test_item_snapshot_retains_plan_diff_and_exact_turn_error(): + history = BtwHistory(10000, 100) + seed(history) + history.observe(TurnPlan(item_id="plan", turn_id="native", plan=[])) + history.observe(TurnDiff(item_id="diff", turn_id="native", diff="a diff")) + history.observe(Error(msg_id="human", code="internal", message="failed")) + history.observe(Error(msg_id="unaccepted-input", code="internal", message="unrelated")) + restored = replay(history) + assert any(e.type == "turn_plan" for e in restored) + assert any(e.type == "turn_diff" and e.diff == "a diff" for e in restored) + assert [e.message for e in restored if e.type == "error"] == ["failed"] + + +def test_evicted_item_does_not_reappear_as_a_mid_sentence_fragment(): + history = BtwHistory(1500, 10) + seed(history) + history.observe(Delta(message_id="big", text="prefix" * 1000)) + history.observe(Delta(message_id="big", text=",only a suffix")) + history.observe(AssistantMsgEnd(message_id="big")) + history.observe(Delta(message_id="new", text="A complete new item.")) + restored = replay(history) + assert restored[0].truncated + assert [e.text for e in restored if e.type == "delta"] == ["A complete new item."] + + +def test_long_stream_coalesces_bounded_chunks_and_replace_discards_old_text(): + history = BtwHistory(1_000_000, 10) + seed(history) + for _ in range(5000): + history.observe(Delta(message_id="stream", text="x" * 100)) + text = [e.text for e in replay(history) if e.type == "delta"] + assert "".join(text) == "x" * 500_000 + assert len(text) < 20 and max(map(len, text)) <= 32 * 1024 + history.observe(Delta(message_id="stream", text="replacement", replace=True)) + history.observe(Delta(message_id="stream", text=" complete")) + assert [e.text for e in replay(history) if e.type == "delta"] == ["replacement complete"] + assert history._bytes < 2000 + + +def test_btw_overflow_and_sync_use_private_snapshots_without_native_history(): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("btw-private") + ctx.engine = "codex" + ctx.btw = ctx.btw_announced = True + ctx.owner_client_id = "owner" + ctx.sdk = SimpleNamespace(btw_completions=CodexBtwCompletions(50000, 100, 1000)) + ctx.sdk.btw_completions.initial_owner = "human" + machine.sessions[ctx.key] = ctx + machine._push_mirrored_history = AsyncMock(side_effect=AssertionError("durable read")) + for frame in [UserMsg(msg_id="human", prompt="inspect"), + TurnBinding(msg_id="human", turn_id="native"), + Delta(message_id="item-1", text="tail")]: + await machine._emit(ctx, frame) + ctx.sdk.btw_completions.observe(completed(1, "full reply")) + transport.sent.clear() + await machine._repair_codex_projection_after_overflow(ctx, "native") + assert all(e.owner_id == "owner" and e.sid == ctx.key for e in transport.sent) + assert any(e.type == "delta" and e.text == "full reply" for e in transport.sent) + transport.sent.clear() + await machine._handle_sync_btw(SyncBtw( + sid=ctx.key, client_id="new-tab", owner_id="owner", cursor=999999, + generation=machine.instance_id)) + assert any(e.type == "user_msg" and e.msg_id == "human" for e in transport.sent) + assert all(e.to == "new-tab" and e.owner_id == "owner" for e in transport.sent) + machine._push_mirrored_history.assert_not_called() + transport.sent.clear() + ctx.owner_client_id = None + await machine._repair_codex_projection_after_overflow(ctx, "native") + assert not transport.sent + asyncio.run(run()) diff --git a/tests/test_claude_native_task_merge.py b/tests/test_claude_native_task_merge.py index 03a99dc9..bc5deb2c 100644 --- a/tests/test_claude_native_task_merge.py +++ b/tests/test_claude_native_task_merge.py @@ -71,7 +71,8 @@ def assert_one_response(events): @pytest.mark.asyncio @pytest.mark.parametrize("persistent", [False, True]) @pytest.mark.parametrize("stop_reason", ["end_turn", None]) -async def test_absorbed_notifications_keep_one_main_response_and_accept_next_prompt(persistent, stop_reason): +@pytest.mark.parametrize("receipt", [False, True]) +async def test_absorbed_notifications_keep_one_main_response_and_accept_next_prompt(persistent, stop_reason, receipt): async with environment() as (service, attach): client = await attach() if persistent else NativeClient() worker = service.sessions[client.id] if persistent else None @@ -88,7 +89,11 @@ async def test_absorbed_notifications_keep_one_main_response_and_accept_next_pro runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "check both")) inputs = native.prompts if persistent else native.inputs await until(lambda: len(inputs) == 1) - for frame in response_frames(stop_reason): + frames = response_frames(stop_reason) + if receipt: + frames[-1].update(origin={"kind": "task-notification"}, + user_message_uuid=HUMAN, user_message_uuids=[HUMAN]) + for frame in frames: await native.queue.put(frame) await asyncio.wait_for(runner, 3) await until(lambda: sdk._background_callbacks_pending == 0 and ctx.state == "idle") @@ -169,6 +174,7 @@ async def project(message, _turn): try: await sdk.query("check both") frames = response_frames("end_turn") + frames[-1].update(origin={"kind": "task-notification"}, user_message_uuids=[HUMAN]) frames[2:2] = [ task_input("channel-input", {"kind": "channel"}), assistant("channel-answer", [{"type": "text", "text": "channel report"}]), diff --git a/tests/test_claude_queued_prompt_history.py b/tests/test_claude_queued_prompt_history.py index 1ca467c9..c2ad8048 100644 --- a/tests/test_claude_queued_prompt_history.py +++ b/tests/test_claude_queued_prompt_history.py @@ -184,7 +184,7 @@ def test_only_accepted_humans_on_active_ancestry_are_restored(tmp_path, compact) "old", "human", "steer-one", "steer-two"] -def test_v43_rebuilds_claude_projection_preserving_other_engines_and_assets(tmp_path): +def test_v43_migration_rebuilds_projections_preserving_dsh_and_assets(tmp_path): path = tmp_path / f"{SID}.jsonl" write(path, transcript(True)) store = HistoryIndexStore(tmp_path / "index") @@ -199,8 +199,9 @@ def test_v43_rebuilds_claude_projection_preserving_other_engines_and_assets(tmp_ db.execute("PRAGMA user_version=43") reopened = HistoryIndexStore(tmp_path / "index") assert reopened.get_page(SID, "claude", fingerprint, before=None, limit=4) is None - for engine in ("codex", "dsh"): - assert reopened.get_page(SID, engine, fingerprint, before=None, limit=4) is not None + # The later v45 migration also refreshes newer Codex message projections. + assert reopened.get_page(SID, "codex", fingerprint, before=None, limit=4) is None + assert reopened.get_page(SID, "dsh", fingerprint, before=None, limit=4) is not None with sqlite3.connect(store.path) as db: assert db.execute("SELECT count(*) FROM claude_compact_records").fetchone()[0] == 0 assert db.execute("SELECT count(*) FROM history_image_assets").fetchone()[0] == 3 diff --git a/tests/test_claude_steering.py b/tests/test_claude_steering.py index 2976b543..c284b21c 100644 --- a/tests/test_claude_steering.py +++ b/tests/test_claude_steering.py @@ -72,6 +72,93 @@ def requesting(uid="request"): return {"type": "system", "subtype": "status", "status": "requesting", "uuid": uid} +def test_result_receipt_requires_latest_consumed_input_and_preserves_pending_steers(): + from cc_remote.claude_steering import is_human_result + + steers = PendingSteers() + steers.annotate(user("first"), managed_active=True) + steers.add("second", {"id": "second-remote"}) + value = steers.annotate({**result(), "origin": ORIGIN, + "user_message_uuids": ["first"]}, managed_active=True) + assert is_human_result(value) and value["__cc_steer_intermediate"] + assert "second" in steers.pending + steers.annotate(user("second"), managed_active=True) + for fields in ({"user_message_uuid": "first"}, + {"user_message_uuids": ["foreign"]}, + {"user_message_uuid": "second", "parent_tool_use_id": "child"}): + value = steers.annotate({**result(), "origin": ORIGIN, **fields}, managed_active=True) + assert not is_human_result(value) + + value = steers.annotate({**result(), "origin": ORIGIN, + "user_message_uuids": ["first", "second"]}, managed_active=True) + assert is_human_result(value) and not value.get("__cc_steer_intermediate") + # A duplicate receipt or the next task-only Result cannot claim a new query. + steers.begin_turn() + for origin in (None, ORIGIN): + value = steers.annotate({**result(), "origin": origin, "user_message_uuid": "second"}) + assert not is_human_result(value) + + +def test_receipts_do_not_change_older_service_terminal_ledger(): + from cc_remote.claude_steering import is_human_result + + steers = PendingSteers() + steers.annotate(user("human"), managed_active=True) + value = steers.annotate({**result(), "origin": ORIGIN, "user_message_uuid": "human"}, + managed_active=True, result_receipts=False) + assert "__cc_managed_result" not in value and not is_human_result(value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("persistent", [False, True]) +async def test_task_origin_result_consumes_exact_steer_and_commits_original_root(persistent): + async with environment() as (service, attach): + client = await attach() if persistent else NativeClient() + worker = service.sessions[client.id] if persistent else None + native = worker.client if worker else client + sdk = SdkHandle(WrapperConfig(turn_reader_queue_cap=1)) + sdk.client = client + sdk._start_message_pump() + consumer = None + try: + sdk.next_turn_id = "root" + await sdk.query("first") + + async def read(): + return [m async for m in sdk.receive_response()] + + consumer = asyncio.create_task(read()) + await native.queue.put(user("human", "first")) + await until(lambda: sdk._managed_input_seen) + await sdk.steer("guide", native_id="native-guide", metadata={"id": "guide"}) + await native.queue.put({**result(), "origin": ORIGIN, "user_message_uuid": "human"}) + await native.queue.put(user("native-guide", "guide")) + await until(lambda: not sdk._steers.pending) + assert not consumer.done() + if worker: + assert worker.terminal_seq is None and worker.turn["id"] == "root" + await native.queue.put({**result(), "origin": ORIGIN, + "user_message_uuids": ["native-guide"], "queued_turn_count": 0}) + messages = await asyncio.wait_for(consumer, 3) + assert isinstance(messages[-1], ResultMessage) + await sdk.ack_service_message(messages[-1], turn_id="root") + sdk.release_background_messages() + if worker: + assert worker.turn is None + sdk.next_turn_id = "next" + await sdk.query("next") + await native.queue.put(user("next-native", "next")) + await native.queue.put(result()) + messages = await asyncio.wait_for(read(), 3) + await sdk.ack_service_message(messages[-1], turn_id="next") + assert native.interrupts == 0 + finally: + if consumer: + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) + await sdk._stop_message_pump() + + @pytest.mark.asyncio @pytest.mark.parametrize("persistent", [False, True]) @pytest.mark.parametrize("finish", ["result", "steer", "stop"]) diff --git a/tests/test_codex_detail_pages.py b/tests/test_codex_detail_pages.py new file mode 100644 index 00000000..7f577284 --- /dev/null +++ b/tests/test_codex_detail_pages.py @@ -0,0 +1,206 @@ +"""Read-only long-turn paging: source windows, display pages, and cursor scope.""" +import json +import asyncio +from types import SimpleNamespace + +import pytest + +from cc_remote.wrapper.codex_detail_pages import CodexDetailPages, CodexDetailCursorExpired +from cc_remote.wrapper.machine import _turn_detail_page +from tests.test_multisession import _mk_ctx, _mk_machine + + +def _event(payload): + return {"type": "event_msg", "payload": payload} + + +def _user(native, visible, prompt): + return [ + _event({"type": "task_started", "turn_id": native}), + _event({"type": "item_completed", "turn_id": native, "item": { + "type": "UserMessage", "id": visible, + "content": [{"type": "text", "text": prompt}], + }}), + ] + + +def _message(index, native="native-long"): + return _event({"type": "item_completed", "turn_id": native, "item": { + "type": "AgentMessage", "id": f"progress-{index}", "phase": "commentary", + "content": [{"type": "Text", "text": f"Public progress {index}"}], + }}) + + +def _write(path, rows, mode="w"): + with path.open(mode) as stream: + for row in rows: + stream.write(json.dumps(row) + "\n") + + +def _semantic(page): + return ([{k: v for k, v in event.items() if k != "ts"} for event in page[0]], *page[1:]) + + +def _long_rollout(path, *, newer=False): + rows = _user("native-old", "user-old", "Earlier question") + rows += [_event({"type": "task_complete", "turn_id": "native-old"})] + rows += _user("native-long", "user-long", "The long question") + for index in range(40): + rows += [_message(index), {"type": "compacted", "payload": { + "message": "PRIVATE SUMMARY", "replacement_history": ["x" * 90_000], + }}] + if newer: + rows += [_event({"type": "task_complete", "turn_id": "native-long"})] + rows += _user("native-new", "user-new", "Later question") + [_message("new", "native-new")] + _write(path, rows) + + +def _read(pager, path, before=None, **overrides): + args = dict(before=before, limit=3, window_bytes=1024 * 1024, + max_bytes=512 * 1024, tool_result_max=65536, paginate=_turn_detail_page) + args.update(overrides) + return pager.read(str(path), "iris@session", "user-long", "revision-1", **args) + + +@pytest.mark.parametrize("newer", [False, True]) +def test_long_turn_pages_recover_every_public_message_and_navigate_back(tmp_path, newer): + path = tmp_path / "rollout.jsonl" + _long_rollout(path, newer=newer) + pager = CodexDetailPages() + page = _read(pager, path) + ids = [] + seen = set() + pages = [] + before = None + while page is not None: + events, has_more, older, has_newer, newer_cursor = page + pages.append((before, page)) + assert all(e.get("prompt") == "The long question" + for e in events if e["type"] == "user_msg") + assert not any("PRIVATE SUMMARY" in e.get("text", "") for e in events) + assert any(e["type"] == "turn_binding" and e["turn_id"] == "native-long" for e in events) + ids += [e["message_id"] for e in events if e["type"] == "delta"] + if not has_more: + break + assert older is not None and older not in seen + seen.add(older) + before = older + page = _read(pager, path, before) + assert set(ids) == {f"progress-{i}" for i in range(40)} + assert len(ids) == 40 + assert len(pages) > 10 # both byte windows and display-group pages were crossed + # Follow the exact reverse links back to the newest frozen page. + for _before, expected in reversed(pages[:-1]): + assert page[3] is True + page = _read(pager, path, page[4]) + assert _semantic(page) == _semantic(expected) + + +def test_long_turn_cursor_survives_append_but_rejects_changed_scope_and_rollback(tmp_path): + path = tmp_path / "rollout.jsonl" + _long_rollout(path) + pager = CodexDetailPages() + newest = _read(pager, path) + cursor = newest[2] + original = _read(pager, path, cursor) + _write(path, [_message("appended")], "a") + assert _semantic(_read(pager, path, cursor)) == _semantic(original) + assert any(e.get("message_id") == "progress-appended" for e in _read(pager, path)[0]) + for sid, turn, revision in [ + ("other@session", "user-long", "revision-1"), + ("iris@session", "user-old", "revision-1"), + ("iris@session", "user-long", "revision-2"), + ]: + with pytest.raises(CodexDetailCursorExpired): + pager.read(str(path), sid, turn, revision, before=cursor, limit=3, + window_bytes=1024 * 1024, max_bytes=512 * 1024, + tool_result_max=65536, paginate=_turn_detail_page) + with pytest.raises(CodexDetailCursorExpired): + _read(pager, path, cursor + "0") + with pytest.raises(CodexDetailCursorExpired): + _read(pager, path, cursor, limit=4) + path.write_text("{}\n") + with pytest.raises(CodexDetailCursorExpired): + _read(pager, path, cursor) + + +def test_long_turn_pager_does_not_claim_short_turns_or_unknown_ids(tmp_path): + path = tmp_path / "rollout.jsonl" + _write(path, _user("native-long", "user-long", "short")) + assert _read(CodexDetailPages(), path) is None + _long_rollout(path, newer=True) + pager = CodexDetailPages() + assert pager.read(str(path), "iris@session", "user-new", "revision-1", + before=None, limit=3, window_bytes=1024 * 1024, + max_bytes=512 * 1024, tool_result_max=65536, + paginate=_turn_detail_page) is None + + +def test_detail_segment_fence_is_frozen_with_its_source_snapshot(tmp_path): + path = tmp_path / "frozen-boundary.jsonl" + _long_rollout(path) + pager = CodexDetailPages() + active = _read(pager, path) + assert not any(e["type"] == "turn_end" for e in active[0]) + # A steer appended after a snapshot must not retroactively close that + # snapshot's active EOF when its cursors are revisited. + next_user = _user("native-long", "steer", "new instruction")[1] + next_user["timestamp"] = "2026-09-27T17:20:00Z" + _write(path, [next_user], "a") + older = _read(pager, path, active[2]) + returned = _read(pager, path, older[4]) + assert _semantic(returned) == _semantic(active) + fresh = _read(CodexDetailPages(), path) + terminal, = [e for e in fresh[0] if e["type"] == "turn_end"] + assert terminal["result"]["subtype"] == "steered" + assert terminal["turn_id"] is None and not terminal["result"]["is_error"] + + +def test_long_turn_handler_pages_without_an_engine_and_resets_expired_cursor(tmp_path): + path = tmp_path / "rollout.jsonl" + _long_rollout(path) + + async def run(): + machine, transport = _mk_machine() + machine.cfg.codex_history_window_max_bytes = 1024 * 1024 + ctx = _mk_ctx("long-session", "long-session") + ctx.engine = "codex" + machine.sessions[ctx.key] = ctx + machine._codex_rollout_for_wire = lambda _sid: str(path) + machine._codex_history = None # no app-server or model API is available + revision = machine._history_revision(ctx.key) + args = dict(session_id=ctx.key, turn_id="user-long", revision=revision, + before=None, client_id="browser", limit=3) + detail = await machine._handle_get_turn_detail(SimpleNamespace(**args)) + assert detail.authoritative and detail.has_more and detail.to == "browser" + assert any(e.get("message_id") for e in detail.events if e["type"] == "delta") + args["before"] = detail.oldest_cursor + older = await machine._handle_get_turn_detail(SimpleNamespace(**args)) + assert older.authoritative and older.has_newer + machine._codex_detail_pages = CodexDetailPages() # cursor cache eviction/restart + expired = await machine._handle_get_turn_detail(SimpleNamespace(**args)) + assert expired.reset_required and not expired.authoritative and not expired.events + assert len(transport.sent) == 3 + + asyncio.run(run()) + + +def test_tool_call_and_result_across_source_windows_keep_the_same_native_id(tmp_path): + path = tmp_path / "rollout.jsonl" + rows = _user("native-long", "user-long", "The long question") + rows += [{"type": "response_item", "payload": {"type": "function_call", + "call_id": "seam-tool", "name": "exec_command", "arguments": '{"cmd":"ls"}'}}] + rows += [{"type": "compacted", "payload": {"message": "x" * 50_000}}] * 30 + rows += [{"type": "response_item", "payload": {"type": "function_call_output", + "call_id": "seam-tool", "output": "Result on the other side of the window"}}] + _write(path, rows) + pager = CodexDetailPages() + newest = _read(pager, path) + assert any(e["type"] == "tool_result" and e["tool_use_id"] == "seam-tool" + for e in newest[0]) + page = newest + while page[1]: + page = _read(pager, path, page[2]) + assert any(e["type"] == "tool_use" and e["tool_use_id"] == "seam-tool" + and e["category"] == "command" and e["input"] == {"command": "ls"} + for e in page[0]) diff --git a/tests/test_codex_history.py b/tests/test_codex_history.py index d55576ad..abd6609f 100644 --- a/tests/test_codex_history.py +++ b/tests/test_codex_history.py @@ -10,6 +10,7 @@ from cc_remote.wrapper.codex_history import ( CodexHistoryCursorError, CodexHistoryInvalidResponse, + CodexHistoryProjectionTooLarge, CodexHistoryUnsupported, CodexOfficialHistory, ) @@ -39,6 +40,49 @@ ) +def test_rollout_public_agent_items_keep_ids_without_compaction_summaries(tmp_path): + path = tmp_path / "public-messages.jsonl" + def completed(item): + return {"type": "event_msg", "payload": { + "type": "item_completed", "turn_id": "native-turn", "item": item, + }} + first = completed({ + "type": "AgentMessage", "id": "comment-1", "phase": "commentary", + "content": [{"type": "Text", "text": "Checking progress."}], + }) + second = completed({ + **first["payload"]["item"], "id": "comment-2", + }) + final = completed({ + "type": "AgentMessage", "id": "final-1", "phase": "final_answer", + "content": [{"type": "Text", "text": "Finished."}], + }) + rows = [ + {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "native-turn"}}, + completed({"type": "UserMessage", "id": "user-1", + "content": [{"type": "text", "text": "Inspect."}]}), + first, first, + {"type": "response_item", "payload": { + "type": "message", "role": "assistant", "phase": "final_answer", + "content": [{"type": "output_text", "text": "PRIVATE COMPACTION SUMMARY"}], + }}, + completed({"type": "ContextCompaction", "id": "compact-1"}), + second, final, + {"type": "event_msg", "payload": {"type": "task_complete", + "turn_id": "native-turn", "last_agent_message": "Finished."}}, + ] + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + events, _ = codex_translate_history(str(path), 65536) + deltas = [e for e in events if e.type == "delta"] + assert [(e.message_id, e.channel, e.text) for e in deltas] == [ + ("comment-1", "commentary", "Checking progress."), + ("comment-2", "commentary", "Checking progress."), + ("final-1", "final", "Finished."), + ] + assert len([e for e in events if e.type == "turn_end"]) == 1 + assert any(e.type == "process" and e.kind == "compaction" for e in events) + + def _user( item_id: str, text: str, @@ -3060,7 +3104,7 @@ async def rpc(method, params, cwd=None): async def run(): history = CodexOfficialHistory(64 * 1024, rpc=rpc) with pytest.raises( - CodexHistoryInvalidResponse, + CodexHistoryProjectionTooLarge, match="exceeded its page limit", ): await history._items_for_turn("thread-1", "native-1") diff --git a/tests/test_codex_rollout_lifecycle.py b/tests/test_codex_rollout_lifecycle.py new file mode 100644 index 00000000..521ef6b2 --- /dev/null +++ b/tests/test_codex_rollout_lifecycle.py @@ -0,0 +1,127 @@ +"""Source clocks, visible segment fences and public native commands.""" +import json +from datetime import datetime + +import pytest + +from cc_remote.wrapper.codex_stream import ( + codex_next_user_boundary_ts, codex_translate_history, +) +from cc_remote.wrapper.history_store import materialize_history_turns + + +def row(second, payload, kind="event_msg"): + return {"timestamp": f"2026-09-27T17:00:{second:02d}Z", "type": kind, "payload": payload} + + +def item(second, value): + return row(second, {"type": "item_completed", "turn_id": "native", "item": value}) + + +def user(second, uid): + return item(second, {"type": "UserMessage", "id": uid, + "content": [{"type": "text", "text": uid}]}) + + +def write(path, rows): + offsets = [] + with path.open("wb") as stream: + for value in rows: + offsets.append(stream.tell()) + stream.write((json.dumps(value) + "\n").encode()) + return offsets + + +def stamp(second): + return datetime.fromisoformat(row(second, {})["timestamp"]).timestamp() + + +def test_history_events_retain_source_timestamps_including_delayed_agent_rows(tmp_path): + path = tmp_path / "source.jsonl" + write(path, [ + row(0, {"type": "task_started", "turn_id": "native"}), user(1, "first"), + row(2, {"type": "agent_message", "message": "progress", "phase": "commentary"}), + row(8, {"type": "function_call", "call_id": "tool", "name": "exec_command", + "arguments": '{"command":"ls"}'}, "response_item"), + row(9, {"type": "function_call_output", "call_id": "tool", "output": "ok"}, "response_item"), + row(10, {"type": "context_compacted", "id": "compact"}), + item(11, {"type": "AgentMessage", "id": "answer", "phase": "final_answer", + "content": [{"type": "Text", "text": "done"}]}), + row(12, {"type": "task_complete", "turn_id": "native"}), + ]) + events, _ = codex_translate_history(str(path), 1024) + assert [e.msg_id for e in events if e.type == "user_msg"] == ["first"] + assert all(stamp(0) <= e.ts <= stamp(12) for e in events) + assert next(e for e in events if e.type == "delta" and e.text == "progress").ts == stamp(2) + assert next(e for e in events if e.type == "tool_use").ts == stamp(8) + assert next(e for e in events if e.type == "tool_result").ts == stamp(9) + assert [e.model_dump() for e in events] == [ + e.model_dump() for e in codex_translate_history(str(path), 1024)[0]] + turn, = materialize_history_turns([e.model_dump() for e in events]) + assert turn["processStartedTs"] == int(stamp(2) * 1000) + assert turn["processDoneTs"] <= turn["doneTs"] == int(stamp(12) * 1000) + + +@pytest.mark.parametrize("legacy_pair", [False, True]) +def test_bounded_steer_segment_closes_without_claiming_native_terminal(tmp_path, legacy_pair): + path = tmp_path / "source.jsonl" + rows = [row(0, {"type": "task_started", "turn_id": "native"}), user(1, "first"), + row(2, {"type": "agent_message", "message": "progress", "phase": "commentary"})] + if legacy_pair: + rows += [row(9, {"type": "message", "role": "user", "id": "next"}, "response_item"), + row(10, {"type": "user_message", "message": "next"})] + else: + rows += [user(10, "next")] + offsets = write(path, rows) + end_ts = codex_next_user_boundary_ts(str(path), offsets[3], "native") + assert end_ts == stamp(10) - 0.001 + assert codex_next_user_boundary_ts(str(path), offsets[3], "native", + end_offset=path.stat().st_size - 1) is None + events, _ = codex_translate_history(str(path), 1024, end_offset=offsets[3], + snapshot_in_progress=True, segment_end_ts=end_ts) + terminal = events[-1] + assert terminal.type == "turn_end" and terminal.result.subtype == "steered" + assert not terminal.result.is_error and terminal.turn_id is None + assert terminal.ts == end_ts + assert any(e.type == "delta" and e.text == "progress" for e in events) + # A byte window inside the response and a frozen active EOF are not fences. + assert codex_next_user_boundary_ts(str(path), offsets[2], "native") is None + active, _ = codex_translate_history(str(path), 1024, end_offset=offsets[3], snapshot_in_progress=True) + assert not any(e.type == "turn_end" for e in active) + + +@pytest.mark.parametrize("camel", [False, True]) +def test_native_commands_are_readable_bounded_and_deduplicated(tmp_path, camel): + path = tmp_path / "commands.jsonl" + command = {"type": "CommandExecution", "id": "exec-1", "command": ["rg", "a b", "."], + "cwd": "/project", "status": "Completed", "exit_code": 1, + "aggregated_output": "native output" * 100, "process_id": "pid-1", + "parsed_cmd": [{"type": "Search", "query": "a b"}], + "duration": {"secs": 2, "nanos": 500_000_000}} + if camel: + for snake, alternate in (("exit_code", "exitCode"), ("aggregated_output", "aggregatedOutput"), + ("process_id", "processId"), ("parsed_cmd", "commandActions")): + command[alternate] = command.pop(snake) + command["durationMs"] = 2500 + del command["duration"] + write(path, [row(0, {"type": "task_started", "turn_id": "native"}), user(1, "first"), + row(2, {"type": "function_call", "call_id": "exec-1", "name": "exec_command", + "arguments": '{}'}, "response_item"), + item(4, command), item(4, command), + row(5, {"type": "task_complete", "turn_id": "native"})]) + events, _ = codex_translate_history(str(path), 64) + use, = [e for e in events if e.type == "tool_use"] + result, = [e for e in events if e.type == "tool_result"] + assert use.tool_use_id == result.tool_use_id == "exec-1" + assert use.title == "搜索 a b" and use.input["command"] == "rg 'a b' ." + assert use.input["cwd"] == "/project" and use.input["process_id"] == "pid-1" + assert use.ts == stamp(2) and result.ts == stamp(4) + assert result.exit_code == 1 and result.status == "failed" and result.is_error + assert result.duration_ms == 2500 and result.truncated + + +def test_unknown_source_time_never_becomes_reconstruction_time(tmp_path): + path = tmp_path / "unknown-time.jsonl" + write(path, [{"type": "event_msg", "payload": {"type": "user_message", "message": "hi"}}, + {"type": "event_msg", "payload": {"type": "agent_message", "message": "hello"}}]) + assert all(e.ts == 0 for e in codex_translate_history(str(path), 1024)[0]) diff --git a/tests/test_codex_steer_pagination.py b/tests/test_codex_steer_pagination.py index c29aa227..57267fd1 100644 --- a/tests/test_codex_steer_pagination.py +++ b/tests/test_codex_steer_pagination.py @@ -5,6 +5,7 @@ import pytest from cc_remote.wrapper import machine as mm +from cc_remote.wrapper import codex_history as history_module from cc_remote.wrapper.codex_history import CodexOfficialHistory from cc_remote.wrapper.codex_rpc import CodexRpcResponseTooLarge from cc_remote.wrapper.history_store import history_image_from_events @@ -123,6 +124,59 @@ async def test_source_proven_steers_are_not_deleted_when_official_items_are_inco assert not machine._codex_rollout_history_active("steered") +@pytest.mark.asyncio +async def test_bounded_steer_head_uses_one_source_pagination_family(monkeypatch, tmp_path): + machine, calls = setup_history(monkeypatch, tmp_path, oversized=True) + monkeypatch.setattr(history_module, "_MAX_ITEM_PAGES", 1) + revision = machine._history_revision("steered") + page = await machine._build_requested_history( + "steered", before=None, limit=8, cwd="/tmp", detail="summary") + assert page.error is None and page.authoritative + assert page.revision != revision + assert machine._codex_rollout_history_active("steered") + prompts = [turn.prompt for turn in page.turns] + native_reads = len(calls) + while page.has_more: + page = await machine._build_requested_history( + "steered", before=page.oldest_id, limit=8, cwd="/tmp", detail="summary") + assert page.error is None and page.authoritative + prompts = [turn.prompt for turn in page.turns] + prompts + assert len(calls) == native_reads + assert prompts == [f"prompt {i}/{j}" for i in range(8) + for j in range({0: 4, 1: 2}.get(i, 1))] + + +@pytest.mark.asyncio +async def test_bounded_older_steer_page_keeps_official_cursor_family(monkeypatch, tmp_path): + machine, _ = setup_history(monkeypatch, tmp_path, oversized=True) + head = await machine._build_requested_history( + "steered", before=None, limit=6, cwd="/tmp", detail="summary") + monkeypatch.setattr(history_module, "_MAX_ITEM_PAGES", 1) + old = await machine._build_requested_history( + "steered", before=head.oldest_id, limit=2, cwd="/tmp", detail="summary") + assert old.error and not old.authoritative + assert not machine._codex_rollout_history_active("steered") + + +@pytest.mark.asyncio +async def test_bounded_steer_head_does_not_fallback_after_source_changes(monkeypatch, tmp_path): + machine, _ = setup_history(monkeypatch, tmp_path, oversized=True) + source = tmp_path / "steered.jsonl" + + async def changing_items(*_args): + with source.open("a") as stream: + stream.write(json.dumps({"type": "event_msg", "payload": { + "type": "agent_message", "message": "new live output", + }}) + "\n") + raise history_module.CodexHistoryProjectionTooLarge("item budget") + + monkeypatch.setattr(machine._codex_history, "_items_for_turn", changing_items) + head = await machine._build_requested_history( + "steered", before=None, limit=8, cwd="/tmp", detail="summary") + assert head.error and not head.authoritative + assert not machine._codex_rollout_history_active("steered") + + @pytest.mark.asyncio async def test_recovered_steer_keeps_exact_active_turn_running(): first = [_user("initial", "first"), _agent("answer-first", "first reply")] diff --git a/tests/test_history.py b/tests/test_history.py index b48fda3f..ba49a85c 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -126,6 +126,8 @@ def test_codex_process_clock_overlay_requires_exact_logical_and_native_owner( ), ] + for turn in turns: + turn["processDetailState"] = "unknown" mm._apply_codex_process_clocks(turns, clocks) assert turns[0]["processStartedTs"] == 12_345 @@ -136,7 +138,8 @@ def test_codex_process_clock_overlay_requires_exact_logical_and_native_owner( assert "processStartedTs" not in turns[2] -def test_codex_process_clock_cannot_resurrect_exact_empty_steer(tmp_path): +@pytest.mark.parametrize("native_turn_id", [None, "native-turn"]) +def test_codex_process_clock_cannot_resurrect_exact_empty_steer(tmp_path, native_turn_id): rollout = tmp_path / "empty-steer.jsonl" rollout.write_text('{"type":"session_meta"}\n') machine, _transport = _mk_machine() @@ -144,6 +147,7 @@ def test_codex_process_clock_cannot_resurrect_exact_empty_steer(tmp_path): rollout, "accepted-during-compaction", "native-turn", 30_000) empty = _empty_summary_turn( "native-user", client_message_id="accepted-during-compaction", + native_turn_id=native_turn_id, ) mm._apply_codex_process_clocks( @@ -2443,6 +2447,7 @@ async def summary_page(self, _sid, **kwargs): client_message_id=client_message_id, native_turn_id="native-turn", ) + turn["processDetailState"] = "unknown" return CodexHistoryPage( events=(), turns=(turn,), @@ -2853,9 +2858,8 @@ async def run(): assert first.turns[0].processDetailState == "none" assert first.turns[0].processStartedTs is None - # The sidecar changes while rollout bytes and the cached SQLite source - # fingerprint stay identical. The cache-hit path must overlay it rather - # than returning the old compact-derived clock. + # A new clock cannot contradict this exact final-only source segment, + # including on the cache-hit path and with a native terminal/fork id. machine._codex_process_clocks.observe_start( rollout, "browser-message", @@ -2864,8 +2868,8 @@ async def run(): ) cached = await machine._build_history( "cached-clock", limit=4, detail="summary") - assert cached.turns[0].processDetailState == "present" - assert cached.turns[0].processStartedTs == 77_000 + assert cached.turns[0].processDetailState == "none" + assert cached.turns[0].processStartedTs is None asyncio.run(run()) diff --git a/tests/test_history_store.py b/tests/test_history_store.py index e7c5ba4f..44190822 100644 --- a/tests/test_history_store.py +++ b/tests/test_history_store.py @@ -28,6 +28,30 @@ def _page(label: str, *, more: bool = False) -> MaterializedHistoryPage: ) +@pytest.mark.parametrize("old_version", [44, 45]) +def test_public_codex_item_migration_invalidates_only_codex_projections(tmp_path, old_version): + path = tmp_path / "source.jsonl" + path.write_text("{}\n") + source = HistorySourceFingerprint.capture(path) + store = HistoryIndexStore(tmp_path / "state") + for engine in ("codex", "claude", "dsh"): + store.put_page(engine, engine, source, before=None, limit=4, page=_page(engine)) + store.put_image_asset(engine, engine, source, engine, "image", + "thumbnail", "image/png", 1, 1, engine.encode()) + with sqlite3.connect(store.path) as connection: + connection.execute(f"PRAGMA user_version={old_version}") + migrated = HistoryIndexStore(tmp_path / "state") + with sqlite3.connect(migrated.path) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + for table in ("history_pages", "history_turn_details"): + assert sorted(connection.execute(f"SELECT engine FROM {table}").fetchall()) == [ + ("claude",), ("dsh",)] + assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 3 + migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("new")) + reopened = HistoryIndexStore(tmp_path / "state") + assert reopened.get_page("codex", "codex", source, before=None, limit=4) == _page("new") + + def test_history_index_roundtrip_is_bound_to_exact_source_snapshot(tmp_path): source_path = tmp_path / "rollout.jsonl" source_path.write_text('{"type":"first"}\n') @@ -461,7 +485,7 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='codex'" @@ -490,11 +514,11 @@ def test_compact_migration_rebuilds_claude_details_and_preserves_assets(tmp_path connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 assert connection.execute( - "SELECT engine FROM history_pages").fetchall() == ([] if old_version == 41 else [("codex",)]) + "SELECT engine FROM history_pages").fetchall() == [] assert connection.execute( - "SELECT engine FROM history_turn_details").fetchall() == [("codex",)] + "SELECT engine FROM history_turn_details").fetchall() == [] assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 2 assert migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("rebuilt")) reopened = HistoryIndexStore(tmp_path / "state") @@ -565,7 +589,7 @@ def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path): assert migrated.get_page( "session-1", "claude", source, before=None, limit=4) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 assert connection.execute( "SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0 @@ -593,7 +617,7 @@ def test_v20_migration_rebuilds_codex_and_claude_identity_projections(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -654,7 +678,7 @@ def test_v21_migration_rebuilds_claude_alias_and_codex_process_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -711,7 +735,7 @@ def test_v22_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -773,7 +797,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -844,7 +868,7 @@ def test_async_question_migration_preserves_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -953,7 +977,7 @@ def test_legacy_migration_rebuilds_all_derived_history_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ( "history_pages", "history_turn_details", @@ -1000,7 +1024,7 @@ def test_v10_migration_invalidates_changed_projection_rows(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1051,7 +1075,7 @@ def test_v11_migration_invalidates_claude_pages_and_adds_compact_index( "claude-session", "claude", source, before=None, limit=4, ) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" @@ -1097,7 +1121,7 @@ def test_recent_migration_invalidates_changed_projection_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1139,7 +1163,7 @@ def test_owner_and_interrupt_alias_migration_invalidates_both_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -1203,7 +1227,7 @@ def test_recent_summary_migration_rebuilds_pages_but_preserves_source_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 assert connection.execute( "SELECT COUNT(*) FROM history_pages" ).fetchone()[0] == 0 diff --git a/web/package.json b/web/package.json index 2634d444..97302977 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,7 @@ "test:jitter": "playwright test -c playwright.jitter.config.ts --project=webkit", "test:diff": "npm run test:compile --silent && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js", "test:preview": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", - "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/tool-details.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-usage.test.js && node node_modules/.tmp/cc-remote-tests/tests/themes.test.js && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/clipboard-paste-guard.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", + "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/tool-details.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-usage.test.js && node node_modules/.tmp/cc-remote-tests/tests/themes.test.js && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/btw-history.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/clipboard-paste-guard.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/web/src/components/BtwPanel.css b/web/src/components/BtwPanel.css index 3e9e3999..bac27b07 100644 --- a/web/src/components/BtwPanel.css +++ b/web/src/components/BtwPanel.css @@ -16,6 +16,7 @@ .btw-chat-close{ width:26px; height:28px; flex:none; display:grid; place-items:center; border:0; border-left:1px solid transparent; background:transparent; color:var(--dim); cursor:pointer; } .btw-chat-close:hover{ color:var(--danger); } .btw-body{ flex:1; min-height:0; display:flex; flex-direction:column; overflow:hidden; padding:4px 2px; } +.btw-history-note{ padding:8px 20px; color:var(--dim); font-size:12px; line-height:1.5; } .btw-empty{ flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:28px 20px; color:var(--dim); font-size:13px; line-height:1.7; text-align:center; } .btw-composer{ position:relative; flex:none; padding:9px 12px 8px; border-top:1px solid var(--border); background:var(--bg); } .btw-composer-notice{ position:absolute; left:50%; bottom:calc(100% + 8px); z-index:3; transform:translateX(-50%); padding:7px 11px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); color:var(--dim); font-size:11px; white-space:nowrap; } diff --git a/web/src/components/BtwPanel.tsx b/web/src/components/BtwPanel.tsx index f6af533a..7cc98253 100644 --- a/web/src/components/BtwPanel.tsx +++ b/web/src/components/BtwPanel.tsx @@ -490,6 +490,9 @@ export function BtwPanel(p: Props) {
+ {p.rt?.truncated &&
+ 较早的部分过程已超出临时对话保留范围 +
} {awaitingFirstChat ?
diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 56f19508..92f8d8c0 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -2826,8 +2826,9 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud detailError={processDetailError} externalPlanItemId={externalPlanItemId} onLoadDetail={onLoadDetail - ? () => requestProcessDetail( - t.id, undefined, "initial", false) + ? (automatic) => automatic + ? onLoadDetail(t.id) + : requestProcessDetail(t.id, undefined, "initial", false) : undefined} onRetryDetail={onLoadDetail && detailRetryDirection ? () => requestProcessDetail( diff --git a/web/src/components/ProcessTimeline.tsx b/web/src/components/ProcessTimeline.tsx index 87164f90..30ae81f5 100644 --- a/web/src/components/ProcessTimeline.tsx +++ b/web/src/components/ProcessTimeline.tsx @@ -719,7 +719,7 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati deferredCount?: number; detailLoading?: boolean; detailError?: string | null; - onLoadDetail?: () => boolean | void; + onLoadDetail?: (automatic?: boolean) => boolean | void; onRetryDetail?: () => boolean | void; canLoadEarlier?: boolean; canLoadNewer?: boolean; @@ -800,6 +800,7 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati const [localDetailError, setLocalDetailError] = useState(null); const [now, setNow] = useState(Date.now()); const manuallyToggled = useRef(false); + const detailAttempt = useRef<{ turn?: string; requested: boolean }>({ requested: false }); const tapGuard = useRef(new PointerTapGuard()); const interaction = useProcessInteraction(onInteractionStart, onInteractionEnd); @@ -823,6 +824,24 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati return () => window.clearInterval(timer); }, [processActive]); + useEffect(() => { + // A restored/default-open disclosure needs the same request as a click. + // One attempt per opening prevents rerenders, StrictMode and failed loads + // from starting a retry loop. Explicit Retry remains available. + if (detailAttempt.current.turn !== historyTurnId || !open || !needsAuthoritativeDetail) { + detailAttempt.current = { turn: historyTurnId, requested: false }; + } + if (!open || !needsAuthoritativeDetail) return; + if (detailLoading) { + detailAttempt.current.requested = true; + return; + } + if (detailAttempt.current.requested || detailError || localDetailError || !onLoadDetail) return; + detailAttempt.current.requested = true; + if (onLoadDetail(true) === false) setLocalDetailError(DETAIL_REQUEST_ERROR); + }, [open, needsAuthoritativeDetail, detailLoading, detailError, localDetailError, + onLoadDetail, historyTurnId]); + const hasDeferredOnly = timelineItems.length === 0 && needsAuthoritativeDetail; const waitingForContent = timelineItems.length === 0 && !visiblePlanBlock && processActive; @@ -866,6 +885,7 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati const elapsed = rawElapsed != null && rawElapsed >= 500 ? rawElapsed : null; const requestDetail = (load = onLoadDetail) => { + detailAttempt.current = { turn: historyTurnId, requested: true }; setLocalDetailError(null); if (load?.() === false) { setLocalDetailError(DETAIL_REQUEST_ERROR); @@ -961,7 +981,9 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati {(hasDeferredOnly || waitingForContent) && !visibleDetailError && (
- {hasDeferredOnly ? "正在加载过程…" : "等待模型响应…"} + {hasDeferredOnly ? detailLoading ? "正在加载过程…" : ( + + ) : "等待模型响应…"}
)} {canLoadEarlier && ( diff --git a/web/src/history-detail-projection.ts b/web/src/history-detail-projection.ts index 301e80df..d512bfc8 100644 --- a/web/src/history-detail-projection.ts +++ b/web/src/history-detail-projection.ts @@ -175,7 +175,19 @@ function materializeSegments( // Retry/repair windows may overlap at an exact native block boundary. // The later source page refreshes that payload in place; equal text or // titles are never treated as identity evidence. - blocks[existing] = block; + const previous = blocks[existing]; + if (previous.kind === "tool" && block.kind === "tool" + && block.tool === "tool" && Object.keys(block.input).length === 0) { + // A rollout byte window can begin with an output whose call is on + // the preceding page. Keep the recovered call metadata and take the + // newer result; the generic output-only envelope is not a new tool. + blocks[existing] = { ...previous, ...block, + tool: previous.tool, input: previous.input, category: previous.category, + title: previous.title, parent_id: previous.parent_id, server: previous.server, + startedTs: previous.startedTs ?? block.startedTs }; + } else { + blocks[existing] = block; + } } } } diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts index a864d57e..13118d04 100644 --- a/web/src/history-merge.ts +++ b/web/src/history-merge.ts @@ -78,8 +78,8 @@ function hasDeferredTurnDetail(turn: Turn): boolean { || (turn.detailEventCount ?? 0) > 0; } -function isExactClosedSteer(summary: Turn, detail: Turn): boolean { - return summary.done && !!summary.clientMsgId && !summary.forkPointId +function isExactClosedSegment(summary: Turn, detail: Turn): boolean { + return summary.done && sharesExactTurnAlias(summary, detail); } @@ -149,6 +149,7 @@ function trustworthyProcessStartedTs(turn: Turn): number | undefined { const started = turn.processStartedTs; const done = turn.processDoneTs; if (started == null) return undefined; + if (turn.doneTs != null && started > turn.doneTs) return undefined; // Older projections assigned every hydrated native item the same parser // timestamp. Do not let that invalid zero interval combine with a newer // source witness and manufacture a duration stretching to refresh time. @@ -162,6 +163,7 @@ function trustworthyProcessDoneTs(turn: Turn): number | undefined { if (started == null || done == null || done - started < 500) { return undefined; } + if (turn.doneTs != null && done > turn.doneTs) return undefined; return done; } @@ -1264,7 +1266,7 @@ export function mergeAuthoritativeTurnDetail( ownsProcessBlock(indexedBlockOwners(canonicalTurns!), index)); } const merged = mergeTurn(summary, detail, false, "second", false, false, - isExactClosedSteer(canonical, detail)); + isExactClosedSegment(canonical, detail)); return { ...merged, id: summary.id, @@ -1387,10 +1389,29 @@ export function installAuthoritativeTurnDetailPage( // selected heavy-process page. Expanding or paging details must not make // their gallery disappear again. Retain references only, never image bytes. const outputImages = generatedOutputImages([...summary.blocks, ...processBlocks]); - const detailImageIds = new Set(generatedOutputImages(detailWithoutFinals) + // A page describes a bounded source window, not the absence of records + // already received on the live stream. Keep the bounded live row while its + // native turn is open; rendering merges it with detail by item identity. + // A later completed detail read can retire this provisional tail. + const liveCandidates = summary.done ? [] : summary.blocks.filter( + (block) => !isFinalTextBlock(block)); + const liveIds = new Set(liveCandidates.map(blockIdentity)); + const liveProcess = mergeBlocks( + processBlocks, liveCandidates, false, false, false, "combine", + ).filter(block => liveIds.has(blockIdentity(block))); + const retainedProcess = mergeDetailWithLiveTail(detailWithoutFinals, liveProcess); + const detailImageIds = new Set(generatedOutputImages(retainedProcess) .map(generatedImageIdentity)); let processDetailState = mergedProcessDetailState( summary, detail, processBlocks); + const exactEmptyDetail = summary.done && detail.done + && sharesExactTurnAlias(summary, detail) && !hasMore && !hasNewer + && !detailProjection?.capped + && !processBlocks.some(isPresentableProcessBlock) + && !summary.blocks.some(isPresentableProcessBlock) + && !summary.liveSpillBlocks?.some(isPresentableProcessBlock) + && statedProcessDetailState(detail) === "none"; + if (exactEmptyDetail) processDetailState = "none"; // A bounded page containing only the final answer is not an exact // process-free conclusion while adjacent source pages remain unread. Keep // the honest unknown state mounted so automatic/manual pagination cannot @@ -1415,8 +1436,8 @@ export function installAuthoritativeTurnDetailPage( // ordinary live-turn 256 item / 16 MiB cap cannot evict them. Legacy // callers without a projection retain the pre-v21 behavior. blocks: detailProjection - ? [...outputImages, ...canonicalFinals] - : [...detailWithoutFinals, + ? [...mergeDetailWithLiveTail(outputImages, liveProcess), ...canonicalFinals] + : [...retainedProcess, ...outputImages.filter(image => !detailImageIds.has(generatedImageIdentity(image))), ...canonicalFinals], done: summary.done, @@ -1426,7 +1447,7 @@ export function installAuthoritativeTurnDetailPage( interrupted: summary.interrupted, error: summary.error, progress: summary.progress, - detailEventCount: summary.detailEventCount, + detailEventCount: exactEmptyDetail ? 0 : summary.detailEventCount, detailLoaded: !restoreIncomplete && !incompleteUnknownProcess, detailLoading: false, detailError: undefined, @@ -1805,7 +1826,7 @@ export function mergeInitialHistory( !!options.preserveLiveTailOpen && !!options.reconcileReplayOrphans && liveIndex === activeOwnerIndex, (settledCodex || !!options.reconcileReplayOrphans) - && isExactClosedSteer(historyTurn, liveTurn), + && isExactClosedSegment(historyTurn, liveTurn), ); if (settledCodex && sharesExactTurnAlias(historyTurn, liveTurn)) { merged[index] = restoreUnownedHistoryIdentity( diff --git a/web/src/reducer.ts b/web/src/reducer.ts index 3beaec60..98f8df90 100644 --- a/web/src/reducer.ts +++ b/web/src/reducer.ts @@ -225,6 +225,7 @@ export interface SessionRuntime { collaborationMode: CollaborationModeName; fast: boolean | null; // null until the wrapper reports the real service tier replaying: boolean; + btwReplayPending?: Turn[]; // True only after this connection has received this sid's Snapshot or // ReplayEnd. Prevents stale local "idle" state from draining work early. syncReady: boolean; @@ -5664,9 +5665,7 @@ function reduceEvent( }); case "replay_start": { const replaySid = e.sid ?? state.focusedSid; - // Ephemeral side chats have no canonical History endpoint. Their ring is - // the authoritative bounded projection, so keep and apply a retained - // suffix even when its older prefix has fallen out of the ring. + // Side chats rebuild from a private item snapshot, not durable History. const needsAuthoritativeHistory = (e.truncated || !!e.rebuild) && !replaySid?.startsWith("btw-"); const submittedTurn = needsAuthoritativeHistory && !e.rebuild @@ -5706,6 +5705,20 @@ function reduceEvent( rt.replaying = true; rt.syncReady = false; rt.truncated = e.truncated; + if (replaySid?.startsWith("btw-") && e.rebuild) { + const pending = [...rt.turns, ...(rt.btwReplayPending ?? [])].find((turn) => + turn.id === rt.acceptancePending && !turnHasBoundEngineId(turn)); + rt.btwReplayPending = pending ? [pending] : []; + rt.turns = []; + rt.liveOwner = null; + rt.pendingLiveBinding = null; + rt.pendingTerminalFences = null; + rt.historyFence = null; + rt.hydratedCacheTurnIds = []; + rt.liveDetailTurnIds = []; + rt.historyInvalidated = false; + rt.loading = true; + } // rebuild clears turns then refills — keep loading=true so the gap shows a // spinner rather than briefly flashing the empty "send a message" prompt. if (needsAuthoritativeHistory) { @@ -5771,6 +5784,11 @@ function reduceEvent( } case "replay_end": return { ...patch(state, e.sid, (rt) => { + if (rt.btwReplayPending) { + rt.turns = [...rt.turns, ...rt.btwReplayPending.filter((pending) => + !rt.turns.some((turn) => turnHasIdentityAlias(turn, pending.id)))]; + rt.btwReplayPending = undefined; + } for (const usage of e.turn_usage ?? []) { rt.turnUsage = rememberTurnUsage(rt.turnUsage, usage); } diff --git a/web/tests/btw-history.test.ts b/web/tests/btw-history.test.ts new file mode 100644 index 00000000..e933a8f2 --- /dev/null +++ b/web/tests/btw-history.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { createServer } from "vite"; +import type { AppState, Turn } from "../src/reducer.ts"; +import type { ServerEvent } from "../src/protocol.ts"; + +const harness = await createServer({ + root: process.cwd(), appType: "custom", logLevel: "silent", + server: { middlewareMode: true, watch: null }, +}); +try { + const { initialState, reduce } = await harness.ssrLoadModule("/src/reducer.ts"); + const sid = "btw-history"; + let state: AppState = initialState; + const event = (body: Record) => { + state = reduce(state, { type: "event", event: { + v: 72, ts: 1, sid, ...body, + } as ServerEvent }); + }; + event({ type: "btw_opened", request_id: "r", parent_sid: "main", + btw_sid: sid, engine: "codex", created_at: 1, revision: 1 }); + const orphan: Turn = { + id: "message-first", prompt: "", done: true, + blocks: [{ kind: "text", message_id: "message-first", channel: "commentary", + text: ",确认它在做什么", done: true }], + }; + state = { ...state, runtimes: { ...state.runtimes, [sid]: { + ...state.runtimes[sid], turns: [orphan], + } } }; + const restore = () => { + event({ type: "replay_start", generation: "g", from_seq: 0, to_seq: 100, + truncated: false, rebuild: true }); + event({ type: "user_msg", msg_id: "human", prompt: "只读看看这个会话" }); + event({ type: "turn_binding", msg_id: "human", turn_id: "native" }); + event({ type: "assistant_msg_start", message_id: "message-first", channel: "commentary" }); + event({ type: "delta", message_id: "message-first", channel: "commentary", + text: "我先只读核查,确认它在做什么" }); + event({ type: "assistant_msg_end", message_id: "message-first", channel: "commentary" }); + event({ type: "turn_end", turn_id: "native", result: { + subtype: "success", duration_ms: 1200, is_error: false, + } }); + event({ type: "replay_end", to_seq: 100, truncated: false }); + }; + restore(); + restore(); + const turns = state.runtimes[sid].turns; + assert.equal(turns.length, 1, "reconnect must remove the orphan, not duplicate its reply"); + assert.equal(turns[0].prompt, "只读看看这个会话"); + assert.equal(turns[0].blocks[0].kind === "text" && turns[0].blocks[0].text, + "我先只读核查,确认它在做什么"); + assert.equal(turns[0].done, true); + assert.equal(state.runtimes[sid].loading, false); + + // The wrapper omits items whose user was evicted. An empty bounded snapshot + // must clear the stale local fragment and keep an explicit truncation flag. + event({ type: "replay_start", generation: "g", from_seq: 95, to_seq: 100, + truncated: true, rebuild: true }); + event({ type: "replay_end", to_seq: 100, truncated: true }); + assert.deepEqual(state.runtimes[sid].turns, []); + assert.equal(state.runtimes[sid].truncated, true); + + state = { ...state, runtimes: { ...state.runtimes, [sid]: { + ...state.runtimes[sid], acceptancePending: "pending", + turns: [{ id: "pending", clientMsgId: "pending", prompt: "next input", + blocks: [], done: false }], + } } }; + restore(); + assert.deepEqual(state.runtimes[sid].turns.map(t => t.id), ["human", "pending"], + "a snapshot racing acceptance must preserve the pending input after older replies"); + + event({ type: "replay_start", generation: "g", from_seq: 0, to_seq: 110, + truncated: false, rebuild: true }); + event({ type: "replay_start", generation: "g", from_seq: 0, to_seq: 110, + truncated: false, rebuild: true }); + event({ type: "user_msg", msg_id: "pending", prompt: "next input" }); + event({ type: "turn_binding", msg_id: "pending", turn_id: "next-native" }); + event({ type: "delta", message_id: "stream", text: "Beginning ", channel: "commentary" }); + event({ type: "replay_end", to_seq: 110, truncated: false }); + event({ type: "state", state: "running", seq: 111 }); + event({ type: "delta", message_id: "stream", text: "continues live", channel: "commentary", + seq: 112 }); + const active = state.runtimes[sid]; + assert.equal(active.turns.length, 1); + assert.equal(active.turns[0].id, "pending"); + assert.equal(active.turns[0].done, false, "restoring history must preserve the active turn"); + assert.equal(active.turns[0].blocks[0].kind === "text" && active.turns[0].blocks[0].text, + "Beginning continues live"); + assert.equal(active.state, "running"); + assert.equal(active.liveOwner?.turnId, "pending", "the activity spark follows the restored owner"); + + event({ type: "replay_start", generation: "g", from_seq: 0, to_seq: 112, + truncated: false, rebuild: true }); + state = { ...state, runtimes: { ...state.runtimes, [sid]: { + ...state.runtimes[sid], acceptancePending: "new-local", + turns: [{ id: "new-local", prompt: "input during replay", blocks: [], done: false }], + } } }; + restore(); + assert.equal(state.runtimes[sid].turns.at(-1)?.id, "new-local", + "a repeated snapshot must preserve a new input submitted during the earlier replay"); +} finally { + await harness.close(); +} +console.log("BTW history recovery checks passed"); diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index 31b635ff..89118864 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -1123,6 +1123,7 @@ function HistoryConversationBrowserFixture() { const detailOlderErrorOnce = params.has("detail-older-error-once"); const detailRetainedPreview = params.has("detail-retained-preview"); const detailRestoredPage = params.get("detail-restored-page"); + const detailInitialOpen = params.get("detail-initial-open"); const detailScrollCancel = params.has("detail-scroll-cancel"); const mermaid = params.has("mermaid"); const actualMermaid = params.has("actual-mermaid"); @@ -1159,6 +1160,8 @@ function HistoryConversationBrowserFixture() { } if (detailPaging) { let detailTurn = detailPagingTurn("deferred", false, detailRetainedPreview); + if (detailInitialOpen === "restored") detailTurn.detailRestoreOpen = true; + if (detailInitialOpen === "default") detailTurn.done = false; if (detailRestoredPage === "process") { detailTurn = { ...detailPagingTurn("latest"), @@ -1235,7 +1238,7 @@ function HistoryConversationBrowserFixture() { } return INITIAL; }, [ - actualMermaid, compactTools, detailPaging, detailRetainedPreview, detailRestoredPage, + actualMermaid, compactTools, detailPaging, detailRetainedPreview, detailRestoredPage, detailInitialOpen, detailScrollCancel, dualImage, interactiveTimeline, math, streamingMath, deepBrowse, invalidMermaid, large, largeCount, paragraphs, mermaid, mermaidHistory, diff --git a/web/tests/history-browser.spec.ts b/web/tests/history-browser.spec.ts index b542f219..762eef27 100644 --- a/web/tests/history-browser.spec.ts +++ b/web/tests/history-browser.spec.ts @@ -5401,6 +5401,25 @@ test("turn detail stays bounded and older pages load explicitly without jumping" )).toBeLessThan(2); }); +for (const opening of ["default", "restored"]) { + test(`initially ${opening} open process detail requests once and retries only explicitly`, async ({ page }) => { + await page.goto("/tests/history-browser.html?detail-paging=1&delay=600&detail-error-once=1" + + `&detail-initial-open=${opening}`); + const header = page.locator(".turn-process-head"); + await expect(header).toHaveAttribute("aria-expanded", "true"); + await expect(page.locator("html")).toHaveAttribute("data-detail-requests", "1"); + // Automatic restoration must not start an explicit-click scroll transaction. + await expect(page.locator(".thread")).toHaveAttribute("data-detail-anchor-active", "false"); + await expect(page.getByRole("alert")).toContainText("详细过程暂时不可用"); + await page.waitForTimeout(750); + await expect(page.locator("html")).toHaveAttribute("data-detail-requests", "1"); + await page.getByRole("button", {name: "重试", exact: true}).click(); + await expect(page.locator("html")).toHaveAttribute("data-detail-requests", "2"); + await expect(header).toHaveAttribute("aria-busy", "false"); + await expect(page.getByRole("alert")).toHaveCount(0); + }); +} + test("a loading process can collapse and reopen without issuing a duplicate read", async ({ page, }) => { diff --git a/web/tests/process-detail.test.ts b/web/tests/process-detail.test.ts index 79d1c986..59f3e00b 100644 --- a/web/tests/process-detail.test.ts +++ b/web/tests/process-detail.test.ts @@ -5,6 +5,7 @@ import { createServer } from "vite"; import { installAuthoritativeTurnDetailPage, + mergeDetailWithLiveTail, mergeAuthoritativeTurnDetail, mergeInitialHistory, restoreCachedTurnDetails, @@ -17,6 +18,40 @@ import { projectAgentEvents, } from "../src/agent-detail.ts"; import type { AgentDetail } from "../src/protocol.ts"; +import { installTurnDetailProjectionPage } from "../src/history-detail-projection.ts"; + +const runningWithCommentary: Turn = { + id: "running-detail", prompt: "inspect", done: false, + blocks: [{kind: "text", message_id: "live-progress", channel: "commentary", + text: "Already visible progress", done: true, liveOrder: 0}, + {kind: "process", item_id: "command-1", processKind: "command", + title: "Check", phase: "start", status: "running", done: false, liveOrder: 1}], +}; +const sourceCommand = { + ...runningWithCommentary.blocks[1], done: true, status: "succeeded", +} as ProcessBlock; +const installedRunning = installAuthoritativeTurnDetailPage( + runningWithCommentary, {...runningWithCommentary, blocks: [sourceCommand]}, + {hasMore: true, oldestCursor: "older", hasNewer: false}, + {segments: [], blocks: [sourceCommand], capped: false, + hasMore: true, oldestCursor: "older", hasNewer: false, newerCursor: null}, +); +const runningDisplay = mergeDetailWithLiveTail( + installedRunning.detailProjection!.blocks, installedRunning.blocks); +assert.equal(runningDisplay.length, 2); +assert.equal(runningDisplay[0].kind, "text", + "a partial history page must retain live commentary in its original order"); +assert.equal(runningDisplay[1].done, true, + "retaining the live row must not resurrect a completed tool"); +assert.equal(installedRunning.done, false); +const settledRunning = installAuthoritativeTurnDetailPage( + {...installedRunning, done: true}, {...installedRunning, blocks: [sourceCommand]}, + {hasMore: false, hasNewer: false}, + {segments: [], blocks: [sourceCommand], capped: false, + hasMore: false, oldestCursor: null, hasNewer: false, newerCursor: null}, +); +assert.equal(settledRunning.blocks.length, 0, + "completed source detail retires the provisional live row"); const agentEvents: ServerEvent[] = [{ v: 37, type: "process", item_id: "nested-agent", kind: "agent", @@ -125,6 +160,39 @@ assert.equal(processAfterStaleZeroCache.processStartedTs, 10_000); assert.equal(processAfterStaleZeroCache.processDoneTs, 14_000, "a stale parser-time zero interval cannot stretch source-backed timing"); +const sourceBoundedProcess = { ...sourceTimedProcess, ts: 5_000, doneTs: 15_000 }; +const correctedClock = mergeInitialHistory([sourceBoundedProcess], [{ + ...sourceBoundedProcess, processStartedTs: 10_000, processDoneTs: 99_000, +}])[0]; +assert.equal(correctedClock.processDoneTs, 14_000, + "cached reconstruction time beyond the source terminal must not win a max merge"); + +const finalOnlySegment: Turn = { + ...exactDirectDetail, id: "final-only-steer", clientMsgId: "client-final", + forkPointId: "native-task", done: true, processDetailState: "none", detailEventCount: 0, +}; +const falseProcessClaim: Turn = { + ...finalOnlySegment, processDetailState: "present", detailReasons: ["process"], + detailEventCount: 1, processStartedTs: 99_000, +}; +const correctedFinalSummary = mergeInitialHistory([finalOnlySegment], [falseProcessClaim], {}, true)[0]; +assert.equal(correctedFinalSummary.processDetailState, "none", + "a native fork point does not protect a false metadata-only process claim"); +const correctedFinalDetail = installAuthoritativeTurnDetailPage( + falseProcessClaim, finalOnlySegment, {hasMore: false, hasNewer: false}, +); +assert.equal(correctedFinalDetail.processDetailState, "none"); +assert.equal(correctedFinalDetail.detailEventCount, 0); +assert.equal(correctedFinalDetail.processStartedTs, undefined); +assert.equal(correctedFinalDetail.detailError, undefined); +for (const page of [{hasMore: true, hasNewer: false}, {hasMore: false, hasNewer: true}]) { + assert.equal(installAuthoritativeTurnDetailPage(falseProcessClaim, finalOnlySegment, page) + .processDetailState, "present", "an unread adjacent page is not evidence of absence"); +} +assert.equal(installAuthoritativeTurnDetailPage( + {...falseProcessClaim, done: false}, finalOnlySegment, {hasMore: false, hasNewer: false}, +).processDetailState, "present", "an active response can still produce process content"); + const partialDirectDetail = installAuthoritativeTurnDetailPage( { ...opaqueDirectSummary, id: "partial-direct" }, { ...exactDirectDetail, id: "partial-direct" }, @@ -245,6 +313,31 @@ try { v: 37, ts: 10, ...body, } as ServerEvent); + const decodeSeam = (events: ServerEvent[]): Turn | undefined => { + let state = { ...initialState, focusedSid: "seam", runtimes: { seam: createRuntime() } }; + for (const item of events) state = reduce(state, { + type: "event", event: { ...item, sid: "seam" }, + }); + return state.runtimes.seam.turns.find((turn: Turn) => turn.id === "human"); + }; + const seamUse = event({ type: "tool_use", message_id: "message", tool_use_id: "call", + tool: "shell", category: "command", input: { command: "ls" }, title: "List files" }); + const seamUser = event({ type: "user_msg", msg_id: "human", prompt: "inspect" }); + const newestSeam = installTurnDetailProjectionPage(undefined, { + events: [seamUser, event({ ...seamUse, tool: "tool", category: "tool", input: {}, title: null }), + event({ type: "tool_result", tool_use_id: "call", content: "files", is_error: false })], + hasMore: true, oldestCursor: "older", + }, decodeSeam); + const joinedSeam = installTurnDetailProjectionPage(newestSeam.projection, { + before: "older", events: [seamUser, seamUse], hasMore: false, + hasNewer: true, newerCursor: "newer", + }, decodeSeam).projection.blocks; + assert.equal(joinedSeam.length, 1, "one call spanning byte windows stays one tool"); + assert.equal(joinedSeam[0].kind === "tool" && joinedSeam[0].tool, "shell"); + assert.deepEqual(joinedSeam[0].kind === "tool" && joinedSeam[0].input, { command: "ls" }); + assert.equal(joinedSeam[0].kind === "tool" && joinedSeam[0].result?.content, "files"); + assert.equal(joinedSeam[0].done, true); + let multiBtwState = reduce(initialState, { type: "event", event: event({ type: "btw_opened", sid: "btw-one", request_id: "open-one", diff --git a/web/vite.config.ts b/web/vite.config.ts index 210e75dd..7ca60d79 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -27,10 +27,10 @@ export default defineConfig({ }, { name: "initial-vendor", - // Keep small, shared projection primitives out of the entry - // without adding a fifth startup request. They have no UI side - // effects and change only with the bounded-history contract. - test: /node_modules[\\/]|preload-helper|src[\\/](?:compaction-orphans|history-browse|history-requests|runtime-bounds|remote-viewer|file-changes|tool-command)\.ts$|src[\\/]icons\.tsx$/, + // Keep the shared reducer and projection primitives together. + // This compresses their common field names without increasing + // startup requests or pulling any lazy UI into the initial load. + test: /node_modules[\\/]|preload-helper|src[\\/](?:compaction-orphans|history-browse|history-requests|history-detail-projection|reducer|runtime-bounds|remote-viewer|file-changes|tool-command)\.ts$|src[\\/]icons\.tsx$/, tags: ["$initial"], priority: 10, }, From 900c0f7188aecbe231c41176a4a1bc7a6ef2a9d4 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Mon, 28 Sep 2026 19:13:43 -0700 Subject: [PATCH 2/3] fix(runtime): stabilize session recovery and live projections - Recover old Claude sessions after a proven service restart without replaying accepted input. - Keep rejected sends private and preserve their complete payload for manual retry. - Preserve Codex steering boundaries and native user-message identities. - Ignore Claude cost-only transcript growth and project unified quota windows. - Restore local file preview links and guide Claude to the authenticated viewer. - Validate the deployed snapshot with 5181 tests passing, 4 expected skips, and the local Web and shell checks. --- cc_remote/claude_service/client.py | 44 ++++- cc_remote/wrapper/claude_external.py | 10 +- cc_remote/wrapper/claude_rate_limits.py | 112 +++++++----- cc_remote/wrapper/codex_external.py | 4 +- cc_remote/wrapper/machine.py | 169 ++++++++++++------ cc_remote/wrapper/sdk.py | 25 ++- docs/claude-session-service.md | 14 +- tests/test_claude_external.py | 174 ++++++++++++++++++ tests/test_claude_rate_limits.py | 176 ++++++++++++++++-- tests/test_claude_service_restart.py | 227 ++++++++++++++++++++++++ tests/test_codex_controls.py | 110 ++++++++++++ tests/test_codex_history.py | 5 +- tests/test_query_rejection.py | 75 ++++++++ web/package.json | 2 +- web/src/components/MessageBlock.tsx | 11 +- web/src/reducer.ts | 36 +++- web/tests/markdown-preview.test.ts | 40 +++++ web/tests/notices-rate-limits.test.ts | 25 +++ web/tests/query-rejection.test.ts | 107 +++++++++++ web/tests/steer-boundary.test.ts | 53 ++++++ web/vite.config.ts | 4 +- 21 files changed, 1285 insertions(+), 138 deletions(-) create mode 100644 tests/test_claude_service_restart.py create mode 100644 tests/test_query_rejection.py create mode 100644 web/tests/query-rejection.test.ts diff --git a/cc_remote/claude_service/client.py b/cc_remote/claude_service/client.py index fcbfc63a..f6909ce6 100644 --- a/cc_remote/claude_service/client.py +++ b/cc_remote/claude_service/client.py @@ -23,6 +23,24 @@ "claude_service_callback", default=None) +def service_owner_exited(identity) -> bool: + """Prove an exact local owner exited; an unreadable PID is not proof.""" + if identity is None: + return False + from cc_remote.wrapper.process_scan import process_identity + + current = process_identity(identity.pid) + if current is not None: + return current != identity + try: + os.kill(identity.pid, 0) + except ProcessLookupError: + return True + except OSError: + pass + return False + + class Connection: def __init__(self, socket_path: str): self.socket_path = os.path.expanduser(socket_path) @@ -101,7 +119,8 @@ def options_payload(options) -> dict: class RemoteClient: - def __init__(self, socket_path, *, options, metadata, isolated=False): + def __init__(self, socket_path, *, options, metadata, isolated=False, + previous_owner_identity=None): self.connection = Connection(socket_path) self.options = options self.metadata = metadata @@ -119,6 +138,7 @@ def __init__(self, socket_path, *, options, metadata, isolated=False): self.last_seq = 0 self.ready = asyncio.Event() self.owner_identity = None + self.previous_owner_identity = previous_owner_identity async def connect(self) -> None: await self.connection.connect() @@ -136,12 +156,32 @@ async def connect(self) -> None: claude_sdk_process_env(self.options.env, environment) if self.isolated else {**environment, **self.options.env} ) + worker_id = self.metadata.get("service_id") + from cc_remote.wrapper.process_scan import process_identity + + owner = process_identity(hello["pid"]) + if (worker_id and self.options.resume and not self.options.fork_session + and owner is not None and owner != self.previous_owner_identity + and service_owner_exited(self.previous_owner_identity)): + sessions = await self.connection.call("list") + if not any(item["id"] == worker_id or all( + item["metadata"].get(key) == self.metadata.get(key) + for key in ("profile_root", "session_id", "space", "work_id", "btw") + ) for item in sessions): + # A restarted service has no old in-memory worker. Resume + # the same native transcript using its full account/cwd + # identity, without submitting any prior accepted input. + # Keep the hint until open succeeds so a failed connection + # does not erase recovery authority. A replacement worker + # already owning this transcript needs ordinary replay + # recovery, not this idle control/new-input reconnect. + worker_id = None self.description = await self._open({ "options": payload, "metadata": self.metadata, "isolated": self.isolated, "fork": self.options.fork_session, - "session": self.metadata.get("service_id"), + "session": worker_id, "strict_session": bool(hello.get("strict_controller_leases")), }, legacy=not hello.get("strict_controller_leases")) except BaseException: diff --git a/cc_remote/wrapper/claude_external.py b/cc_remote/wrapper/claude_external.py index c099ea52..8255191d 100644 --- a/cc_remote/wrapper/claude_external.py +++ b/cc_remote/wrapper/claude_external.py @@ -41,6 +41,7 @@ _NEUTRAL_METADATA_TYPES = frozenset({ "ai-title", "atis-latch", + "cost-state", "mode", "permission-mode", "queue-operation", @@ -119,13 +120,15 @@ def _resolve_continue_target( def classify_claude_growth( data: bytes, owned_message_ids: Collection[str] = (), -) -> tuple[Literal["sdk", "external", "unknown"], tuple[str, ...]]: +) -> tuple[Literal["sdk", "external", "metadata", "unknown"], tuple[str, ...]]: """Attribute complete Claude JSONL growth without a time heuristic. Agent SDK transcript rows carry ``entrypoint=sdk-py`` (and user rows also carry ``promptSource=sdk``), while native TUI rows carry ``entrypoint=cli``. A few metadata rows have no direct origin; ``last-prompt`` and file-history rows can still be attributed through the message UUID they reference. + Cost-only growth is ``metadata``: it advances no conversation and proves + nothing about process ownership. Unknown or partial data deliberately remains unknown so the machine can fail closed unless an SDK operation is actively writing. An explicit @@ -200,6 +203,11 @@ def classify_claude_growth( if sdk_evidence: # Preserve insertion order while avoiding unbounded duplicate ids. return "sdk", tuple(dict.fromkeys(new_owned)) + # Reconnect/exit may flush statistics without any accompanying SDK rows. + # Keep this narrower than the attribution-neutral allowlist: a standalone + # mode/permission/queue update still needs its original ownership checks. + if all(row.get("type") == "cost-state" for row in rows): + return "metadata", () return "unknown", () diff --git a/cc_remote/wrapper/claude_rate_limits.py b/cc_remote/wrapper/claude_rate_limits.py index 3e71f003..f1fbef9d 100644 --- a/cc_remote/wrapper/claude_rate_limits.py +++ b/cc_remote/wrapper/claude_rate_limits.py @@ -196,53 +196,75 @@ def _persist(self) -> None: "Claude rate-limit cache could not be persisted") from exc @staticmethod - def _update( - rate_type: str, entry: dict[str, Any], - ) -> RateLimitUpdate: - limit_id, name, slot, duration = _RATE_LIMITS[rate_type] - window = StatusRateLimitWindow( - used_percent=entry.get("used_percent"), - resets_at=entry.get("resets_at"), - window_duration_mins=duration, - ) - return RateLimitUpdate( - limit_id=limit_id, - name=name, - # Empty is an explicit clear for a previously rejected window. - reached_type=(rate_type if entry.get("status") == "rejected" else ""), - primary=window if slot == "primary" else None, - secondary=window if slot == "secondary" else None, - ) + def _updates(limits: dict[str, dict[str, Any]]) -> tuple[RateLimitUpdate, ...]: + grouped: dict[str, dict[str, Any]] = {} + for rate_type in _RATE_LIMITS: + entry = limits.get(rate_type) + if entry is None: + continue + limit_id, name, slot, duration = _RATE_LIMITS[rate_type] + update = grouped.setdefault(limit_id, { + "limit_id": limit_id, "name": name, "reached_type": "", + }) + update[slot] = StatusRateLimitWindow( + used_percent=entry.get("used_percent"), + resets_at=entry.get("resets_at"), + window_duration_mins=duration, + ) + # Account windows share a bucket. An allowed companion window + # must not clear the rejection reported in this same observation. + if entry.get("status") == "rejected" and not update["reached_type"]: + update["reached_type"] = rate_type + return tuple(RateLimitUpdate(**update) for update in grouped.values()) - def observe(self, info: Any, *, now: int | None = None) -> RateLimitUpdate | None: + def observe(self, info: Any, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]: observed_at = int(time.time()) if now is None else int(now) rate_type = getattr(info, "rate_limit_type", None) - if rate_type not in _RATE_LIMITS: - return None - raw_reset = getattr(info, "resets_at", None) - resets_at = _reset_timestamp(raw_reset, observed_at) - if raw_reset is not None and resets_at is None: - if rate_type in self._limits: - self._limits.pop(rate_type, None) - self._persist() - return None - status = getattr(info, "status", None) - if status not in {"allowed", "allowed_warning", "rejected"}: - status = "allowed" - used_percent = _used_percent(getattr(info, "utilization", None)) - if status == "rejected": - used_percent = 100 - entry = { - "resets_at": resets_at, - "used_percent": used_percent, - "status": status, - "observed_at": observed_at, - } - changed = self._limits.get(rate_type) != entry - self._limits[rate_type] = entry + windows: dict[str, tuple[Any, Any, Any]] = {} + if isinstance(rate_type, str) and rate_type in _RATE_LIMITS: + windows[rate_type] = ( + getattr(info, "resets_at", None), + getattr(info, "utilization", None), + getattr(info, "status", None), + ) + # The pinned SDK preserves newer CLI fields in raw. Read only known + # public windows; never persist the rest of that payload. Top-level + # status belongs to rateLimitType, not every unified window. + raw = getattr(info, "raw", None) + unified = raw.get("unifiedWindows") if isinstance(raw, dict) else None + if isinstance(unified, dict): + for name in _RATE_LIMITS: + window = unified.get(name) + if not isinstance(window, dict) or not ( + "utilization" in window or "resetsAt" in window + ): + continue + reset, used, status = windows.get(name, (None, None, "allowed")) + windows[name] = ( + window.get("resetsAt", reset), + window.get("utilization", used), + status, + ) + changed = False + observed: dict[str, dict[str, Any]] = {} + for name, (raw_reset, used, status) in windows.items(): + resets_at = _reset_timestamp(raw_reset, observed_at) + if raw_reset is not None and resets_at is None: + changed = self._limits.pop(name, None) is not None or changed + continue + if status not in {"allowed", "allowed_warning", "rejected"}: + status = "allowed" + entry = { + "resets_at": resets_at, + "used_percent": 100 if status == "rejected" else _used_percent(used), + "status": status, + "observed_at": observed_at, + } + changed = self._limits.get(name) != entry or changed + self._limits[name] = observed[name] = entry if changed: self._persist() - return self._update(rate_type, entry) + return self._updates(observed) def snapshot(self, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]: observed_at = int(time.time()) if now is None else int(now) @@ -269,8 +291,4 @@ def snapshot(self, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]: for rate_type in expired: self._limits.pop(rate_type, None) self._persist() - return tuple( - self._update(rate_type, self._limits[rate_type]) - for rate_type in _RATE_LIMITS - if rate_type in self._limits - ) + return self._updates(self._limits) diff --git a/cc_remote/wrapper/codex_external.py b/cc_remote/wrapper/codex_external.py index d2fcdef8..a9ba4a62 100644 --- a/cc_remote/wrapper/codex_external.py +++ b/cc_remote/wrapper/codex_external.py @@ -1078,7 +1078,7 @@ def codex_user_item_text(item: object) -> str | None: def codex_rollout_user_message( payload: object, ) -> CodexRolloutUserMessage | None: - """Normalize legacy and 0.147 persisted user-message records.""" + """Normalize legacy and current persisted user-message records.""" if not isinstance(payload, dict): return None payload_type = payload.get("type") @@ -1096,7 +1096,7 @@ def codex_rollout_user_message( return None raw_text = codex_user_item_text(item) message_id = item.get("id") - client_id = item.get("clientId", client_id) + client_id = item.get("clientId") or item.get("client_id") or client_id else: return None if not isinstance(raw_text, str): diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index f14bf70b..22d4b9f7 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -17913,6 +17913,21 @@ async def _handle_takeover(self, cmd): # can recover a response that was lost with the original WebSocket. return None + async def _reject_query(self, ctx, cmd, error: Error) -> Error: + """A pre-acceptance rejection belongs to the sender, not the live turn. + + The reliable command cache handles lost ACKs. Never put this response + in the shared ring: reconnect replay rewrites recipients and would make + another browser mistake an unsent message for a failed engine turn. + """ + error.sid = (self._ctx_wire_sid(ctx) if ctx is not None + else getattr(cmd, "sid", None)) + error.msg_id = getattr(cmd, "msg_id", None) + error.request_id = getattr(cmd, "cmd_id", None) + error.to = getattr(cmd, "client_id", None) + await self.transport.send(error) + return error + async def _handle_query(self, cmd): sid = getattr(cmd, "sid", None) ctx = self._ctx_for(sid) @@ -17923,13 +17938,13 @@ async def _handle_query(self, cmd): error = Error(code=ERR_NOT_RUNNING, message="该会话未启动(可能启动失败),重新点进这个会话再发", msg_id=getattr(cmd, "msg_id", None)) - await self._emit_to_sid(sid, error) - return error + return await self._reject_query(ctx, cmd, error) if getattr(cmd, "delivery", "immediate") != "immediate": return await self._enqueue_deferred_query(ctx, cmd) async with ctx.query_lock: if not self._is_resident_context(ctx): - return await self._missing_session_error(cmd, "发送消息") + return await self._reject_query(ctx, cmd, Error( + code=ERR_NOT_RUNNING, message="该会话未启动,无法发送消息")) return await self._handle_immediate_query(ctx, cmd) async def _handle_immediate_query( @@ -17942,14 +17957,12 @@ async def _handle_immediate_query( if await self._refresh_btw_availability(ctx): error = Error(code=ERR_NOT_RUNNING, message=self.BTW_DESTROYED_MESSAGE, msg_id=getattr(cmd, "msg_id", None)) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if ctx.state != "idle": error = Error( code=ERR_BUSY, message="该会话正忙,先 interrupt", msg_id=getattr(cmd, "msg_id", None)) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if self._claude_autonomous_followup_pending(ctx): # Claude may emit a task notification after the parent Result and # immediately start an autonomous response while the wrapper still @@ -17961,8 +17974,7 @@ async def _handle_immediate_query( message="Claude 正在处理后台任务结果,请稍后重试或排队发送", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if ctx.engine == "claude" and ctx.space == "code": await self._adopt_claude_broker_handle(ctx) if ctx.state != "idle": @@ -17971,8 +17983,7 @@ async def _handle_immediate_query( message="该会话正由终端中的 Claude 回合运行,先 interrupt", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) is_claude_broker = bool(getattr(ctx.sdk, "is_claude_broker", False)) is_codex_shared = self._codex_shared_affinity(ctx) if ( @@ -17986,8 +17997,7 @@ async def _handle_immediate_query( else "Codex 共享通道重连失败,本次未发送;请重试"), msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if is_claude_broker: try: metadata = await ctx.sdk.refresh_status() @@ -18003,8 +18013,7 @@ async def _handle_immediate_query( message="Claude 连接暂不可用,本次消息未发送,请稍后重试。", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) else: # A terminal session exit was proven and the context is now # a fully connected SDK handle. Continue through the normal @@ -18020,8 +18029,7 @@ async def _handle_immediate_query( message="本机终端正在编辑输入;完成、发送或取消后再从 Remote 发送", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) route_sid = self._ctx_wire_sid(ctx) if route_sid: self._watch_session(route_sid) @@ -18050,15 +18058,13 @@ async def _handle_immediate_query( message=message, msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if not cmd.prompt and not cmd.images and not cmd.files: error = Error( code=ERR_BAD_PROMPT, message="消息内容为空,请输入内容或添加附件。", msg_id=getattr(cmd, "msg_id", None)) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) attachment_error = validate_attachments( getattr(cmd, "images", None), getattr(cmd, "files", None)) if attachment_error: @@ -18067,8 +18073,7 @@ async def _handle_immediate_query( message="附件不符合要求,请调整后重试。", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if ctx.space == "work" and ctx.work_id: try: await asyncio.to_thread( @@ -18083,8 +18088,7 @@ async def _handle_immediate_query( message="工作资料同步失败,本轮尚未发送;请重试", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if self._claude_autonomous_followup_pending(ctx): # Ownership and Work preflights above contain awaits. A task # notification can start its autonomous follow-up during one of @@ -18094,21 +18098,18 @@ async def _handle_immediate_query( message="Claude 正在处理后台任务结果,请稍后重试或排队发送", msg_id=getattr(cmd, "msg_id", None), ) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) if ctx.engine == "claude" and not is_claude_broker: context_error = await self._prepare_claude_context_before_query( ctx, msg_id=getattr(cmd, "msg_id", None)) if context_error is not None: - await self._emit(ctx, context_error) - return context_error + return await self._reject_query(ctx, cmd, context_error) if ctx.engine == "codex": await self._apply_codex_context(ctx) if ctx.state != "idle" or getattr(ctx.sdk, "turn_active", False): error = Error(code=ERR_BUSY, message="会话已开始新回合,请稍后重试或排队发送", msg_id=getattr(cmd, "msg_id", None)) - await self._emit(ctx, error) - return error + return await self._reject_query(ctx, cmd, error) # All synchronous rejection paths have passed. A new conversation may # now finish before the next sidebar catalog read, so remember its first # accepted prompt under the temporary key; capture migrates it to the @@ -19448,6 +19449,12 @@ async def _handle_set_model(self, cmd): if not self._is_resident_context(ctx): return await self._missing_session_error( cmd, "切换模型") + if getattr(ctx.sdk, "service_restart_required", False): + recovered = await self._reload_stale_claude_context_control( + ctx, expected_sdk=ctx.sdk, + expected_client=getattr(ctx.sdk, "client", None)) + if not recovered: + raise RuntimeError("Claude service recovery is not ready") await ctx.sdk.set_model(requested_model) else: await ctx.sdk.set_model(requested_model) @@ -20988,7 +20995,7 @@ async def _claude_context_refresh_readiness( # fail-closed boundary merely to paint Context metadata. if ctx.write_state != "writable": return "external" - if ctx.needs_reload: + if ctx.needs_reload or getattr(ctx.sdk, "service_restart_required", False): return "stale" return "ready" @@ -20999,11 +21006,12 @@ async def _reload_stale_claude_context_control( expected_sdk, expected_client, ) -> bool: - """Resume external transcript growth without crossing a new owner.""" + """Resume a stale transcript/dead service without crossing a new owner.""" if (ctx.sdk is not expected_sdk or getattr(ctx.sdk, "client", None) is not expected_client or getattr(ctx.sdk, "is_claude_broker", False) - or not ctx.needs_reload): + or not (ctx.needs_reload or getattr( + ctx.sdk, "service_restart_required", False))): return False readiness = await self._claude_context_refresh_readiness( ctx, @@ -21016,25 +21024,29 @@ async def _reload_stale_claude_context_control( async def reconnect_if_still_stale() -> bool: if (ctx.sdk is not expected_sdk or getattr(ctx.sdk, "client", None) is not expected_client - or not ctx.needs_reload + or not (ctx.needs_reload or getattr( + ctx.sdk, "service_restart_required", False)) or self._claude_context_work_active(ctx) or ctx.write_state != "writable"): return False resume_id, fork = self._claude_reconnect_identity(ctx) + transcript_changed = ctx.needs_reload # Clear first so a watcher append during reconnect can reassert the # stale bit without being overwritten after the await. ctx.needs_reload = False try: - await self._stage_claude_handoff_controls(ctx) + if transcript_changed: + await self._stage_claude_handoff_controls(ctx) await expected_sdk.force_reconnect( resume_id=resume_id, cwd=ctx.cwd, - reason="external transcript change before context", + reason=("external transcript change before context" + if transcript_changed else "persistent Claude service restart"), preserve_model=True, fork=fork, ) except Exception as reconnect_exc: - ctx.needs_reload = True + ctx.needs_reload = ctx.needs_reload or transcript_changed log.warning( "Claude context stale-generation reload failed", session_id=ctx.session_id, @@ -21728,12 +21740,12 @@ async def _observe_claude_rate_limit_message( return True async with self._claude_rate_limit_lock: try: - update = await asyncio.to_thread( + updates = await asyncio.to_thread( store.observe, message.rate_limit_info) except ClaudeRateLimitStoreError: log.warning("Claude rate-limit update could not be cached") return True - if update is None: + if not updates: return True # Each CLAUDE_CONFIG_DIR is an authentication boundary. Seed only # resident normal sessions from the same profile; @@ -21747,7 +21759,8 @@ async def _observe_claude_rate_limit_message( ) async with self._claude_rate_limit_emit_lock: for target in targets: - await self._emit(target, update.model_copy(deep=True)) + for update in updates: + await self._emit(target, update.model_copy(deep=True)) return True async def _observe_claude_model_fallback(self, ctx: SessionContext, message) -> None: @@ -37563,6 +37576,10 @@ async def _run_turn( codex_handoff_to_spontaneous = False codex_query_reconnected = False native_turn_id: Optional[str] = None + codex_initial_msg_id = ctx.active_msg_id + codex_initial_user_seen = False + codex_seen_user_items: set[str] = set() + codex_seen_user_clients: set[str] = set() file_meta = ([{"filename": item.get("filename", "attachment")} for item in (files or [])] or None) prelaunch_terminal_emitted = False @@ -37760,6 +37777,55 @@ async def next_turn_message(): wait_task.cancel() await asyncio.gather(wait_task, return_exceptions=True) + async def publish_codex_user(raw: dict) -> None: + """Move the visible segment on an exact managed-task user boundary.""" + nonlocal codex_initial_user_seen + user = codex_live_user_message(raw) + if user is None or user.turn_id != native_turn_id: + return + if user.client_id is not None: + await self._remember_codex_live_user_alias(ctx, user) + if user.message_id in codex_seen_user_items: + return + initial = bool(codex_initial_msg_id and codex_initial_msg_id in { + user.message_id, user.client_id, + }) + if not initial and not user.client_id and not codex_initial_user_seen: + # A missed initial echo cannot turn an unlabelled first item + # into a second input. Never guess from equal prompt text. + return + if len(codex_seen_user_items) >= self.CODEX_LIVE_USER_ITEM_IDS: + return + codex_seen_user_items.add(user.message_id) + if initial: + codex_initial_user_seen = True + return + if user.client_id is not None: + if ( + ctx.codex_published_steers.get(user.client_id) == native_turn_id + or user.client_id in codex_seen_user_clients + ): + # Remote already published its boundary at RPC acceptance. + # A late echo must not steal activity from a newer input. + return + codex_seen_user_clients.add(user.client_id) + msg_id = user.client_id or user.message_id + ctx.active_msg_id = msg_id + self._rebind_codex_turn(ctx, user.turn_id, msg_id) + await self._emit(ctx, TurnSteered( + msg_id=msg_id, + turn_id=user.turn_id, + prompt=user.prompt, + )) + if user.client_id is not None: + # Reconcile the canonical history id only after the boundary; + # pre-inserting UserMsg would leave the previous row open. + await self._emit(ctx, UserMsg( + msg_id=user.message_id, + client_msg_id=user.client_id, + prompt=user.prompt, + )) + async def emit_codex_event(event) -> None: nonlocal notice_active # Translator errors/progress are turn-local, but the translator is @@ -38012,7 +38078,8 @@ async def handoff_codex_account_switch( async def reconnect_claude(reason: str) -> None: """Reconnect without hiding transcript changes during the await.""" check_delivery = getattr(ctx.sdk, "check_service_delivery", None) - if check_delivery is not None: + if (check_delivery is not None + and not getattr(ctx.sdk, "service_restart_required", False)): check_delivery() external_change = reason.startswith("external transcript change") if external_change: @@ -38042,7 +38109,8 @@ async def reconnect_claude(reason: str) -> None: if not _recover_service and not _adopt_steer: if not is_codex: check_delivery = getattr(ctx.sdk, "check_service_delivery", None) - if check_delivery is not None: + if (check_delivery is not None + and not getattr(ctx.sdk, "service_restart_required", False)): check_delivery() # An EXTERNAL process (a native `claude`/`codex` in the user's terminal) # appended to this session's transcript since we resumed it, so our child's @@ -38095,7 +38163,8 @@ async def reconnect_claude(reason: str) -> None: # the failed prompt automatically because it may already have run. if ( not is_codex - and getattr(ctx.sdk, "message_pump_failed", False) + and (getattr(ctx.sdk, "message_pump_failed", False) + or getattr(ctx.sdk, "service_restart_required", False)) ): log.warning( "recovering failed Claude SDK message pump before query", @@ -38489,17 +38558,7 @@ async def msg_stream(): continue await ctx.codex_steer_gate.wait() await self._confirm_uncertain_codex_steer(ctx, msg) - live_user = codex_live_user_message(msg) - if ( - live_user is not None - and live_user.client_id is not None - ): - # Only an upstream clientId can prove a native user-item - # alias. Initial Query also keeps its source-bound - # segment-0 fallback for older app-server generations; - # never guess from an unlabelled prompt or timestamp. - await self._remember_codex_live_user_alias( - ctx, live_user) + await publish_codex_user(msg) sid = codex_session_id(msg) if sid and not ctx.session_id: await self._capture_session_id(ctx, sid) diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index bd861118..5e863f0d 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -233,6 +233,7 @@ def __init__( self.client: ClaudeSDKClient | None = None self.service_metadata: dict | None = None self.service_socket_override: str | None = None + self._service_owner_identity = None self.service_recovery: dict | None = None self.service_turn_metadata: dict | None = None self.service_defer_events = False @@ -409,7 +410,15 @@ def _options( "set_mode('plan'); 'just do it' / 'go ahead' -> set_mode('bypassPermissions') " "or 'acceptEdits'. The user has no Shift+Tab here, so calling this is how you " "enter plan mode for them.\n" - "Modes: default, acceptEdits, plan, auto, bypassPermissions." + "Modes: default, acceptEdits, plan, auto, bypassPermissions.\n\n" + "The user is reading your replies in cc-remote's browser. When sharing " + "files or directories available on this machine, use clickable Markdown " + "links with absolute paths, for example [Listen]() " + "or [Open folder](), rather than bare paths or " + "file:// URLs. These links open cc-remote's file browser or preview, " + "including audio playback. Link directly to existing audio files when " + "the user asks to listen; no separate HTML page or web server is needed. " + "Only link to real files and directories; preserve their actual paths." ) # showThinkingSummaries is an interactive CLI preference. SDK sessions # must request the readable summary explicitly. Pass only display here: @@ -612,6 +621,7 @@ async def connect( "applied_effort": launch_effort, }, isolated=self.isolate_account_env, + previous_owner_identity=self._service_owner_identity, ) elif self.isolate_account_env: self.client = ClaudeSDKClient( @@ -622,6 +632,11 @@ async def connect( self.client = ClaudeSDKClient(options=opts) self._conversation_rewind_capability = None await self.client.connect() + if self.service_metadata is not None and getattr(self.client, "id", None): + # Retain both across failed closes/connects. A worker ID alone + # cannot distinguish a restarted service from a lost live lease. + self.service_metadata["service_id"] = self.client.id + self._service_owner_identity = self.client.owner_identity self.service_recovery = getattr(self.client, "recovery", None) description = getattr(self.client, "description", {}) if description.get("attached"): @@ -1881,6 +1896,14 @@ async def _receive_response_pumped(self): finally: self._turn_consumer_active = False + @property + def service_restart_required(self) -> bool: + """A confirmed dead service may be resumed, never its accepted query.""" + from cc_remote.claude_service.client import service_owner_exited + + return self.service_metadata is not None and service_owner_exited( + self._service_owner_identity) + @property def message_pump_failed(self) -> bool: """Whether this client must be reconnected before another query.""" diff --git a/docs/claude-session-service.md b/docs/claude-session-service.md index d94dc4ee..f7e8ac93 100644 --- a/docs/claude-session-service.md +++ b/docs/claude-session-service.md @@ -36,9 +36,17 @@ that exact worker retries lease conflicts for at most five seconds. This does not replace a live controller, resubmit a prompt or retry an unknown response. Older services' coarse conflict errors are checked against the listed worker's full identity before retry. When strict leases are negotiated, a missing explicit -worker is rejected; only confirmed native close clears the Wrapper's worker -identity for a deliberate reconnect. Older controllers keep their existing -reconnect behavior without opting into strict leases. +worker is rejected. A confirmed native close clears the Wrapper's worker identity +for a deliberate reconnect. After a service restart, the Wrapper can also resume +the same native transcript when it proves that the previous process identity has +exited, the socket belongs to a different process generation, and neither the old +worker nor a replacement owning that transcript is present. An unreadable process +identity or a live old owner is not sufficient. +Model switching and explicit idle context refresh use this recovery before their +control request; the next user query can recover too, without resubmitting any +previously accepted input. Cached history/context reads never trigger it. Older +controllers keep their existing reconnect behavior without opting into strict +leases. Accepted steering uploads survive reader/control failures and ordinary service detach because their native turn may still need them. A confirmed native close diff --git a/tests/test_claude_external.py b/tests/test_claude_external.py index 865d8cbc..f01fddbd 100644 --- a/tests/test_claude_external.py +++ b/tests/test_claude_external.py @@ -38,6 +38,20 @@ def _watch(path) -> dict: } +@pytest.fixture +def cost_state_row() -> bytes: + return (json.dumps({ + "type": "cost-state", + "sessionId": "sid", + "totalCostUSD": 0.06, + "totalAPIDuration": 1200, + "totalDuration": 10000, + "modelUsage": { + "test-model": {"inputTokens": 12, "outputTokens": 3}, + }, + }) + "\n").encode() + + def _fake_process( root: Path, pid: int, @@ -1239,6 +1253,110 @@ def test_claude_growth_classifier_rejects_partial_jsonl(): ) == ("unknown", ()) +@pytest.mark.parametrize("count", [1, 3]) +def test_cost_state_only_growth_is_metadata(cost_state_row, count): + assert classify_claude_growth(cost_state_row * count) == ("metadata", ()) + + +@pytest.mark.parametrize("row, expected", [ + ({"type": "assistant", "entrypoint": "sdk-py", "uuid": "sdk-msg"}, + ("sdk", ("sdk-msg",))), + ({"type": "user", "entrypoint": "cli"}, ("external", ())), + ({"type": "cost-state", "entrypoint": "cli"}, ("external", ())), + ({"type": "cost-state", "promptSource": "cli"}, ("external", ())), + ({"type": "user"}, ("unknown", ())), + ({"type": "future-metadata"}, ("unknown", ())), + ({"type": "ai-title"}, ("unknown", ())), + ({"type": "mode"}, ("unknown", ())), + ({"type": "permission-mode"}, ("unknown", ())), + ({"type": "queue-operation"}, ("unknown", ())), +]) +def test_cost_state_does_not_hide_other_growth(cost_state_row, row, expected): + data = cost_state_row + (json.dumps(row) + "\n").encode() + cost_state_row + assert classify_claude_growth(data) == expected + + +@pytest.mark.parametrize("suffix", [b'{"type":"assistant"', b"not-json\n"]) +def test_cost_state_does_not_hide_incomplete_growth(cost_state_row, suffix): + assert classify_claude_growth(cost_state_row + suffix) == ("unknown", ()) + assert classify_claude_growth(cost_state_row[:-1]) == ("unknown", ()) + + +@pytest.mark.parametrize("foreign_owner, scan_complete, pending_reload", [ + (False, True, False), + (True, True, False), + (False, False, False), + (False, True, True), +]) +def test_cost_state_watch_preserves_ownership_protection( + tmp_path, cost_state_row, foreign_owner, scan_complete, pending_reload, +): + async def go(): + machine, _ = _mk_machine() + path = tmp_path / "session.jsonl" + path.write_bytes(b"") + ctx = _mk_ctx("sid", "sid") + ctx.needs_reload = pending_reload + invalidations = [] + ctx.sdk = SimpleNamespace( + invalidate_context_usage_cache=lambda: invalidations.append(True)) + machine.sessions["sid"] = ctx + watch = _watch(path) + machine._watch["sid"] = watch + mirrored = [] + + async def mirror(sid): + mirrored.append(sid) + + machine._push_mirrored_history = mirror + path.write_bytes(cost_state_row * 3) + holders = {ProcessIdentity(102, 1002)} if foreign_owner else set() + await machine._poll_claude_watch( + "sid", watch, holders, 1000.0, + ownership_scan_complete=scan_complete, + ) + + assert watch["size"] == path.stat().st_size + assert ctx.needs_reload is (foreign_owner or pending_reload) + assert machine._is_external("sid") is (foreign_owner or not scan_complete) + assert bool(invalidations) is foreign_owner + assert bool(mirrored) is (foreign_owner or not scan_complete) + assert ctx.external_ts == (1000.0 if foreign_owner else 0.0) + + asyncio.run(go()) + + +@pytest.mark.parametrize("replace_file", [False, True]) +def test_cost_state_does_not_hide_transcript_replacement( + tmp_path, cost_state_row, replace_file, +): + async def go(): + machine, _ = _mk_machine() + path = tmp_path / "session.jsonl" + path.write_bytes(cost_state_row * 3) + ctx = _mk_ctx("sid", "sid") + machine.sessions["sid"] = ctx + watch = _watch(path) + machine._watch["sid"] = watch + machine._push_mirrored_history = lambda _sid: asyncio.sleep(0) + + if replace_file: + replacement = tmp_path / "replacement.jsonl" + replacement.write_bytes(cost_state_row * 3) + replacement.replace(path) + else: + path.write_bytes(cost_state_row) + await machine._poll_claude_watch( + "sid", watch, set(), 1000.0, + ownership_scan_complete=True, + ) + + assert ctx.needs_reload is True + assert ctx.claude_native_controls_dirty is True + + asyncio.run(go()) + + @pytest.mark.parametrize("entrypoint, expected", [("sdk-py", "sdk"), ("cli", "external"), (None, "external")]) def test_injected_system_prompt_keeps_its_native_process_provenance(entrypoint, expected): row = {"type": "user", "uuid": "injected", "entrypoint": entrypoint, @@ -1690,6 +1808,62 @@ async def go(): asyncio.run(go()) +@pytest.mark.parametrize("poll_during_reconnect", [False, True]) +def test_cost_state_during_effort_reconnect_does_not_cancel_query( + tmp_path, monkeypatch, cost_state_row, poll_during_reconnect, +): + async def go(): + machine, transport = _mk_machine() + path = tmp_path / "session.jsonl" + path.write_bytes(b"") + ctx = _mk_ctx("sid", "sid") + ctx.state = "running" + ctx.active_msg_id = "msg-effort" + sdk = _ClaudeRunSdk() + sdk.effort = "high" + ctx.sdk = sdk + machine.sessions["sid"] = ctx + watch = _watch(path) + machine._watch["sid"] = watch + mirrored = [] + + async def mirror(sid): + mirrored.append(sid) + + machine._push_mirrored_history = mirror + + async def probe(_paths, _cwds): + return HolderScan({"sid": set()}, True) + + monkeypatch.setattr(machine, "_probe_claude_holders", probe) + reconnect = sdk.force_reconnect + + async def reconnect_with_cost_state(**kwargs): + await reconnect(**kwargs) + with path.open("ab") as stream: + stream.write(cost_state_row) + if poll_during_reconnect: + await machine._poll_claude_watch( + "sid", watch, set(), 1000.0, + ownership_scan_complete=True, + ) + + monkeypatch.setattr(sdk, "force_reconnect", reconnect_with_cost_state) + await machine._run_turn(ctx, "hello") + + assert sdk.reconnects == 1 + assert sdk.reconnect_args[0]["reason"] == "effort change" + assert sdk.applied_effort == "high" + assert sdk.queries == 1 + assert ctx.state == "idle" + assert ctx.needs_reload is False + assert watch["size"] == path.stat().st_size + assert mirrored == [] + assert not [event for event in transport.sent if event.type == "error"] + + asyncio.run(go()) + + def test_short_external_append_is_reloaded_before_claude_query( tmp_path, monkeypatch, ): diff --git a/tests/test_claude_rate_limits.py b/tests/test_claude_rate_limits.py index b21fbeef..c3caf85a 100644 --- a/tests/test_claude_rate_limits.py +++ b/tests/test_claude_rate_limits.py @@ -6,8 +6,10 @@ from types import SimpleNamespace import pytest +from claude_agent_sdk._internal.message_parser import parse_message from claude_agent_sdk.types import RateLimitEvent, RateLimitInfo +from cc_remote.claude_service.wire import decode_sdk, encode_sdk from cc_remote.wrapper.claude_rate_limits import ( ClaudeRateLimitStore, ClaudeRateLimitStoreError, @@ -33,12 +35,140 @@ def _info( ) +def _unified_event(now: int, **overrides) -> RateLimitEvent: + return parse_message({ + "type": "rate_limit_event", + "uuid": "quota-event", + "session_id": "native-claude", + "rate_limit_info": { + "status": "allowed", + "rateLimitType": "five_hour", + "resetsAt": now + 3_600, + "unifiedWindows": { + "five_hour": {"utilization": 0.01, "resetsAt": now + 3_600}, + "seven_day": {"utilization": 0, "resetsAt": now + 86_400}, + }, + **overrides, + }, + }) + + +@pytest.mark.parametrize("persistent_service", [False, True]) +def test_native_unified_windows_survive_sdk_projection_and_cache_reload( + tmp_path, persistent_service, +): + now = int(time.time()) + event = _unified_event(now, credential="must-not-persist") + if persistent_service: + event = decode_sdk(encode_sdk(event)) + store = ClaudeRateLimitStore(tmp_path) + store.observe(event.rate_limit_info, now=now) + + windows = { + window.window_duration_mins: window + for update in store.snapshot(now=now) + for window in (update.primary, update.secondary) if window is not None + } + assert {duration: window.used_percent for duration, window in windows.items()} == { + 300: 1, 10_080: 0, + } + assert windows[300].resets_at == now + 3_600 + assert windows[10_080].resets_at == now + 86_400 + reloaded = ClaudeRateLimitStore(tmp_path).snapshot(now=now + 1) + assert [(update.primary.used_percent, update.secondary.used_percent) + for update in reloaded] == [(1, 0)] + assert "credential" not in store.path.read_text() + assert "must-not-persist" not in store.path.read_text() + + +@pytest.mark.parametrize("rate_type", [None, "overage", "future-window"]) +def test_unified_windows_do_not_require_a_known_top_level_limit(tmp_path, rate_type): + now = int(time.time()) + store = ClaudeRateLimitStore(tmp_path) + update, = store.observe(_unified_event( + now, rateLimitType=rate_type, status="rejected", + ).rate_limit_info, now=now) + + assert update.primary.used_percent == 1 + assert update.secondary.used_percent == 0 + assert update.reached_type == "", "an unrelated rejection must not exhaust these windows" + + +@pytest.mark.parametrize("rejected_type", ["five_hour", "seven_day", "seven_day_opus"]) +def test_unified_rejection_applies_only_to_its_own_window(tmp_path, rejected_type): + now = int(time.time()) + store = ClaudeRateLimitStore(tmp_path) + windows = { + name: {"utilization": used, "resetsAt": now + 10_000} + for name, used in (("five_hour", 0.01), ("seven_day", 0), + ("seven_day_opus", 0.23), ("seven_day_sonnet", 0.45)) + } + windows["unknown"] = {"utilization": 1, "credential": "must-not-persist"} + updates = store.observe(_unified_event( + now, rateLimitType=rejected_type, status="rejected", unifiedWindows=windows, + ).rate_limit_info, now=now) + for projected in (updates, ClaudeRateLimitStore(tmp_path).snapshot(now=now)): + main, opus, sonnet = projected + assert main.primary.used_percent == (100 if rejected_type == "five_hour" else 1) + assert main.secondary.used_percent == (100 if rejected_type == "seven_day" else 0) + assert main.reached_type == (rejected_type if rejected_type != "seven_day_opus" else "") + assert opus.limit_id == "claude-seven-day-opus" + assert opus.primary.used_percent == (100 if rejected_type == "seven_day_opus" else 23) + assert sonnet.primary.used_percent == 45 and sonnet.reached_type == "" + assert "unknown" not in store.path.read_text() + assert "credential" not in store.path.read_text() + + +@pytest.mark.parametrize("unified", [None, [], "invalid", { + "five_hour": None, "seven_day": [], "seven_day_opus": {}, +}]) +def test_malformed_unified_shapes_retain_legacy_fields(tmp_path, unified): + now = int(time.time()) + update, = ClaudeRateLimitStore(tmp_path).observe(_unified_event( + now, utilization=0.25, unifiedWindows=unified, + ).rate_limit_info, now=now) + assert update.primary.used_percent == 25 and update.secondary is None + + +@pytest.mark.parametrize("used", [None, False, -0.1, 1.1, "0.5", float("nan"), float("inf")]) +def test_unified_utilization_is_validated_without_guessing_units(tmp_path, used): + now = int(time.time()) + update, = ClaudeRateLimitStore(tmp_path).observe(_unified_event( + now, utilization=0.75, unifiedWindows={ + "five_hour": {"utilization": used, "resetsAt": now + 1_000}, + "seven_day": {"utilization": 0, "resetsAt": now + 10_000}, + }, + ).rate_limit_info, now=now) + assert update.primary.used_percent is None + assert update.secondary.used_percent == 0 + + +def test_unified_windows_expire_independently_and_keep_unmentioned_buckets(tmp_path): + now = int(time.time()) + store = ClaudeRateLimitStore(tmp_path) + store.observe(_info("seven_day_opus", resets_at=now + 20_000), now=now) + update, = store.observe(_unified_event(now, utilization=0.9).rate_limit_info, now=now) + assert update.primary.used_percent == 1, "nested utilization overrides the legacy field" + event = _unified_event(now, unifiedWindows={ + "five_hour": {"utilization": 0.8, "resetsAt": now}, + "seven_day": {"utilization": 0, "resetsAt": now + 10_000}, + }) + update, = store.observe(event.rate_limit_info, now=now) + assert update.primary is None and update.secondary.used_percent == 0 + main, opus = ClaudeRateLimitStore(tmp_path).snapshot(now=now) + assert main.primary is None and main.secondary.used_percent == 0 + assert opus.primary.used_percent == 37 + assert [row.limit_id for row in store.snapshot(now=now + 10_000)] == [ + "claude-seven-day-opus", + ] + + def test_claude_rate_limit_store_sanitizes_and_replays_windows(tmp_path): now = int(time.time()) store = ClaudeRateLimitStore(tmp_path) - five_hour = store.observe( + five_hour, = store.observe( _info("five_hour", resets_at=now + 10_000), now=now) - weekly = store.observe( + weekly, = store.observe( _info( "seven_day", resets_at=now + 20_000, utilization=0.82, status="rejected", @@ -62,8 +192,7 @@ def test_claude_rate_limit_store_sanitizes_and_replays_windows(tmp_path): restored = ClaudeRateLimitStore(tmp_path).snapshot(now=now + 1) assert [(event.limit_id, bool(event.primary), bool(event.secondary)) for event in restored] == [ - ("claude", True, False), - ("claude", False, True), + ("claude", True, True), ] @@ -71,11 +200,11 @@ def test_claude_rate_limit_store_handles_specialized_expiry_and_bad_usage( tmp_path, ): store = ClaudeRateLimitStore(tmp_path) - opus = store.observe( + opus, = store.observe( _info("seven_day_opus", resets_at=11_000, utilization=1.5), now=10_000, ) - sonnet = store.observe( + sonnet, = store.observe( _info("seven_day_sonnet", resets_at=12_000, utilization=None), now=10_000, ) @@ -94,7 +223,7 @@ def test_claude_rejection_without_reset_or_usage_remains_visible(tmp_path): now = int(time.time()) store = ClaudeRateLimitStore(tmp_path) - rejected = store.observe(_info( + rejected, = store.observe(_info( "five_hour", resets_at=None, utilization=None, @@ -125,7 +254,7 @@ def test_claude_allowed_without_reset_clears_rejection_state(tmp_path): status="rejected", ), now=now) - allowed = store.observe(_info( + allowed, = store.observe(_info( "seven_day", resets_at=None, utilization=None, @@ -141,10 +270,10 @@ def test_claude_allowed_without_reset_clears_rejection_state(tmp_path): def test_claude_rate_limit_store_ignores_unknown_or_stale_events(tmp_path): store = ClaudeRateLimitStore(tmp_path) - assert store.observe(_info("overage"), now=10_000) is None + assert store.observe(_info("overage"), now=10_000) == () assert store.observe( _info("five_hour", resets_at=10_000), now=10_000, - ) is None + ) == () assert store.snapshot(now=10_000) == () @@ -168,7 +297,8 @@ def _event(rate_type: str, utilization: float) -> RateLimitEvent: ) -def test_machine_publishes_sdk_rate_limits_to_every_resident_claude_session(): +@pytest.mark.parametrize("unified", [False, True]) +def test_machine_publishes_sdk_rate_limits_to_every_resident_claude_session(unified): async def run(): machine, transport = _mk_machine() code = _mk_ctx("claude-code", "claude-code") @@ -183,13 +313,15 @@ async def run(): } assert await machine._observe_claude_rate_limit_message( - code, _event("five_hour", 0.25)) is True + code, _unified_event(int(time.time())) if unified else _event("five_hour", 0.25)) is True published = [message for message in transport.sent if message.type == "rate_limit_update"] assert {message.sid for message in published} == { "claude-code", "claude-work", } - assert all(message.primary.used_percent == 25 for message in published) + assert all(message.primary.used_percent == (1 if unified else 25) for message in published) + if unified: + assert all(message.secondary.used_percent == 0 for message in published) assert code.buffer.tail_seq == 0 and work.buffer.tail_seq == 0 transport.sent.clear() @@ -206,13 +338,14 @@ async def run(): asyncio.run(run()) -def test_hello_reseeds_unexpired_claude_limits_without_a_model_probe(): +@pytest.mark.parametrize("unified", [False, True]) +def test_hello_reseeds_unexpired_claude_limits_without_a_model_probe(unified): async def run(): machine, transport = _mk_machine() ctx = _mk_ctx("claude-session", "claude-session") machine.sessions[ctx.key] = ctx await machine._observe_claude_rate_limit_message( - ctx, _event("five_hour", 0.4)) + ctx, _unified_event(int(time.time())) if unified else _event("five_hour", 0.4)) transport.sent.clear() await machine._handle_client_hello(SimpleNamespace( @@ -226,13 +359,16 @@ async def run(): assert limits[0].sid == "claude-session" assert limits[0].to == "client-1" assert limits[0].route_id == "route-1" - assert limits[0].primary.used_percent == 40 + assert limits[0].primary.used_percent == (1 if unified else 40) + if unified: + assert limits[0].secondary.used_percent == 0 asyncio.run(run()) +@pytest.mark.parametrize("unified", [False, True]) def test_machine_keeps_rate_limits_inside_claude_profile( - tmp_path, monkeypatch, + tmp_path, monkeypatch, unified, ): async def run(): personal_root = tmp_path / "personal" @@ -267,7 +403,7 @@ async def run(): } assert await machine._observe_claude_rate_limit_message( - company, _event("five_hour", 0.6), + company, _unified_event(int(time.time())) if unified else _event("five_hour", 0.6), ) is True published = [ @@ -278,6 +414,8 @@ async def run(): assert await machine._claude_rate_limit_snapshot(personal) == () company_snapshot = await machine._claude_rate_limit_snapshot(company) assert len(company_snapshot) == 1 - assert company_snapshot[0].primary.used_percent == 60 + assert company_snapshot[0].primary.used_percent == (1 if unified else 60) + if unified: + assert company_snapshot[0].secondary.used_percent == 0 asyncio.run(run()) diff --git a/tests/test_claude_service_restart.py b/tests/test_claude_service_restart.py new file mode 100644 index 00000000..53eccc13 --- /dev/null +++ b/tests/test_claude_service_restart.py @@ -0,0 +1,227 @@ +"""A new service generation can resume a transcript, never an accepted input.""" + +import asyncio +import os +from contextlib import asynccontextmanager + +import pytest + +from cc_remote.claude_service import client as client_module +from cc_remote.claude_service.client import RemoteClient, service_owner_exited +from cc_remote.config import WrapperConfig +from cc_remote.protocol import ContextReport, Error, GetContext, SetModel, TurnEnd +from cc_remote.wrapper import process_scan +from cc_remote.wrapper.process_scan import ProcessIdentity +from cc_remote.wrapper.sdk import SdkHandle +from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk +from tests.test_claude_live_context import SUMMARY +from tests.test_claude_service import FakeClient, environment +from tests.test_claude_service_delivery import wait_until + + +@asynccontextmanager +async def restarted_service(monkeypatch): + # Same PID with a new start time also covers PID reuse. No real daemon or + # model is touched: only the local Unix transport and service are real. + generation = [1] + def identity(pid): + return ProcessIdentity(pid, generation[0]) + monkeypatch.setattr(process_scan, "process_identity", identity) + controls = [] + + async def control(client, request, timeout): + controls.append(request) + return dict(SUMMARY) if request["subtype"] == "get_context_usage" else {} + + monkeypatch.setattr(FakeClient, "_send_control_request", control, raising=False) + async with environment() as (service, _attach): + sdk = SdkHandle(WrapperConfig( + claude_service_socket=str(service.directory / "service.sock"))) + machine, transport, ctx = _machine_with_sdk(sdk) + sdk.service_metadata = { + "profile_root": str(service.directory / "profile"), + "session_id": SESSION_ID, "cwd": ctx.cwd, "space": "code", + } + await sdk.connect(resume_id=SESSION_ID, cwd=ctx.cwd) + first = sdk.client + old_worker = service.sessions[first.id] + # It is deliberately not completed. Restart recovery must not try to + # finish it by submitting its prompt again to the new service. + first.next_turn = {"id": "old-input", "prompt": "accepted before restart"} + await first.query("accepted before restart") + await first.detach() + await old_worker.close() + service.sessions.clear() + generation[0] += 1 + await wait_until(lambda: sdk.message_pump_failed) + try: + yield sdk, machine, transport, ctx, service, old_worker, controls + finally: + await sdk.detach_for_shutdown() + + +@pytest.mark.asyncio +async def test_model_switch_after_service_restart_resumes_exact_session(monkeypatch): + async with restarted_service(monkeypatch) as ( + sdk, machine, _, ctx, service, old, controls, + ): + assert sdk.service_restart_required + reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5")) + assert not isinstance(reply, Error) + assert sdk.model == "sonnet-5.5" + assert controls == [{"subtype": "set_model", "model": "sonnet-5.5"}] + worker = service.sessions[sdk.client.id] + assert worker.id != old.id + assert sdk.service_metadata["service_id"] == worker.id + assert worker.client.options.resume == SESSION_ID + assert worker.client.options.cwd == ctx.cwd + assert worker.metadata["profile_root"] == old.metadata["profile_root"] + assert old.client.prompts == ["accepted before restart"] + assert worker.client.prompts == [] + assert not sdk.service_restart_required + + +@pytest.mark.asyncio +async def test_context_refresh_recovers_but_cached_read_does_not(monkeypatch): + async with restarted_service(monkeypatch) as ( + sdk, machine, _, ctx, service, old, controls, + ): + sdk.remember_recent_context_usage(dict(SUMMARY)) + await machine._handle_get_context(GetContext(sid=SESSION_ID)) + assert not service.sessions and controls == [] + report = await machine._handle_get_context(GetContext(sid=SESSION_ID, refresh=True)) + assert isinstance(report, ContextReport) and report.source == "control" + assert report.total_tokens == SUMMARY["totalTokens"] + assert service.sessions[sdk.client.id].client.prompts == [] + assert controls == [{"subtype": "get_context_usage", "detail": "summary"}] + assert old.client.prompts == ["accepted before restart"] + + +@pytest.mark.asyncio +async def test_new_query_recovers_after_service_died_during_ack(monkeypatch): + async with restarted_service(monkeypatch) as ( + sdk, machine, transport, ctx, service, old, _, + ): + sdk._service_delivery_error = ConnectionError("old service died during ACK") + ctx.active_msg_id = "new-input" + ctx.state = "running" + + async def query(client, prompt): + client.prompts.append(prompt) + await client.queue.put({ + "type": "result", "subtype": "success", "duration_ms": 20, + "duration_api_ms": 19, "is_error": False, "num_turns": 1, + "session_id": SESSION_ID, + }) + + monkeypatch.setattr(FakeClient, "query", query) + await asyncio.wait_for(machine._run_turn(ctx, "new explicit question"), 3) + worker = service.sessions[sdk.client.id] + assert worker.client.prompts == ["new explicit question"] + assert old.client.prompts == ["accepted before restart"] + assert len([frame for frame in transport.sent if isinstance(frame, TurnEnd)]) == 1 + assert not [frame for frame in transport.sent if isinstance(frame, Error)] + assert worker.turn is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("blocked", ["active", "external", "callback"]) +async def test_model_recovery_yields_to_active_or_external_work(monkeypatch, blocked): + async with restarted_service(monkeypatch) as ( + sdk, machine, _, ctx, service, old, controls, + ): + if blocked == "active": + ctx.state = "running" + elif blocked == "external": + async def external(_sid): + return True + machine._prime_claude_ownership = external + else: + sdk._background_callbacks_pending = 1 + reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5")) + assert isinstance(reply, Error) + assert not service.sessions and controls == [] + assert sdk.service_metadata["service_id"] == old.id + + +@pytest.mark.asyncio +async def test_unavailable_service_keeps_restart_identity_for_later_retry(monkeypatch): + async with restarted_service(monkeypatch) as ( + sdk, machine, _, ctx, service, old, controls, + ): + connect = client_module.Connection.connect + + async def unavailable(_self): + raise ConnectionRefusedError("service is starting") + + monkeypatch.setattr(client_module.Connection, "connect", unavailable) + reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5")) + assert isinstance(reply, Error) + assert sdk.service_restart_required + assert sdk.service_metadata["service_id"] == old.id + assert not service.sessions and controls == [] + monkeypatch.setattr(client_module.Connection, "connect", connect) + reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5")) + assert not isinstance(reply, Error) + assert service.sessions[sdk.client.id].client.prompts == [] + + +@pytest.mark.asyncio +async def test_restart_does_not_take_over_a_replacement_worker(monkeypatch): + async with restarted_service(monkeypatch) as ( + sdk, machine, _, ctx, service, old, controls, + ): + another = RemoteClient(sdk.client.connection.socket_path, options=sdk.client.options, + metadata={**old.metadata, "service_id": None}) + try: + await another.connect() + another.next_turn = {"id": "another-input", "prompt": "another controller"} + await another.query("another controller") + await another.detach() + reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5")) + assert isinstance(reply, Error) + assert list(service.sessions) == [another.id] + worker = service.sessions[another.id] + assert worker.turn["id"] == "another-input" + assert worker.client.prompts == ["another controller"] + assert not worker.client.closed and not controls + finally: + await another.detach() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("owner", ["live", "unknown", "unreadable"]) +async def test_missing_worker_is_not_recreated_without_proven_restart(monkeypatch, owner): + async with environment() as (service, attach): + first = await attach() + first.options.resume = "native-session" + identity = first.owner_identity if owner != "unknown" else None + if owner == "unreadable": + monkeypatch.setattr(process_scan, "process_identity", lambda _pid: None) + client = RemoteClient(first.connection.socket_path, options=first.options, + metadata={**first.metadata, "service_id": "missing-worker"}, + previous_owner_identity=identity) + try: + with pytest.raises(RuntimeError, match="KeyError"): + await client.connect() + assert list(service.sessions) == [first.id] + assert service.sessions[first.id].client.prompts == [] + finally: + await client.detach() + + +@pytest.mark.parametrize("probe", ["alive", "denied", "absent", "reused"]) +def test_service_owner_exit_proof_distinguishes_unknown_pid(monkeypatch, probe): + expected = ProcessIdentity(os.getpid(), 1) + monkeypatch.setattr(process_scan, "process_identity", lambda pid: ( + ProcessIdentity(pid, 2) if probe == "reused" else None)) + + def kill(pid, signal): + assert pid == expected.pid and signal == 0 + if probe == "denied": + raise PermissionError() + if probe == "absent": + raise ProcessLookupError() + + monkeypatch.setattr(client_module.os, "kill", kill) + assert service_owner_exited(expected) == (probe in {"absent", "reused"}) diff --git a/tests/test_codex_controls.py b/tests/test_codex_controls.py index f5379b76..ba0d2ab3 100644 --- a/tests/test_codex_controls.py +++ b/tests/test_codex_controls.py @@ -6932,6 +6932,116 @@ async def receive_response(self): asyncio.run(run()) +@pytest.mark.parametrize("initial_echo", [False, True]) +def test_managed_codex_turn_projects_external_steers_once(initial_echo): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("managed-steers", "managed-steers") + ctx.engine = "codex" + ctx.state = "running" + ctx.active_msg_id = "browser-original" + ctx.turn_task = asyncio.current_task() + machine.sessions[ctx.key] = ctx + observations = [] + first_steer_output = asyncio.Event() + original_send = transport.send + + async def observe(message): + if isinstance(message, Delta): + lease = machine._codex_turn_leases.get(ctx.key) + observations.append(( + message.text, ctx.state, ctx.active_msg_id, + ctx.active_turn_binding.msg_id, + lease.msg_id if lease else None, + )) + if message.text == "one": + first_steer_output.set() + await original_send(message) + + transport.send = observe + + def user(item_id, client_id, *, turn_id="native-turn", method="item/completed"): + return {"method": method, "params": { + "threadId": ctx.session_id, "turnId": turn_id, + "item": {"type": "userMessage", "id": item_id, + "clientId": client_id, "text": "same prompt"}, + }} + + def delta(name): + return {"method": "item/agentMessage/delta", "params": { + "threadId": ctx.session_id, "turnId": "native-turn", + "itemId": "answer-" + name, "delta": name, + }} + + class AcceptedSdk: + tier_dirty = False + model = None + effort = None + collaboration_mode = "default" + service_tier = None + shared_daemon_affinity = True + + async def query(self, _prompt, images=None, *, client_user_message_id=None): + assert client_user_message_id == "browser-original" + return "native-turn" + + async def receive_response(self): + # An unlabelled first echo cannot prove an external steer. + yield user("unattributed", None) + if initial_echo: + yield user("native-original", "browser-original") + yield delta("original") + yield user("foreign-user", "foreign-client", turn_id="other-turn") + yield user("native-one", "external-one", method="item/started") + yield user("native-one", "external-one") + yield delta("one") + await first_steer_output.wait() + # Simulate _handle_steer's already-published RPC acceptance. + accepted = TurnSteered(msg_id="remote-steer", turn_id="native-turn", + prompt="same prompt") + ctx.active_msg_id = accepted.msg_id + machine._remember_codex_published_steer(ctx, accepted) + machine._rebind_codex_turn(ctx, accepted.turn_id, accepted.msg_id) + await machine._emit(ctx, accepted) + yield user("native-two", "external-two", method="item/started") + yield user("native-one", "external-one") + yield user("native-original", "browser-original") + yield user("native-remote", "remote-steer") + yield user("native-two", "external-two") + yield delta("two") + yield {"method": "turn/completed", "params": { + "threadId": ctx.session_id, + "turn": {"id": "native-turn", "status": "completed"}, + }} + + ctx.sdk = AcceptedSdk() + machine._ensure_codex_daemon_generation = ( + lambda *_args, **_kwargs: asyncio.sleep(0, result=True)) + machine._begin_codex_checkpoint = lambda _ctx: asyncio.sleep(0) + machine._accept_codex_checkpoint = lambda _ctx: asyncio.sleep(0) + await asyncio.wait_for(machine._run_turn(ctx, "same prompt"), timeout=2) + + assert observations == [ + ("original", "running", "browser-original", "browser-original", "browser-original"), + ("one", "running", "external-one", "external-one", "external-one"), + ("two", "running", "external-two", "external-two", "external-two"), + ] + assert [(event.msg_id, event.turn_id) for event in transport.sent + if isinstance(event, TurnSteered)] == [ + ("external-one", "native-turn"), ("remote-steer", "native-turn"), + ("external-two", "native-turn"), + ] + assert [(event.msg_id, event.client_msg_id) for event in transport.sent + if isinstance(event, UserMsg) and event.client_msg_id] == [ + ("native-one", "external-one"), ("native-two", "external-two"), + ] + terminals = [event for event in transport.sent if isinstance(event, TurnEnd)] + assert len(terminals) == 1 and not terminals[0].result.is_error + assert ctx.state == "idle" + + asyncio.run(run()) + + def test_managed_codex_turn_replaces_stale_effort_with_model_default(): async def run(): machine, transport = _mk_machine() diff --git a/tests/test_codex_history.py b/tests/test_codex_history.py index abd6609f..604f834c 100644 --- a/tests/test_codex_history.py +++ b/tests/test_codex_history.py @@ -2150,7 +2150,8 @@ def test_live_rollout_user_recovery_bounds_the_reverse_search(tmp_path): ) is None -def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path): +@pytest.mark.parametrize("client_field", ["clientId", "client_id"]) +def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path, client_field): rollout = tmp_path / "rollout-modern-user.jsonl" rollout.write_text("".join(json.dumps(row) + "\n" for row in [ { @@ -2176,7 +2177,7 @@ def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path): "turn_id": "native-modern", "item": { "id": "user-modern", - "clientId": "cli-message-modern", + client_field: "cli-message-modern", "type": "UserMessage", "content": [{"type": "text", "text": "inspect modern"}], }, diff --git a/tests/test_query_rejection.py b/tests/test_query_rejection.py new file mode 100644 index 00000000..4c206cc8 --- /dev/null +++ b/tests/test_query_rejection.py @@ -0,0 +1,75 @@ +"""A rejected send is private command feedback, never a failed engine turn.""" + +import asyncio + +import pytest + +from cc_remote.protocol import CommandAck, Error, Query, StateEvent +from tests.test_claude_autocompact import ( + SESSION_ID, _AutoCompactSdk, _machine_with_sdk, +) + + +@pytest.mark.parametrize("race", ["running", "notification", "preflight"]) +def test_rejected_query_is_private_and_reliable_without_touching_active_turn(race): + async def run(): + machine, transport, ctx = _machine_with_sdk(_AutoCompactSdk()) + ctx.active_msg_id = "original-task" + if race == "running": + ctx.state = "running" + elif race == "notification": + ctx.claude_background_followup_pending = True + else: + async def ownership(_sid): + ctx.claude_background_followup_pending = True + return False + + machine._prime_claude_ownership = ownership + + await machine._emit(ctx, StateEvent( + state=ctx.state, msg_id="original-task")) + original_seq = ctx.buffer.tail_seq + command = Query(sid=SESSION_ID, msg_id="rejected-message", + prompt="new instruction", client_id="sender", + cmd_id="send-command") + await machine._process_command(command) + assert ctx.state == ("running" if race == "running" else "idle") + assert ctx.active_msg_id == "original-task" + # Lost ACK: a reliable retry must replay the rejection privately, never + # rerun the query after the original background response becomes idle. + ctx.state = "idle" + ctx.claude_background_followup_pending = False + await machine._process_command(command) + + errors = [event for event in transport.sent if isinstance(event, Error)] + assert len(errors) == 2 + assert all(event.code == "busy" for event in errors) + assert all(event.to == "sender" for event in errors) + assert all(event.request_id == "send-command" for event in errors) + assert all(event.msg_id == "rejected-message" for event in errors) + assert all(event.sid == SESSION_ID for event in errors) + assert ctx.active_msg_id == "original-task" + assert ctx.turn_task is None + assert ctx.buffer.tail_seq == original_seq + assert not any(isinstance(event, Error) for event in ctx.buffer.replay_from( + 0, cc_session_id=SESSION_ID, state=ctx.state, rebuild=True)) + assert len([e for e in transport.sent if isinstance(e, CommandAck)]) == 2 + + asyncio.run(run()) + + +def test_missing_session_query_rejection_stays_correlated_to_sender(): + async def run(): + machine, transport, _ctx = _machine_with_sdk(_AutoCompactSdk()) + result = await machine._handle_query(Query( + sid="missing-session", msg_id="unaccepted-message", prompt="hi", + client_id="sender", cmd_id="send-command")) + assert isinstance(result, Error) + assert result.code == "not_running" + assert result.to == "sender" + assert result.request_id == "send-command" + assert result.sid == "missing-session" + assert result.msg_id == "unaccepted-message" + assert transport.sent[-1] is result + + asyncio.run(run()) diff --git a/web/package.json b/web/package.json index 97302977..c7c9a202 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,7 @@ "test:jitter": "playwright test -c playwright.jitter.config.ts --project=webkit", "test:diff": "npm run test:compile --silent && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js", "test:preview": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", - "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/tool-details.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-usage.test.js && node node_modules/.tmp/cc-remote-tests/tests/themes.test.js && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/btw-history.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/clipboard-paste-guard.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", + "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/tool-details.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-usage.test.js && node node_modules/.tmp/cc-remote-tests/tests/themes.test.js && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/query-rejection.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/btw-history.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/clipboard-paste-guard.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/web/src/components/MessageBlock.tsx b/web/src/components/MessageBlock.tsx index e114b8d1..1683dbdf 100644 --- a/web/src/components/MessageBlock.tsx +++ b/web/src/components/MessageBlock.tsx @@ -3,7 +3,7 @@ import { createContext, isValidElement, useContext, useEffect, useId, type ComponentPropsWithoutRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import ReactMarkdown, { type Components } from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform, type Components, type UrlTransform } from "react-markdown"; import { parseLocalFileTarget } from "../file-link"; import { RemoteViewerContext } from "../remote-viewer-context"; import { ViewerPagesContext } from "../viewer-pages-context"; @@ -438,6 +438,14 @@ function MarkdownImage({ src, alt, title }: ComponentPropsWithoutRef<"img">) { onPreviewImage={onPreviewImage} />; } +// Keep local file anchors until MarkdownLink turns them into preview buttons. +// Images and all other schemes retain react-markdown's default URL policy. +const messageUrlTransform: UrlTransform = (url, key, node) => { + if (key === "href" && node.tagName === "a" + && /^file:\/\//i.test(url) && parseLocalFileTarget(url)) return url; + return defaultUrlTransform(url); +}; + function MarkdownLink({ href = "", children, title, }: ComponentPropsWithoutRef<"a">) { @@ -672,6 +680,7 @@ export function MessageBlock({ text, done, onOpenFile, imageAssets, if (part.kind === "markdown") return {part.text}; if (part.kind === "visualization") return query.msg_id === e.msg_id); const pending = runtime?.pendingSend?.msg_id === e.msg_id ? runtime.pendingSend : undefined; - const deferred = queued ?? pending; + const rejectedQuery = (sendRejection || e.request_id) + && runtime?.acceptancePending === e.msg_id + && runtime.acceptanceKind === "query" ? runtime.acceptanceQuery : null; + if (rejectedQuery && runtime?.turns.some((turn) => turn.id === e.msg_id + && (turnHasBoundEngineId(turn) || mutableTurnBlocks(turn).length > 0))) return state; + const deferred = queued ?? pending ?? rejectedQuery; if (deferred && key) { - const problem = presentCommandProblem(e); + const problem = rejectedQuery + ? sendRejection ? presentTurnProblem(e) : "本次消息未发送,请稍后重试。" + : presentCommandProblem(e); if (deferred.queueState === "queued") { const next = patch(state, key, (rt) => { rt.queue = rt.queue.map((query) => @@ -5919,6 +5939,12 @@ function reduceEvent( failedAt: Date.now(), }, ]; + if (rejectedQuery) { + // Preserve the complete input in the bounded "未发送" list, not + // as a failed engine turn. Retrying requires an explicit click. + rt.turns = rt.turns.filter((turn) => turn.id !== e.msg_id); + clearAcceptance(rt); + } }); return { ...next, @@ -5930,6 +5956,12 @@ function reduceEvent( if (!e.msg_id) { return { ...state, banner: presentCommandProblem(e) }; } + if (sendRejection || e.request_id) { + // A rejected command never owns an engine terminal. Older wrappers + // broadcast/replayed these errors to observers; without this browser's + // exact pending query, there is no conversation row to create or fail. + return state; + } return patch(state, e.sid, (rt) => { rt.loading = false; // never leave a spinner spinning behind an error if (rt.acceptancePending === e.msg_id) { diff --git a/web/tests/markdown-preview.test.ts b/web/tests/markdown-preview.test.ts index b09f2e97..315f0c7c 100644 --- a/web/tests/markdown-preview.test.ts +++ b/web/tests/markdown-preview.test.ts @@ -1390,6 +1390,46 @@ $$`, assert.match(messageMarkup, /在 Remote 中打开/); assert.doesNotMatch(messageMarkup, /href="\/home\/nancy/); + for (const [href, path] of [ + ["/tmp/listen/short.wav", "/tmp/listen/short.wav"], + ["file:///tmp/listen/short.wav", "/tmp/listen/short.wav"], + ["file://localhost/tmp/listen/long%20clip.wav", "/tmp/listen/long clip.wav"], + ["file:///tmp/listen/", "/tmp/listen/"], + ]) { + const markup = renderToStaticMarkup(createElement(MessageBlock, { + text: `| 音频 |\n|---|\n| [试听](${href}) |`, + done: true, + onOpenFile: () => {}, + })); + assert.match(markup, /]*class="message-file-link"/, + `${href} must enter the authenticated preview instead of becoming a disabled link`); + assert.ok(markup.includes(`aria-label="在 Remote 中打开 ${path}"`)); + assert.doesNotMatch(markup, /href=|message-link-disabled/); + } + for (const href of [ + "file://other-host/tmp/clip.wav", "file:///tmp/bad%00.wav", + "javascript:alert(1)", "data:text/html,test", "vscode://file/tmp/clip.wav", + ]) { + const markup = renderToStaticMarkup(createElement(MessageBlock, { + text: `[试听](<${href}>)`, done: true, onOpenFile: () => {}, + })); + assert.match(markup, /message-link-disabled/); + assert.doesNotMatch(markup, /message-file-link|href=/, + "local-file support must not allow remote authorities or other URI schemes"); + } + const unavailableFilePreview = renderToStaticMarkup(createElement(MessageBlock, { + text: "[试听](file:///tmp/clip.wav)", done: true, + })); + assert.match(unavailableFilePreview, /message-link-disabled/); + assert.doesNotMatch(unavailableFilePreview, /href=/, + "without a session preview callback, never navigate the browser to file://"); + const fileUrlImage = renderToStaticMarkup(createElement(MessageBlock, { + text: "![image](file:///tmp/clip.png)", done: true, onLoadImage: () => true, + })); + assert.match(fileUrlImage, /message-image-error/); + assert.doesNotMatch(fileUrlImage, / `line ${index + 1}`).join("\n"); const sourceMarkup = renderToStaticMarkup(createElement(ArtifactPanel, { artifact: { diff --git a/web/tests/notices-rate-limits.test.ts b/web/tests/notices-rate-limits.test.ts index 030f989e..10553e59 100644 --- a/web/tests/notices-rate-limits.test.ts +++ b/web/tests/notices-rate-limits.test.ts @@ -376,6 +376,31 @@ try { }], 1_000).length, 0, "elapsed SDK windows must disappear without waiting for another event"); + const unifiedReset = Math.floor(Date.now() / 1000) + 3_600; + for (const rejected of [false, true]) { + const unifiedState = reduce({ + ...initialState, focusedSid: claudeSid, + runtimes: { [claudeSid]: createRuntime() }, + }, { type: "event", event: event({ + type: "rate_limit_update", sid: claudeSid, + limit_id: "claude", name: "Claude", + reached_type: rejected ? "five_hour" : "", + primary: { used_percent: rejected ? 100 : 1, + resets_at: unifiedReset, window_duration_mins: 300 }, + secondary: { used_percent: 0, + resets_at: unifiedReset + 86_400, window_duration_mins: 10_080 }, + }) }); + const unifiedLimits = unifiedState.runtimes[claudeSid].rateLimits; + assert.equal(unifiedLimits[0].rate_limit_reached_type, rejected ? "five_hour" : ""); + const unifiedHtml = renderToStaticMarkup(createElement(UsageMeter, { + engine: "claude", open: true, rateLimits: unifiedLimits, + onToggle: () => {}, onRefresh: () => {}, onOpenStatus: () => {}, + })); + assert.ok(unifiedHtml.includes(`剩余 ${rejected ? 0 : 99}%`)); + assert.ok(unifiedHtml.includes("剩余 100%"), + "zero utilization in a unified weekly window must remain visible"); + } + const officialDiagnostic = event({ type: "notice", notice_id: "codex-notice-private-diagnostic", severity: "warning", category: "runtime", diff --git a/web/tests/query-rejection.test.ts b/web/tests/query-rejection.test.ts new file mode 100644 index 00000000..fe8d7ead --- /dev/null +++ b/web/tests/query-rejection.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { createServer } from "vite"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Turn } from "../src/reducer.ts"; + +const harness = await createServer({ + root: process.cwd(), appType: "custom", logLevel: "silent", + server: { middlewareMode: true, watch: null }, +}); + +try { + const { initialState, createRuntime, reduce } = await harness.ssrLoadModule("/src/reducer.ts"); + const { QueuedQueryChip } = await harness.ssrLoadModule("/src/components/QueuedQueryChip.tsx"); + const sid = "claude-session"; + const original: Turn = { id: "original-task", prompt: "keep working", done: false, + blocks: [{ kind: "text", message_id: "response", text: "working", + channel: "commentary", done: false }] }; + const pendingQuestion = { ask_id: "question", question: "continue?", options: [] }; + const initial = { + ...initialState, focusedSid: sid, + runtimes: { [sid]: { ...createRuntime(), state: "running", turns: [original], + liveOwner: { turnId: original.id, seq: 2542 }, pendingQuestion } }, + }; + const rejection = { v: 72, type: "error", sid, ts: 1790596023.95201, + code: "busy", message: "该会话正忙,先 interrupt", msg_id: "rejected-message" }; + const images = [{ media_type: "image/png", data: "aW1hZ2U=" }]; + const files = [{ filename: "notes.txt", data: "bm90ZXM=" }]; + const sent = reduce(initial, { type: "query_sent", sid, msg_id: rejection.msg_id, + prompt: "new instruction", images, files, ts: 1790596023950 }); + + // The same event as the production replay: another browser must not acquire + // an empty failed turn, a notice, or a different live owner from this error. + const observer = reduce(initial, { type: "event", event: { ...rejection, seq: 2543 } }); + assert.deepEqual(observer.runtimes[sid].turns, [original]); + assert.deepEqual(observer.runtimes[sid].liveOwner, initial.runtimes[sid].liveOwner); + assert.equal(observer.runtimes[sid].state, "running"); + assert.equal(observer.banner, initial.banner); + + for (const error of [ + { ...rejection, seq: 2543 }, // compatibility with the old broadcast ring + { ...rejection, request_id: "send-command", to: "sender" }, + { ...rejection, code: "not_running", request_id: "send-command", to: "sender" }, + { ...rejection, code: "bad_prompt", request_id: "send-command", to: "sender" }, + { ...rejection, code: "internal", request_id: "send-command", to: "sender" }, + ]) { + const rejected = reduce(sent, { type: "event", event: error }); + const rt = rejected.runtimes[sid]; + assert.deepEqual(rt.turns, [original], "unaccepted optimistic row is not engine history"); + assert.equal(rt.state, "running"); + assert.deepEqual(rt.liveOwner, initial.runtimes[sid].liveOwner); + assert.deepEqual(rt.pendingQuestion, pendingQuestion); + assert.equal(rt.acceptancePending, null); + assert.equal(rt.acceptanceQuery, null); + assert.equal(rt.failedDeferred.length, 1); + assert.equal(rt.failedDeferred[0].prompt, "new instruction"); + assert.deepEqual(rt.failedDeferred[0].images, images); + assert.deepEqual(rt.failedDeferred[0].files, files, "keep full attachment bytes for manual retry"); + assert.equal(rt.failedDeferred[0].queueState, "failed"); + assert.match(rt.failedDeferred[0].queueError, /未发送|无法发送/); + assert.equal(rt.queue.length, 0, "rejection must not automatically enqueue or resubmit"); + assert.equal(rt.pendingSend, null); + const html = renderToStaticMarkup(createElement(QueuedQueryChip, { + query: rt.failedDeferred[0], onOpen() {}, onRemove() {}, + })); + assert.match(html, /未发送/); + assert.match(html, /new instruction/); + const repeated = reduce(rejected, { type: "event", event: error }); + assert.deepEqual(repeated.runtimes[sid].failedDeferred, rt.failedDeferred); + assert.deepEqual(repeated.runtimes[sid].turns, rt.turns); + } + + const unrelated = reduce(sent, { type: "event", event: { ...rejection, msg_id: "other-message" } }); + assert.deepEqual(unrelated.runtimes[sid].acceptanceQuery, sent.runtimes[sid].acceptanceQuery); + const rekeyed = reduce({ ...sent, runtimes: { + ...sent.runtimes, "real-session": createRuntime(), + } }, { type: "event", event: { + v: 72, type: "session_rekey", ts: 1790596024, old_key: sid, session_id: "real-session", + } }); + const rejectedAfterRekey = reduce(rekeyed, { type: "event", event: { + ...rejection, sid: "real-session", to: "sender", request_id: "send-command", + } }); + assert.deepEqual(rejectedAfterRekey.runtimes["real-session"].failedDeferred[0].files, files); + + // Exact native acceptance wins over a delayed rejection, and releases the + // private retry payload. Real execution errors must still render as failures. + const accepted = reduce(sent, { type: "event", event: { + v: 72, ts: 1790596024, type: "user_msg", sid, + msg_id: rejection.msg_id, prompt: "new instruction", seq: 2543, + } }); + assert.equal(accepted.runtimes[sid].acceptanceQuery, null); + const late = reduce(accepted, { type: "event", event: { + ...rejection, request_id: "send-command", to: "sender", + } }); + assert.deepEqual(late.runtimes[sid].turns, accepted.runtimes[sid].turns); + assert.equal(late.runtimes[sid].failedDeferred.length, 0); + const failed = reduce(accepted, { type: "event", event: { + ...rejection, code: "cc_crash", message: "请求超时,请重新尝试。", seq: 2544, + } }); + const failedTurn = failed.runtimes[sid].turns.find((t: { id: string }) => t.id === rejection.msg_id); + assert.equal(failedTurn.done, true); + assert.equal(failedTurn.terminalSource, "failed"); + assert.equal(failedTurn.error, "请求超时,请重新尝试。"); + console.log("query rejection tests passed"); +} finally { + await harness.close(); +} diff --git a/web/tests/steer-boundary.test.ts b/web/tests/steer-boundary.test.ts index dc78ce58..78f88878 100644 --- a/web/tests/steer-boundary.test.ts +++ b/web/tests/steer-boundary.test.ts @@ -143,6 +143,59 @@ try { const { initialState, createRuntime, reduce } = await harness.ssrLoadModule("/src/reducer.ts"); const { ChatView } = await harness.ssrLoadModule("/src/components/ChatView.tsx"); + { + let state = { ...initialState, focusedSid: "s", runtimes: { s: { + ...createRuntime(), state: "running", syncReady: true, + legacyLiveFallbackBlocked: true, + } } }; + let seq = 0; + const emit = (body: Record) => { + seq += 1; + state = reduce(state, { type: "event", event: { + v: 72, sid: "s", ts: seq, seq, ...body, + } }); + }; + emit({ type: "user_msg", msg_id: "browser-original", prompt: "same prompt" }); + emit({ type: "turn_binding", msg_id: "browser-original", turn_id: task }); + for (const name of ["original", "one", "two"]) { + if (name !== "original") { + // Managed external input publishes the boundary before its history alias. + emit({ type: "turn_steered", msg_id: `external-${name}`, turn_id: task, + prompt: "same prompt" }); + emit({ type: "user_msg", msg_id: `native-${name}`, + client_msg_id: `external-${name}`, prompt: "same prompt" }); + } + emit({ type: "assistant_msg_start", message_id: `answer-${name}`, + turn_id: task, channel: "commentary" }); + emit({ type: "delta", message_id: `answer-${name}`, turn_id: task, + channel: "commentary", text: `working on ${name}` }); + emit({ type: "assistant_msg_end", message_id: `answer-${name}`, + turn_id: task, channel: "commentary" }); + } + const runtime = state.runtimes.s; + const rows = runtime.turns as Turn[]; + assert.equal(runtime.liveOwner?.turnId, "external-two"); + assert.equal(runtime.state, "running", "a steer must not end the native task"); + assert.deepEqual(rows.map(turn => turn.done), [true, true, false]); + assert.deepEqual(rows.map(turn => turn.blocks.filter(block => block.kind === "text") + .map(block => block.message_id)), [["answer-original"], ["answer-one"], ["answer-two"]]); + const canonical = rows.map((turn, index) => ({ ...turn, + id: ["native-original", "native-one", "native-two"][index], + clientMsgId: ["browser-original", "external-one", "external-two"][index], + })); + const refreshed = mergeInitialHistory(canonical, rows, { + preserveLiveTailOpen: true, reconcileReplayOrphans: true, + activeOwnerId: runtime.liveOwner.turnId, + }, true); + assert.equal(refreshed.length, 3, "history aliases must not duplicate user rows"); + assert.deepEqual(refreshed.map(turn => turn.done), [true, true, false]); + emit({ type: "turn_end", turn_id: task, result: { + subtype: "success", duration_ms: 1000, is_error: false, + } }); + assert.ok(state.runtimes.s.turns.every((turn: Turn) => turn.done)); + emit({ type: "state", state: "idle" }); + assert.equal(state.runtimes.s.liveOwner, null); + } for (const local of [false, true]) { for (const channel of ["final", "commentary"] as const) { for (const explicitTask of [undefined, task]) { diff --git a/web/vite.config.ts b/web/vite.config.ts index 7ca60d79..c5029ef9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -27,10 +27,10 @@ export default defineConfig({ }, { name: "initial-vendor", - // Keep the shared reducer and projection primitives together. + // Keep relay delivery, reducer and projection primitives together. // This compresses their common field names without increasing // startup requests or pulling any lazy UI into the initial load. - test: /node_modules[\\/]|preload-helper|src[\\/](?:compaction-orphans|history-browse|history-requests|history-detail-projection|reducer|runtime-bounds|remote-viewer|file-changes|tool-command)\.ts$|src[\\/]icons\.tsx$/, + test: /node_modules[\\/]|preload-helper|src[\\/](?:compaction-orphans|history-browse|history-requests|history-detail-projection|reducer|runtime-bounds|remote-viewer|file-changes|tool-command|ws)\.ts$|src[\\/]icons\.tsx$/, tags: ["$initial"], priority: 10, }, From ab05586b5cee60fca8db1a95837456a635ce5161 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Mon, 28 Sep 2026 20:54:43 -0700 Subject: [PATCH 3/3] feat(claude): refresh account quota without a model turn - Read account-scoped usage through private provider-owned helpers without copying credentials. - Coalesce active reads and preserve newer native events and valid cached quota on failures. - Refresh Claude quota on focus, reconnect, opening the meter and visible-page polling. - Distinguish missing helper configuration from failed quota reads. - Cover helper boundaries, percentage units, concurrency and failure presentation. --- cc_remote/wrapper/claude_rate_limits.py | 63 ++++++++- cc_remote/wrapper/claude_usage.py | 131 +++++++++++++++++++ cc_remote/wrapper/machine.py | 43 ++++++ docs/claude-usage.md | 71 ++++++++++ tests/test_claude_usage.py | 166 ++++++++++++++++++++++++ web/src/App.tsx | 34 ++--- web/src/components/Composer.tsx | 9 +- web/src/components/UsageMeter.tsx | 15 ++- web/tests/notices-rate-limits.test.ts | 26 +++- web/tests/reliability.test.ts | 2 +- 10 files changed, 528 insertions(+), 32 deletions(-) create mode 100644 cc_remote/wrapper/claude_usage.py create mode 100644 docs/claude-usage.md create mode 100644 tests/test_claude_usage.py diff --git a/cc_remote/wrapper/claude_rate_limits.py b/cc_remote/wrapper/claude_rate_limits.py index f1fbef9d..13485096 100644 --- a/cc_remote/wrapper/claude_rate_limits.py +++ b/cc_remote/wrapper/claude_rate_limits.py @@ -1,10 +1,12 @@ -"""Sanitized, expiring Claude Agent SDK rate-limit projection. +"""Sanitized, expiring Claude account rate-limit projection. The SDK has no supported pull API for account usage. Claude Code emits ``RateLimitEvent`` records when its native quota state changes, so the wrapper keeps only those public fields long enough to survive a browser reconnect or a wrapper restart. Model credentials, account identity and the SDK's raw payload -are never persisted. +are never persisted. An optional external usage helper supplies read-only +snapshots using the same cache; its percentages have different units from SDK +events and are parsed separately. """ from __future__ import annotations @@ -12,6 +14,7 @@ import math import os import time +from datetime import datetime from pathlib import Path from typing import Any from uuid import uuid4 @@ -90,6 +93,7 @@ class ClaudeRateLimitStore: def __init__(self, state_dir: str | os.PathLike[str]): self.path = Path(state_dir) / "claude-rate-limits.json" self._limits = self._load() + self.revisions: dict[str, int] = {} def _load(self) -> dict[str, dict[str, Any]]: try: @@ -248,6 +252,7 @@ def observe(self, info: Any, *, now: int | None = None) -> tuple[RateLimitUpdate changed = False observed: dict[str, dict[str, Any]] = {} for name, (raw_reset, used, status) in windows.items(): + self.revisions[name] = self.revisions.get(name, 0) + 1 resets_at = _reset_timestamp(raw_reset, observed_at) if raw_reset is not None and resets_at is None: changed = self._limits.pop(name, None) is not None or changed @@ -266,6 +271,60 @@ def observe(self, info: Any, *, now: int | None = None) -> tuple[RateLimitUpdate self._persist() return self._updates(observed) + def observe_usage(self, payload: dict, revisions: dict[str, int], *, + now: int | None = None) -> None: + """Apply a GET snapshot without overwriting a newer native event. + + OAuth usage percentages are 0..100 (SDK utilization is 0..1). Null + windows remove their old observation; absent windows are not invented. + Validate everything before changing or persisting any cache entry. + """ + observed_at = int(time.time()) if now is None else int(now) + parsed: dict[str, dict | None] = {} + for name in _RATE_LIMITS: + if name not in payload: + continue + window = payload[name] + if window is None: + parsed[name] = None + continue + if not isinstance(window, dict): + raise ValueError("invalid usage window") + used = window.get("utilization") + if (isinstance(used, bool) or not isinstance(used, (int, float)) + or not math.isfinite(used) or not 0 <= used <= 100): + raise ValueError("invalid usage percentage") + raw_reset = window.get("resets_at") + reset = None + if raw_reset is not None: + if not isinstance(raw_reset, str) or len(raw_reset) > 64: + raise ValueError("invalid usage reset") + try: + stamp = datetime.fromisoformat(raw_reset.replace("Z", "+00:00")) + if stamp.tzinfo is None: + raise ValueError() + reset = int(stamp.timestamp()) + except (ValueError, OverflowError): + raise ValueError("invalid usage reset") from None + if reset <= observed_at: + parsed[name] = None + continue + parsed[name] = { + "resets_at": reset, "used_percent": round(used), + "status": "rejected" if used >= 100 else "allowed", + "observed_at": observed_at, + } + if not parsed: + raise ValueError("usage windows missing") + for name, entry in parsed.items(): + if self.revisions.get(name, 0) != revisions.get(name, 0): + continue + if entry is None: + self._limits.pop(name, None) + else: + self._limits[name] = entry + self._persist() + def snapshot(self, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]: observed_at = int(time.time()) if now is None else int(now) expired = [ diff --git a/cc_remote/wrapper/claude_usage.py b/cc_remote/wrapper/claude_usage.py new file mode 100644 index 00000000..f7c9ae85 --- /dev/null +++ b/cc_remote/wrapper/claude_usage.py @@ -0,0 +1,131 @@ +"""Account-scoped, read-only usage helpers; credentials stay with the provider. + +An operator-owned helper performs the authenticated GET and returns only usage +JSON. The Wrapper never reads OAuth/keychain/model credentials or runs a chat +command to obtain quota. Helpers are local configuration, never wire inputs. +""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import signal +import stat +import time + +from cc_remote.claude_profiles import ClaudeProfile + + +MAX_BYTES = 64 * 1024 +TIMEOUT = 15 +REFRESH_INTERVAL = 15 + + +class ClaudeUsageError(RuntimeError): + """Only a fixed, credential-free reason may cross the control link.""" + + +class ClaudeUsageReader: + def __init__(self, state_dir: str | os.PathLike[str]): + self.path = Path(state_dir) / "claude-usage-helpers.json" + self._locks: dict[str, asyncio.Lock] = {} + self._recent: dict[str, tuple[float, tuple[str, ...], str | None]] = {} + + def command(self, profile: ClaudeProfile) -> tuple[str, ...] | None: + try: + with self.path.open("rb") as stream: + info = os.fstat(stream.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_mode & 0o077 or info.st_size > MAX_BYTES): + raise ValueError() + raw = json.loads(stream.read(MAX_BYTES + 1)) + if not isinstance(raw, dict) or raw.get("version") != 1: + raise ValueError() + entries = raw["profiles"] + if not isinstance(entries, dict): + raise ValueError() + entry = entries.get(profile.id) + if entry is None: + return None + if (not isinstance(entry, dict) or not isinstance(entry.get("config_dir"), str) + or Path(entry["config_dir"]).expanduser().resolve() != profile.config_dir): + raise ValueError() + command = entry["command"] + if (not isinstance(command, list) or not 1 <= len(command) <= 16 + or any(not isinstance(arg, str) or not arg or len(arg) > 4096 + or "\x00" in arg for arg in command) + or not Path(command[0]).is_absolute()): + raise ValueError() + return tuple(command) + except FileNotFoundError: + return None + except (OSError, ValueError, KeyError, TypeError): + raise ClaudeUsageError("usage helper configuration invalid") from None + + async def refresh(self, profile: ClaudeProfile, apply) -> str | None: + """Serialize each account's reads, including browser retry storms. + + Only refresh completion is cached here. The shared quota store remains + authoritative, so native events received after a GET are never replaced + with an old cached response. + """ + async with self._locks.setdefault(profile.id, asyncio.Lock()): + command = self.command(profile) + if command is None: + return "usage helper unavailable" + recent = self._recent.get(profile.id) + if (recent and recent[1] == command + and time.monotonic() - recent[0] < REFRESH_INTERVAL): + return recent[2] + error = None + try: + await apply(lambda: self._read(profile, command)) + except ClaudeUsageError as exc: + error = str(exc) + self._recent[profile.id] = (time.monotonic(), command, error) + return error + + async def _read(self, profile: ClaudeProfile, command: tuple[str, ...]) -> dict: + # No shell, project environment, prompt, relay secret or ambient model + # selector. The helper receives the exact public profile binding on stdin. + env = {key: os.environ[key] for key in ( + "HOME", "PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP", + ) if key in os.environ} + process = None + try: + process = await asyncio.create_subprocess_exec( + *command, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + cwd=Path.home(), env=env, start_new_session=True, + ) + async def collect(): + process.stdin.write(json.dumps({ + "version": 1, "profile_id": profile.id, + "config_dir": str(profile.config_dir), + }).encode() + b"\n") + await process.stdin.drain() + process.stdin.close() + output = bytearray() + while chunk := await process.stdout.read(4096): + output.extend(chunk) + if len(output) > MAX_BYTES: + raise ClaudeUsageError("usage response invalid") + if await process.wait() != 0: + raise ClaudeUsageError("usage request failed") + return output + data = json.loads(await asyncio.wait_for(collect(), TIMEOUT)) + if not isinstance(data, dict): + raise ValueError() + return data + except TimeoutError: + raise ClaudeUsageError("usage request timed out") from None + except (OSError, ValueError, RecursionError): + raise ClaudeUsageError("usage response unavailable") from None + finally: + if process is not None and process.returncode is None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await process.wait() diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index 22d4b9f7..bd541e9f 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -198,6 +198,7 @@ ClaudeRateLimitStore, ClaudeRateLimitStoreError, ) +from cc_remote.wrapper.claude_usage import ClaudeUsageError, ClaudeUsageReader from cc_remote.wrapper.codex_controls import ( CODEX_WEB_SEARCH_MODES, CodexControls, @@ -2213,6 +2214,7 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): self._claude_rate_limit_stores[profile.id] = store self._claude_rate_limits = self._claude_rate_limit_stores.get( self._claude_profiles.default.id) + self._claude_usage_reader = ClaudeUsageReader(cfg.state_dir) # A History token changes for every wrapper process and every local # destructive conversation mutation. Browsers persist this token with # IndexedDB turns, so a fresh wrapper can never merge a pre-crash cache @@ -21469,6 +21471,8 @@ async def _handle_get_status(self, cmd) -> None: ctx = self._ctx_for(getattr(cmd, "sid", None)) if ctx is None: return await self._missing_session_error(cmd, "读取状态") + if ctx.engine == "claude": + return await self._handle_get_claude_status(ctx, cmd) if ctx.engine != "codex": error = Error( code=ERR_INTERNAL, @@ -21521,6 +21525,45 @@ async def _handle_get_status(self, cmd) -> None: await self._emit(ctx, error) return error + async def _handle_get_claude_status(self, ctx, cmd): + """Read account quota without connecting or controlling a chat worker.""" + profile = self._claude_profile_for_ctx(ctx) + store = self._claude_rate_limit_stores.get(profile.id) + + async def apply(read): + if store is None: + raise ClaudeUsageError("usage cache unavailable") + async with self._claude_rate_limit_lock: + revisions = dict(store.revisions) + payload = await read() + async with self._claude_rate_limit_lock: + try: + await asyncio.to_thread(store.observe_usage, payload, revisions) + except (ValueError, ClaudeRateLimitStoreError): + raise ClaudeUsageError("usage response invalid") from None + + try: + failure = await self._claude_usage_reader.refresh(profile, apply) + except ClaudeUsageError as exc: + failure = str(exc) + windows = await self._claude_rate_limit_snapshot(ctx) + report = StatusReport( + thread={"thread_id": ctx.session_id or ctx.key, + "session_id": ctx.session_id, + "status": "active" if ctx.state == "running" else "idle"}, + runtime={}, context={}, + rate_limits=[{ + "limit_id": update.limit_id, "limit_name": update.name, + "rate_limit_reached_type": update.reached_type, + "primary": update.primary, "secondary": update.secondary, + } for update in windows], + component_errors=[f"rate_limits: {failure}"] if failure else [], + request_id=getattr(cmd, "cmd_id", None), + to=getattr(cmd, "client_id", None), + ) + await self._emit(ctx, report) + return report + async def _handle_consume_rate_limit_reset_credit(self, cmd): """Redeem one native account reset credit and refresh its snapshot.""" ctx = self._ctx_for(getattr(cmd, "sid", None)) diff --git a/docs/claude-usage.md b/docs/claude-usage.md new file mode 100644 index 00000000..97e08fe9 --- /dev/null +++ b/docs/claude-usage.md @@ -0,0 +1,71 @@ +# Claude account quota + +The quota meter reads on session focus/reconnect, when opened or refreshed, and +once per minute while the Claude page is visible. Returning to the page resumes +these periodic reads. This does not send a prompt, resume an engine, modify history, +interrupt work, or consume model tokens. Native `RateLimitEvent` updates remain +enabled. The Wrapper coalesces reads per account with a 15-second minimum +interval, including failures, across sessions and browser clients. + +The pinned Agent SDK exposes quota events but no account-usage pull method. +Active reads therefore use an optional **operator-owned usage helper**. The +provider/login tool retains its credentials and performs its authenticated +read; cc-remote never reads OAuth, keychain, API key or gateway token files. +No helper or authentication data comes from the browser. A helper must be a +read-only query, not `claude -p`, `/usage` submitted as a chat prompt, or a tool +that logs in, redeems credits or refreshes model conversations. + +Place a private, mode-0600 `claude-usage-helpers.json` in `CC_REMOTE_STATE_DIR` +(normally `~/.cc-remote`). It is external configuration and survives upgrades. +Use absolute paths and bind every entry to its actual native account root: + +```json +{ + "version": 1, + "profiles": { + "primary": { + "config_dir": "/absolute/native/account/root", + "command": ["/absolute/path/to/provider-usage-helper"] + } + } +} +``` + +The executable runs without a shell, from the service user's home. It receives +one JSON object on stdin: `version`, `profile_id` and `config_dir`. Only basic +OS environment fields are inherited; model credentials and account selectors +are not. The helper must verify that this account is the one its provider +connection represents. Keep credentials outside arguments and output. Never +reuse another profile's gateway token merely because the URLs match. + +On success, stdout contains one JSON object with the read-only OAuth usage +shape below; exit nonzero on failure. The Wrapper limits execution to 15 seconds +and stdout to 64 KiB. Stderr and raw errors are never shown or logged. + +```json +{ + "five_hour": {"utilization": 37, "resets_at": "2026-10-01T12:00:00Z"}, + "seven_day": {"utilization": 12.5, "resets_at": "2026-10-05T12:00:00Z"}, + "seven_day_opus": null, + "seven_day_sonnet": null +} +``` + +These `utilization` values are consumed **percentages from 0 to 100**; SDK +events instead use fractions from 0 to 1. Reset times must include a timezone; +null means unknown. A null window clears its cached value. Unknown fields, +account details and paid-credit balances are discarded. Newer native events +win over older in-flight HTTP results. Invalid results do not replace valid +observations. Cached windows expire individually at their reset time. + +A missing helper keeps native-event synchronization and shows that active +reads are not configured; this does not mean the provider lacks a usage API. +A failed read keeps still-valid observations with a +refresh-failure notice; absent data is not reported as exhausted quota. +The existing status wire shape is reused; Codex's app-server status, coupons +and account authentication paths are unchanged. + +For a private gateway, the provider helper can GET its existing +`/api/oauth/usage` endpoint using the gateway's own authentication and release +requirements. Verify the actual endpoint, account binding and response before +enabling the helper. cc-remote does not provision or change that gateway. diff --git a/tests/test_claude_usage.py b/tests/test_claude_usage.py new file mode 100644 index 00000000..3cb6fb9a --- /dev/null +++ b/tests/test_claude_usage.py @@ -0,0 +1,166 @@ +"""Active quota reads never create, resume, interrupt or write a model turn.""" +import asyncio +from datetime import datetime, timezone +import json +import sys +import time + +import pytest + +from cc_remote.claude_profiles import ClaudeProfile +from cc_remote.protocol import GetStatus, StatusReport +from cc_remote.wrapper import claude_usage +from cc_remote.wrapper.claude_rate_limits import ClaudeRateLimitStore +from cc_remote.wrapper.claude_usage import ClaudeUsageError, ClaudeUsageReader +from tests.test_claude_rate_limits import _event +from tests.test_multisession import _mk_ctx, _mk_machine + + +def usage(value=37): + return {"five_hour": {"utilization": value, "resets_at": datetime.fromtimestamp( + time.time() + 3600, timezone.utc).isoformat()}, + "seven_day": {"utilization": 0, "resets_at": None}, + "seven_day_sonnet": None, "ignored_secret": "never-copy"} + + +def configure(reader, profile, command): + reader.path.parent.mkdir(parents=True, exist_ok=True) + reader.path.write_text(json.dumps({"version": 1, "profiles": { + profile.id: {"config_dir": str(profile.config_dir), "command": command}, + }})) + reader.path.chmod(0o600) + + +def test_usage_percentages_and_null_windows_are_not_sdk_fractions(tmp_path): + store = ClaudeRateLimitStore(tmp_path) + store.observe(_event("seven_day_sonnet", .8).rate_limit_info) + store.observe_usage(usage(1), dict(store.revisions)) + row, = store.snapshot() + assert row.primary.used_percent == 1 + assert row.secondary.used_percent == 0 + assert "never-copy" not in store.path.read_text() + assert "seven_day_sonnet" not in store.path.read_text() + restored, = ClaudeRateLimitStore(tmp_path).snapshot() + assert restored.primary == row.primary and restored.secondary == row.secondary + + +@pytest.mark.parametrize("invalid", [True, "5", -1, 101, float("nan"), None]) +def test_invalid_usage_is_atomic(tmp_path, invalid): + store = ClaudeRateLimitStore(tmp_path) + store.observe_usage(usage(), {}) + original = store.path.read_bytes() + bad = usage(50) + bad["seven_day"] = {"utilization": invalid} + with pytest.raises(ValueError): + store.observe_usage(bad, {}) + assert store.path.read_bytes() == original + assert store.snapshot()[0].primary.used_percent == 37 + + +def test_new_native_observation_wins_over_inflight_snapshot(tmp_path): + store = ClaudeRateLimitStore(tmp_path) + before = dict(store.revisions) + store.observe(_event("five_hour", .65).rate_limit_info) + store.observe_usage(usage(1), before) + row, = store.snapshot() + assert row.primary.used_percent == 65 + assert row.secondary.used_percent == 0 + + +@pytest.mark.asyncio +async def test_real_helper_receives_only_profile_binding(monkeypatch, tmp_path): + reader = ClaudeUsageReader(tmp_path) + profile = ClaudeProfile("company", "Company", tmp_path / "native") + monkeypatch.setenv("WRAPPER_TOKEN", "must-not-reach-helper") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "wrong-account") + code = ("import json,os,sys; d=json.load(sys.stdin); " + "assert d['profile_id']=='company'; " + "assert 'WRAPPER_TOKEN' not in os.environ; " + "assert 'CLAUDE_CODE_OAUTH_TOKEN' not in os.environ; " + "print(json.dumps({'seven_day':{'utilization':0,'resets_at':None}}))") + configure(reader, profile, [sys.executable, "-c", code]) + seen = [] + async def apply(read): + seen.append(await read()) + assert await reader.refresh(profile, apply) is None + assert seen == [{"seven_day": {"utilization": 0, "resets_at": None}}] + other = ClaudeProfile(profile.id, profile.label, tmp_path / "other-account") + with pytest.raises(ClaudeUsageError, match="configuration invalid"): + await reader.refresh(other, apply) + reader.path.chmod(0o644) + with pytest.raises(ClaudeUsageError, match="configuration invalid"): + await reader.refresh(profile, apply) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["large", "fail", "timeout"]) +async def test_helper_bounds_output_time_and_private_errors(monkeypatch, tmp_path, mode): + reader = ClaudeUsageReader(tmp_path) + profile = ClaudeProfile("primary", "Primary", tmp_path) + code = {"large": "print('secret' * 30000)", + "fail": "import sys; print('private'); sys.exit(1)", + "timeout": "import time; time.sleep(10)"}[mode] + configure(reader, profile, [sys.executable, "-c", code]) + monkeypatch.setattr(claude_usage, "TIMEOUT", .2) + async def apply(read): + await read() + error = await reader.refresh(profile, apply) + assert error and "secret" not in error and "private" not in error + + +@pytest.mark.asyncio +async def test_busy_session_read_and_parallel_sessions_share_one_get(monkeypatch, tmp_path): + machine, transport = _mk_machine() + reader = machine._claude_usage_reader = ClaudeUsageReader(tmp_path) + profile = machine._claude_profiles.default + configure(reader, profile, [sys.executable]) + one, two = _mk_ctx("one", "one"), _mk_ctx("two", "two") + one.state = "running" + one.active_msg_id = "active-user" + # Any access to SDK controls or query would fail; an active worker is not + # necessary for this read and must not be created as a side effect. + one.sdk = two.sdk = None + machine.sessions = {"one": one, "two": two} + calls = [] + async def read(profile, command): + calls.append(profile.id) + await asyncio.sleep(.01) + await machine._observe_claude_rate_limit_message(one, _event("five_hour", .65)) + return usage(1) + monkeypatch.setattr(reader, "_read", read) + reports = await asyncio.gather(*(machine._handle_get_status(GetStatus( + sid=sid, cmd_id="request-" + sid, client_id="browser-" + sid)) + for sid in ("one", "two"))) + assert calls == [profile.id] + for report, sid in zip(reports, ("one", "two")): + assert isinstance(report, StatusReport) + assert not report.component_errors + assert report.rate_limits[0].primary.used_percent == 65 + assert report.rate_limits[0].secondary.used_percent == 0 + assert report.to == "browser-" + sid + assert report.request_id == "request-" + sid + assert one.state == "running" and one.active_msg_id == "active-user" + assert one.buffer.tail_seq == two.buffer.tail_seq == 0 + assert {e.type for e in transport.sent} <= {"status_report", "rate_limit_update"} + + +@pytest.mark.asyncio +async def test_failed_or_unconfigured_read_retains_native_cache(monkeypatch, tmp_path): + machine, _ = _mk_machine() + reader = machine._claude_usage_reader = ClaudeUsageReader(tmp_path) + profile = machine._claude_profiles.default + ctx = _mk_ctx("session", "session") + machine.sessions = {ctx.key: ctx} + await machine._observe_claude_rate_limit_message(ctx, _event("five_hour", .42)) + request = GetStatus(sid=ctx.key, cmd_id="read", client_id="browser") + report = await machine._handle_get_status(request) + assert report.component_errors == ["rate_limits: usage helper unavailable"] + assert report.rate_limits[0].primary.used_percent == 42 + configure(reader, profile, [sys.executable]) + async def malformed(*args): + return {"error": "private upstream detail"} + monkeypatch.setattr(reader, "_read", malformed) + report = await machine._handle_get_status(request) + assert report.component_errors == ["rate_limits: usage response invalid"] + assert report.rate_limits[0].primary.used_percent == 42 + assert "private" not in report.model_dump_json() diff --git a/web/src/App.tsx b/web/src/App.tsx index d211ff07..85a05a42 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3617,7 +3617,7 @@ export default function App() { const eventEngine = session?.engine ?? (stateRef.current.focusedSid === msg.sid ? engineRef.current : undefined); - if (eventEngine === "codex") { + if (eventEngine === "codex" || eventEngine === "claude") { if (stateRef.current.runtimes[msg.sid]?.statusRequestId) { deferredStatusRefreshRef.current.add(msg.sid); } else { @@ -4346,7 +4346,7 @@ export default function App() { ]); const refreshStatus = useCallback(() => { - if (!focusedSid || focusedEngine !== "codex") return; + if (!focusedSid) return; const current = stateRef.current; if (current.sessions.find( (session) => session.session_id === focusedSid)?.tag === "archived") return; @@ -4355,7 +4355,7 @@ export default function App() { if (requestId) { dispatch({ type: "begin_status_request", sid: focusedSid, requestId }); } - }, [focusedEngine, focusedSid]); + }, [focusedSid]); const consumeResetCredit = useCallback((creditId?: string | null) => { if (!focusedSid || focusedEngine !== "codex") return false; const current = stateRef.current; @@ -4384,23 +4384,23 @@ export default function App() { return requestId !== null; }, [focusedEngine, focusedSid]); useEffect(() => { - if (!authed || !focusedSid || focusedEngine !== "codex" || state.newChat + if (!authed || !focusedSid || state.newChat || archivedBrowse - || rt.state !== "idle" + || (focusedEngine === "codex" && rt.state !== "idle") || state.connState !== "connected" || !state.wrapperOnline) return; - if (stateRef.current.runtimes[focusedSid]?.statusRequestId) return; refreshStatus(); - }, [ - authed, - archivedBrowse, - focusedEngine, - focusedSid, - refreshStatus, - rt.state, - state.connState, - state.newChat, - state.wrapperOnline, - ]); + if (focusedEngine !== "claude") return; + // Account reads do not use the chat reader. The Wrapper coalesces requests + // across sessions/tabs; background pages need no periodic refresh. + const refreshVisible = () => { + if (document.visibilityState === "visible") refreshStatus(); + }; + const timer = window.setInterval(refreshVisible, 60_000); + return () => { + window.clearInterval(timer); + }; + }, [authed, focusedSid, focusedEngine, state.newChat, archivedBrowse, + state.connState, state.wrapperOnline, refreshStatus, rt.state]); useEffect(() => { const artifact = state.artifact; diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 1cbb47b4..6bb1cdf0 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -1061,8 +1061,8 @@ export function Composer(p: Props) { open={usageOpen} report={p.statusReport ?? null} rateLimits={p.rateLimits} - error={p.engine === "codex" ? p.statusError : null} - loading={p.engine === "codex" && p.statusLoading} + error={p.statusError} + loading={p.statusLoading} disabled={locked} onToggle={() => { if (locked) return; @@ -1070,10 +1070,9 @@ export function Composer(p: Props) { setCtxOpen(false); setAutoCompactOpen(false); setUsageOpen(opening); - if (opening && p.engine === "codex") p.onRefreshUsage?.(); + if (opening) p.onRefreshUsage?.(); }} - onRefresh={p.engine === "codex" - ? () => p.onRefreshUsage?.() : undefined} + onRefresh={() => p.onRefreshUsage?.()} onOpenStatus={p.engine === "codex" && p.onStatus ? () => { setUsageOpen(false); p.onStatus?.(); diff --git a/web/src/components/UsageMeter.tsx b/web/src/components/UsageMeter.tsx index 913e4d4e..67b3c6bc 100644 --- a/web/src/components/UsageMeter.tsx +++ b/web/src/components/UsageMeter.tsx @@ -159,6 +159,12 @@ export function UsageMeter({ quotas.fiveHour || quotas.weekly || quotas.overall ); const hasAnyQuota = hasAccountQuota || specializedWindows.length > 0; + const claudeReadError = engine === "claude" + ? report?.component_errors.find((reason) => reason.startsWith("rate_limits:")) + : undefined; + const claudeReadNote = claudeReadError?.endsWith("usage helper unavailable") + ? "未配置主动额度查询,等待原生同步。" + : claudeReadError ? "额度刷新失败,请重试。" : null; const provider = engine === "claude" ? "Claude" : "Codex"; const quotaSummary = hasOverallQuota ? `${provider} 总额度${compactPercent(overall)}` @@ -217,7 +223,7 @@ export function UsageMeter({ ) : !hasAnyQuota ? (
{engine === "claude" - ? "尚未收到 Claude Code 的额度事件;原生额度更新后会自动同步。" + ? claudeReadNote ?? "额度尚未同步,请刷新。" : visibleReport?.account?.auth_type === "chatgpt" ? "账户已登录;本次额度读取失败,请刷新重试。" : "当前 Codex app-server 暂未提供账户额度。"} @@ -240,12 +246,13 @@ export function UsageMeter({ /> ))} } + {hasAnyQuota && claudeReadNote &&
+ {claudeReadNote} 当前显示上次同步数据。 +
}
{error ? "旧账户数据已隐藏" : loading ? "正在更新…" - : engine === "claude" - ? "Claude Code 原生额度事件自动同步" - : "来自当前 Codex 账户"} + : `来自当前 ${provider} 账户`} {onRefresh && } {onOpenStatus && } diff --git a/web/tests/notices-rate-limits.test.ts b/web/tests/notices-rate-limits.test.ts index 10553e59..3abad019 100644 --- a/web/tests/notices-rate-limits.test.ts +++ b/web/tests/notices-rate-limits.test.ts @@ -865,6 +865,7 @@ try { error: null, loading: false, onToggle: () => {}, + onRefresh: () => {}, })); assert.match(claudeUsageMarkup, /5 小时额度/); assert.match(claudeUsageMarkup, /每周额度/); @@ -872,9 +873,28 @@ try { assert.match(claudeUsageMarkup, /剩余 65%/); assert.match(claudeUsageMarkup, /剩余 30%/); assert.match(claudeUsageMarkup, /剩余 20%/); - assert.match(claudeUsageMarkup, /原生额度事件自动同步/); - assert.doesNotMatch(claudeUsageMarkup, />刷新<|完整状态/, - "Claude SDK has no supported pull/status API, so the popover is push-only"); + assert.match(claudeUsageMarkup, /来自当前 Claude 账户/); + assert.match(claudeUsageMarkup, />刷新 {}, onRefresh: () => {}, + })); + assert.match(markup, /剩余 65%/); + if (failure) assert.match(markup, /当前显示上次同步数据/); + else assert.doesNotMatch(markup, /刷新失败|暂不支持/); + const empty = renderToStaticMarkup(createElement(UsageMeter, { + engine: "claude", open: true, report: { ...report, rate_limits: [] }, + onToggle: () => {}, onRefresh: () => {}, + })); + assert.doesNotMatch(empty, /剩余 0%|已用尽|先发送/); + assert.match(empty, failure === "usage helper unavailable" ? /未配置主动额度查询/ + : failure ? /额度刷新失败/ : /额度尚未同步/); + } const compactUsageMarkup = renderToStaticMarkup(createElement(UsageMeter, { open: false, report: quotaReport, diff --git a/web/tests/reliability.test.ts b/web/tests/reliability.test.ts index d714bfe1..9d93333e 100644 --- a/web/tests/reliability.test.ts +++ b/web/tests/reliability.test.ts @@ -18263,7 +18263,7 @@ assert.match(composerSource, /locked = offline \|\| !!controlUi\?\.locked \|\| p\.archived === true/, "archived history must keep the composer read-only"); assert.match(composerSource, - /loading=\{p\.engine === "codex" && p\.statusLoading\}[\s\S]{0,80}disabled=\{locked\}[\s\S]{0,100}if \(locked\) return/, + /loading=\{p\.statusLoading\}[\s\S]{0,80}disabled=\{locked\}[\s\S]{0,100}if \(locked\) return/, "a locked composer must not reopen live account controls"); assert.match(composerSource, /aria-label="上下文占用"[\s\S]{0,100}disabled=\{locked\}[\s\S]{0,100}if \(locked\) return/,