diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea0abaf6..9cbc0e0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -211,6 +211,11 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} run: | + notes_file="docs/releases/$RELEASE_TAG.md" + notes_args=(--generate-notes) + if [ -f "$notes_file" ]; then + notes_args=(--notes-file "$notes_file") + fi if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft)" test "$is_draft" = true || { @@ -222,7 +227,7 @@ jobs: --verify-tag \ --draft \ --title "cc-remote $RELEASE_TAG" \ - --generate-notes + "${notes_args[@]}" fi gh release upload "$RELEASE_TAG" dist/* --clobber gh release edit "$RELEASE_TAG" --draft=false --latest diff --git a/AGENTS.md b/AGENTS.md index 88faf8ce..fe7907f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ attachment and optional App-control MCP tools are separate user choices. transport, never the caller's Origin. Uvicorn trusts forwarded transport metadata only from loopback Caddy. Never put tokens in URLs or protocol message bodies; logging redacts token/password fields. -- **Protocol version gate**: current wire protocol v71 is declared by +- **Protocol version gate**: current wire protocol v72 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. `deserialize` hard-rejects a version mismatch, and `_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d043cd1..608d4726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ ## Unreleased +## v4.0.0 + +cc-remote v4 brings persistent Claude sessions, native steering, durable history +and per-turn diffs, richer side chats and previews, and a terminal workspace +preview to the Claude/Codex control plane. See the bilingual +[release notes](docs/releases/v4.0.0.md) for highlights and upgrade requirements. + +### Final stability fixes + +- Drain injected Claude background responses without blocking the human Result + or the next prompt. Track actual native continuations independently of child + task status, including when the CLI omits an internal user echo. +- Keep task results absorbed during an active response on that response's + original timeline. Restore consumed queued human prompts from native JSONL + and remove empty continuation process disclosures. +- Recover the original working directory from bounded native transcript reads + when a large leading image hides it from the SDK catalog. +- Deduplicate manual compaction using the exact native boundary receipt. +- Yield live-output scroll corrections to touch/scrollbar gestures and keyboard + viewport changes; suppress duplicate iOS keyboard paste deliveries while + preserving deliberate repeated pastes. + +### Changes since v3.0.0 + +- Track native Claude continuations and their live processing state under protocol v72. - Combine terminal queue controls and session recovery under protocol v71. Upgrade Relay, Wrapper, Web and TUI together. TUI recovery replaces replayed text without duplicating existing output. diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 82f5a778..bc8bed50 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -4,6 +4,26 @@ ## 未发布 +## v4.0.0 + +cc-remote v4 为 Claude/Codex 控制面带来独立 Claude 会话服务、原生引导、可恢复 +历史与每轮差异归档、更完整的侧聊和文件预览,以及终端工作台预览版。 +功能重点与升级要求见[双语发布说明](docs/releases/v4.0.0.md)。 + +### 稳定性收敛 + +- 正确消费 Claude 后台注入响应,不再阻塞主轮结束或下一条消息;按真实原生续跑 + 更新运行状态,区分子任务状态,并支持 CLI 省略内部用户回显的情况。 +- 子任务在主轮执行中返回时沿用原时间线;从原生 JSONL 恢复已消费的排队提示词, + 移除没有实际过程内容的续跑折叠项。 +- 首条大图片遮住 SDK 目录信息时,通过有界原生记录读取恢复原始工作目录。 +- 按原生压缩边界回执去重手动压缩记录。 +- 流式输出时尊重触摸、滚动条和键盘视口变化,避免抢回底部或反复抖动;拦截 iOS + 键盘重复投递的粘贴事件,同时保留用户主动连续粘贴。 +- 最终协同协议为 v72,Relay、Wrapper、Web 和 TUI 必须使用一致版本。 + +### 自 v3.0.0 以来的改动 + - 终端队列控制与会话恢复合并使用协议 v71,Relay、Wrapper、Web 和 TUI 需要 一起升级。TUI 恢复时替换已有文本,不再重复追加已显示的内容。 diff --git a/CLAUDE.md b/CLAUDE.md index 5225266f..0ff8daf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ a separate choice; sharing alone does not authorize them. `useLayoutEffect` is deliberately dependency-free — late virtualizer/image measurements settle without a React render, and constraining it to its read set reintroduces a full-viewport jump on touch release. -- **Protocol version gate**: current wire protocol v71 is declared by +- **Protocol version gate**: current wire protocol v72 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. `deserialize` hard-rejects a version mismatch, and `_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all diff --git a/README.md b/README.md index 153396d8..e075a1fa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI -**产品版本:v3.0.0** · Wire protocol v71 +**产品版本:v4.0.0** · Wire protocol v72 [English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) · [终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md) diff --git a/README_en.md b/README_en.md index fd6c11a3..133499e0 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI -**Product version: v3.0.0** · Wire protocol v71 +**Product version: v4.0.0** · Wire protocol v72 [中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) · [Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md) diff --git a/cc_remote/__init__.py b/cc_remote/__init__.py index cf00a507..e1fc0abe 100644 --- a/cc_remote/__init__.py +++ b/cc_remote/__init__.py @@ -5,4 +5,4 @@ - control link: client <-> relay(WS) <-> wrapper <-> ClaudeSDKClient <-> cc """ -__version__ = "3.0.0" +__version__ = "4.0.0" diff --git a/cc_remote/claude_service/server.py b/cc_remote/claude_service/server.py index ba6c9b79..27904b60 100644 --- a/cc_remote/claude_service/server.py +++ b/cc_remote/claude_service/server.py @@ -19,7 +19,7 @@ from pathlib import Path from uuid import uuid4 -from cc_remote.claude_steering import PendingSteers, steer_message, _origin_key +from cc_remote.claude_steering import PendingSteers, background_end_ids, is_managed_input, steer_message from .wire import ( ControllerLeaseConflict, decode_sdk, encode_sdk, private_directory, @@ -99,6 +99,8 @@ def __init__(self, directory: Path, metadata: dict, factory=None): self.callback_answers: dict[str, object] = {} self.initializers: dict[str, dict] = {} self.turn: dict | None = None + self.managed_input_seen = False + self.pending_compact = False self.steers = PendingSteers() # Controller replacement may happen before an attachment's native echo. # Keep ownership here until exact terminal commit or explicit close. @@ -198,8 +200,17 @@ async def read_messages(self) -> None: async for value in self.client._query.receive_messages(): if self.closed: return - value = self.steers.annotate(value) + managed_pending = bool(self.turn and self.terminal_seq is None + and not self.turn.get("awaiting_steer")) + if managed_pending and is_managed_input( + value, pending_compact=self.pending_compact and not self.steers.background_id, + ): + self.managed_input_seen = True + self.origin_id = self.turn["id"] + value = self.steers.annotate( + value, managed_active=managed_pending and self.managed_input_seen) if "__cc_steer" in value: + self.managed_input_seen = True self.origin_id = value["__cc_steer"]["id"] if self.turn is not None: if self.turn.get("awaiting_steer"): @@ -219,17 +230,15 @@ async def read_messages(self) -> None: # stop the sole native reader at a per-turn byte cap: an offline # long turn could then never deliver the Result that frees it. seq = self.journal.append(value) - origin = value.get("origin") - kind = origin.get("kind") if isinstance(origin, dict) else None - if value.get("type") == "user" and kind not in (None, "human"): + background = value.get("__cc_background_start") + if background and not any(t["identity"] == background["id"] for t in self.background_turns): self.background_turns.append({"start_seq": seq - 1, "terminal_seq": None, - "origin": _origin_key(origin), - "identity": self.steers.background_id}) - if value.get("type") == "result" and kind not in (None, "human"): - for turn in reversed(self.background_turns): - if turn["terminal_seq"] is None and turn.get("origin") == _origin_key(origin): + "identity": background["id"]}) + ended = background_end_ids(value) + if ended: + for turn in self.background_turns: + if turn["terminal_seq"] is None and turn["identity"] in ended: turn["terminal_seq"] = seq - break if value.get("type") == "system": subtype = value.get("subtype") task_id = value.get("task_id") @@ -242,8 +251,14 @@ async def read_messages(self) -> None: self.metadata["session_id"] = sid if (self.turn is not None and _human_result(value) and not self.turn.get("awaiting_steer") - and not value.get("__cc_steer_intermediate")): + and not value.get("__cc_steer_intermediate") + and (self.managed_input_seen or not ended)): self.terminal_seq = seq + if not self.managed_input_seen: + # Commands/errors can terminate without a user replay. + self.origin_id = self.turn["id"] + if _human_result(value): + self.managed_input_seen = False await self.notify() except asyncio.CancelledError: raise @@ -266,6 +281,7 @@ def description(self) -> dict: "task_seeds": list(self.task_seeds.values()), "native_steering": True, "background_steering": True, + "background_activity_steering": True, "pending_steers": {uid: {"id": data["id"]} for uid, data in self.steers.pending.items()}, } @@ -362,6 +378,8 @@ async def _mutate(self, method: str, params: dict): "previous_origin_id": self.origin_id, "start_seq": self.journal.seq, "started_at": time.time()} self.terminal_seq = None + self.managed_input_seen = False + self.pending_compact = False directory = params["metadata"].get("attachment_dir") if directory: self.steer_attachment_dirs.add(directory) @@ -386,8 +404,11 @@ async def stream(): prompt = _prompt_messages(prompt) elif not isinstance(prompt, str): raise ValueError("Claude prompt must be text or message objects") - self.turn = {**params["turn"], "start_seq": self.journal.seq, "started_at": time.time()} - self.origin_id = self.turn["id"] + 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.pending_compact = (isinstance(prompt, str) + and prompt.split(maxsplit=1)[:1] == ["/compact"]) self.terminal_seq = None self.submitted_turns.add(self.turn["id"]) self.callback_answers.clear() @@ -404,6 +425,7 @@ async def stream(): if self.terminal_seq is None or params["seq"] != self.terminal_seq: raise ValueError("Claude terminal was not acknowledged exactly") self.ack = max(self.ack, self.terminal_seq) + self._retire_backgrounds(self.terminal_seq) self.turn = None self._cleanup_steer_attachments() self.journal.prune( @@ -416,16 +438,7 @@ async def stream(): if not 0 <= seq <= self.journal.seq: raise ValueError("invalid Claude journal acknowledgement") self.ack = max(self.ack, seq) - retired = [turn["terminal_seq"] for turn in self.background_turns - if turn["terminal_seq"] is not None and seq >= turn["terminal_seq"]] - if retired: - self.background_turns = [turn for turn in self.background_turns - if turn["terminal_seq"] not in retired] - self.task_seeds = { - key: seed for key, seed in self.task_seeds.items() - if seed["data"].get("subtype") != "task_notification" - or seed["seq"] > max(retired) - } + self._retire_backgrounds(seq) boundary = min(self.ack, self.turn["start_seq"]) if self.turn else self.ack if self.background_start is not None: boundary = min(boundary, self.background_start) @@ -437,6 +450,18 @@ async def stream(): return None raise ValueError("unknown Claude service operation") + def _retire_backgrounds(self, seq: int) -> None: + retired = [turn["terminal_seq"] for turn in self.background_turns + if turn["terminal_seq"] is not None and seq >= turn["terminal_seq"]] + if retired: + self.background_turns = [turn for turn in self.background_turns + if turn["terminal_seq"] not in retired] + self.task_seeds = { + key: seed for key, seed in self.task_seeds.items() + if seed["data"].get("subtype") != "task_notification" + or seed["seq"] > max(retired) + } + def _cleanup_steer_attachments(self) -> None: for directory in self.steer_attachment_dirs: shutil.rmtree(directory, ignore_errors=True) diff --git a/cc_remote/claude_steering.py b/cc_remote/claude_steering.py index e0ee6b3a..363781a2 100644 --- a/cc_remote/claude_steering.py +++ b/cc_remote/claude_steering.py @@ -26,6 +26,48 @@ def _origin_key(origin: dict) -> str: return json.dumps(origin, sort_keys=True) +def _main_activity(value: dict) -> bool: + if value.get("parent_tool_use_id") or value.get("parentToolUseID"): + return False + kind = value.get("type") + if kind == "assistant": + return True + if kind == "stream_event": + return value.get("event", {}).get("type") == "message_start" + return (kind == "system" and value.get("subtype") == "status" + and value.get("status") in {"requesting", "compacting"}) + + +def background_end_ids(value: dict) -> tuple[str, ...]: + """Exact native continuations settled by this journaled boundary.""" + return tuple(value.get("__cc_background_ends") or ( + [value["__cc_background_end"]] if value.get("__cc_background_end") else [])) + + +def is_managed_input(value: dict, *, pending_compact: bool = False) -> bool: + """Native consumption evidence, excluding child output and tool results. + + Manual /compact may report its boundary before replaying its user input. + Only a controller with that exact command pending may claim those frames. + """ + origin = value.get("origin") + if (value.get("parent_tool_use_id") or value.get("parentToolUseID") + or (isinstance(origin, dict) and origin.get("kind") not in (None, "human"))): + return False + if value.get("type") == "user": + content = value.get("message", {}).get("content") + return not (isinstance(content, list) and any( + isinstance(part, dict) and part.get("type") in {"tool_result", "server_tool_result"} + for part in content)) + if pending_compact and value.get("type") == "system": + if value.get("subtype") == "status": + return value.get("status") == "compacting" + if value.get("subtype") == "compact_boundary": + metadata = value.get("compact_metadata", value.get("compactMetadata")) + return isinstance(metadata, dict) and metadata.get("trigger") == "manual" + return False + + class PendingSteers: """Fence accepted inputs against their exact replayed human UUIDs. @@ -40,35 +82,85 @@ def __init__(self): self.background_id: str | None = None self.background_origin: str | None = None self.background_origin_data: dict | None = None + self._backgrounds: dict[str, dict] = {} def handoff_background(self, identity: str | None) -> None: - if identity and identity == self.background_id: - self.background_id = None - self.background_origin = None - self.background_origin_data = None + self._backgrounds.pop(identity, None) + self.background_id = next(reversed(self._backgrounds), None) + current = self._backgrounds.get(self.background_id, {}) + self.background_origin = current.get("origin_key") + self.background_origin_data = current.get("origin") def add(self, native_id: str, metadata: dict) -> None: if len(self.pending) >= 32 or native_id in self.pending: raise ClaudeSteerRejected("Claude steering capacity reached") self.pending[native_id] = metadata - def annotate(self, value: dict) -> dict: + def annotate(self, value: dict, *, managed_active: bool = False) -> dict: origin = value.get("origin") kind = origin.get("kind") if isinstance(origin, dict) else None - if not value.get("parent_tool_use_id") and kind not in (None, "human"): + child = value.get("parent_tool_use_id") or value.get("parentToolUseID") + if not child: message = value.get("message") content = message.get("content") if isinstance(message, dict) else None tool_result = isinstance(content, list) and any( isinstance(part, dict) and part.get("type") == "tool_result" for part in content) - if value.get("type") == "user" and not tool_result: - self.background_origin = _origin_key(origin) - self.background_origin_data = origin - self.background_id = "background-" + hashlib.sha256(json.dumps( - [value.get("uuid"), self.background_origin], sort_keys=True).encode()).hexdigest()[:32] - elif value.get("type") == "result" and _origin_key(origin) == self.background_origin: - self.background_id = None - self.background_origin = None - self.background_origin_data = None + injected = (value.get("type") == "user" and not tool_result + and kind not in (None, "human")) + start = value.get("__cc_background_start") + # Code can omit replay of an internal task-notification user row. + # An unsolicited top-level request/output still proves a main turn; + # task edges and forwarded child output do not. + if start or injected or (not managed_active and not self.background_id + and _main_activity(value)): + if start: + identity = start["id"] + origin_key, origin_data = start.get("origin_key"), start.get("origin") + managed = start.get("managed", managed_active) + else: + origin_key = _origin_key(origin) if injected else None + origin_data = origin if injected else None + managed = managed_active + # Several task inputs may feed one physical response. A + # repeated origin refines its existing claim, not a second + # response for which the CLI may never send a Result. + identity = next((key for key, entry in self._backgrounds.items() + if entry["managed"] == managed + and entry["origin_key"] in (None, origin_key)), None) + identity = identity or "background-" + hashlib.sha256(json.dumps( + [value.get("uuid"), origin_key], sort_keys=True).encode()).hexdigest()[:32] + self._backgrounds[identity] = { + "origin_key": origin_key, "origin": origin_data, "managed": managed, + } + self.background_id = identity + self.background_origin = origin_key + self.background_origin_data = origin_data + value = {**value, "__cc_background_start": { + "id": identity, "origin_key": self.background_origin, + "origin": self.background_origin_data, "managed": managed, + }} + elif value.get("type") == "result": + ended = background_end_ids(value) + if not ended: + if kind in (None, "human"): + # 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. + if managed_active: + ended = tuple(key for key, entry in self._backgrounds.items() + if entry["managed"]) + elif kind is None: + ended = tuple(self._backgrounds) + else: + # A precise unrelated origin remains authoritative. + ended = tuple(key for key, entry in self._backgrounds.items() + if entry["origin_key"] in (None, _origin_key(origin))) + if ended: + value = {**value, "__cc_background_ends": list(ended)} + if len(ended) == 1: + value["__cc_background_end"] = ended[0] + for identity in ended: + self.handoff_background(identity) cancelled = value.get("__cc_steer_cancelled") if cancelled: self.pending = {uid: data for uid, data in self.pending.items() @@ -80,7 +172,7 @@ def annotate(self, value: dict) -> dict: if metadata is not None: return {**value, "type": "system", "subtype": "cc_remote_steer_cancelled", "__cc_steer_cancelled": metadata} - if kind not in (None, "human") or value.get("parent_tool_use_id"): + if kind not in (None, "human") or child: return value if value.get("type") == "user": metadata = self.pending.pop(value.get("uuid"), None) diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py index ffb47b54..75c70c29 100644 --- a/cc_remote/protocol.py +++ b/cc_remote/protocol.py @@ -28,7 +28,7 @@ MAX_SINGLE_ATTACHMENT_BYTES, ) -PROTOCOL_VERSION = 71 +PROTOCOL_VERSION = 72 # Codex Desktop renders a 53-week daily token-activity calendar. Keep the wire # payload to that same bounded window so an account response can never turn a @@ -734,6 +734,9 @@ class StateEvent(_Base): phase: Optional[Literal["retrying", "waiting"]] = None detail: Optional[str] = Field(default=None, max_length=4096) msg_id: Optional[WireId] = None + # A native main-agent continuation may extend an already-completed human + # row. Ordinary late progress must never revive that row's activity. + continuation: bool = False class Model(_Base): diff --git a/cc_remote/wrapper/claude_catalog.py b/cc_remote/wrapper/claude_catalog.py index 23d13ca1..8e280299 100644 --- a/cc_remote/wrapper/claude_catalog.py +++ b/cc_remote/wrapper/claude_catalog.py @@ -9,6 +9,7 @@ from __future__ import annotations import errno +from dataclasses import replace import json import os from pathlib import Path @@ -41,14 +42,57 @@ ) from claude_agent_sdk.types import SDKSessionInfo, SessionMessage +from cc_remote.attachments import MAX_TOTAL_ATTACHMENT_BYTES + _MAX_PROJECT_DIRS = 16_384 +# A native user row can carry the full attachment set as base64. Reserve room +# for the prompt and native metadata as well as the expanded image bytes. +_CWD_RECORD_BYTES = 2 * MAX_TOTAL_ATTACHMENT_BYTES +# Native queue records can repeat those images before the actual user row. +# Bound the scan independently while allowing both copies and leading metadata. +_CWD_SCAN_BYTES = 3 * _CWD_RECORD_BYTES def projects_dir(config_dir: str | os.PathLike[str]) -> Path: return Path(config_dir) / "projects" +def _project_bucket_names(directory: str) -> tuple[str, ...]: + sdk_name = _sanitize_path(directory) + if not any(ord(char) > 0xFFFF for char in directory): + return (sdk_name,) + # The native JS sanitizer and hash operate on UTF-16 code units. The + # Python SDK iterates code points, producing different keys for emoji. + encoded = directory.encode("utf-16-le", errors="surrogatepass") + native_units = "".join( + chr(encoded[index] | encoded[index + 1] << 8) + for index in range(0, len(encoded), 2) + ) + native_name = _sanitize_path(native_units) + return (sdk_name,) if native_name == sdk_name else (sdk_name, native_name) + + +def _matches_project_bucket( + names: tuple[str, ...], bucket: str, *, allow_legacy_hash: bool = False, +) -> bool: + if bucket in names: + return True + if not allow_legacy_hash: + return False + # Older native builds used a different hash. Match the same bounded + # prefix as the SDK catalog, requiring a nonempty lowercase hash suffix. + suffix = bucket[MAX_SANITIZED_LENGTH + 1:] + return ( + suffix.isascii() and suffix.isalnum() and suffix == suffix.lower() + and any( + len(name) > MAX_SANITIZED_LENGTH + and bucket.startswith(name[:MAX_SANITIZED_LENGTH] + "-") + for name in names + ) + ) + + def _project_entries(config_dir: str | os.PathLike[str]) -> list[Path]: root = projects_dir(config_dir) try: @@ -89,17 +133,18 @@ def _directory_candidates( result: list[tuple[Path, str]] = [] seen: set[Path] = set() for project_path in roots: - sanitized = _sanitize_path(project_path) - exact = by_name.get(sanitized) + names = _project_bucket_names(project_path) + exact = next((by_name[name] for name in names if name in by_name), None) if exact is not None and exact not in seen: seen.add(exact) result.append((exact, project_path)) continue - if len(sanitized) <= MAX_SANITIZED_LENGTH: + if all(len(name) <= MAX_SANITIZED_LENGTH for name in names): continue - prefix = sanitized[:MAX_SANITIZED_LENGTH] + "-" for entry in available: - if entry not in seen and entry.name.startswith(prefix): + if entry not in seen and _matches_project_bucket( + names, entry.name, allow_legacy_hash=True, + ): seen.add(entry) result.append((entry, project_path)) return result @@ -178,6 +223,67 @@ def transcript_presence( entries.close() +def recover_session_cwd( + info: SDKSessionInfo | None, + path: str | os.PathLike[str] | None, +) -> SDKSessionInfo | None: + """Fill a lite-read miss from complete native records, never a prompt. + + An initial queued image can occupy the SDK's entire 64 KiB head window. + Missing cwd then says nothing about whether the conversation is resumable. + Read a bounded prefix and require the original transcript's project bucket; + later messages may record a different cwd after a shell directory change. + """ + if info is None or info.cwd or path is None: + return info + source = Path(path) + remaining = _CWD_SCAN_BYTES + discard = False + try: + with source.open("rb") as stream: + while remaining > 0: + limit = min(_CWD_RECORD_BYTES, remaining) + line = stream.readline(limit) + if not line: + break + remaining -= len(line) + complete = line.endswith(b"\n") or len(line) < limit + if discard: + discard = not complete + continue + if not complete: + discard = True + continue + try: + row = json.loads(line) + except (ValueError, UnicodeError): + continue + if (not isinstance(row, dict) + or row.get("type") not in {"user", "assistant"} + or row.get("isSidechain") is True + or row.get("sessionId") != info.session_id + or not isinstance(row.get("message"), dict)): + continue + cwd = row.get("cwd") + if (isinstance(cwd, str) and os.path.isabs(cwd) + and "\x00" not in cwd + and _matches_project_bucket( + _project_bucket_names(cwd), source.parent.name, + # Only the explicit native root identifies the + # original cwd when an old hash cannot be checked. + # Later messages can share the same long prefix. + allow_legacy_hash=( + row.get("type") == "user" + and "parentUuid" in row + and row["parentUuid"] is None + ), + )): + return replace(info, cwd=cwd) + except OSError: + pass + return info + + def list_sessions( config_dir: str | os.PathLike[str], *, @@ -207,6 +313,7 @@ def list_sessions( continue info = _parse_session_info_from_lite( session_id, lite, project_path) + info = recover_session_cwd(info, entry) if info is None: continue previous = by_id.get(session_id) @@ -228,7 +335,8 @@ def get_session_info( if lite is None: return None project_path = _canonicalize_path(directory) if directory else None - return _parse_session_info_from_lite(session_id, lite, project_path) + return recover_session_cwd( + _parse_session_info_from_lite(session_id, lite, project_path), path) def get_session_messages( diff --git a/cc_remote/wrapper/claude_external.py b/cc_remote/wrapper/claude_external.py index 6a8f4cfb..c099ea52 100644 --- a/cc_remote/wrapper/claude_external.py +++ b/cc_remote/wrapper/claude_external.py @@ -165,7 +165,8 @@ def classify_claude_growth( external_evidence = True continue if (isinstance(prompt_source, str) and prompt_source - and prompt_source not in _SDK_PROMPT_SOURCES): + and prompt_source not in _SDK_PROMPT_SOURCES + and not (entrypoint in _SDK_ENTRYPOINTS and prompt_source == "system")): external_evidence = True continue if entrypoint in _SDK_ENTRYPOINTS or prompt_source in _SDK_PROMPT_SOURCES: diff --git a/cc_remote/wrapper/claude_steer.py b/cc_remote/wrapper/claude_steer.py index d9249ade..17fbd60b 100644 --- a/cc_remote/wrapper/claude_steer.py +++ b/cc_remote/wrapper/claude_steer.py @@ -12,8 +12,11 @@ from cc_remote.attachments import validate_attachments from cc_remote.claude_steering import ClaudeSteerRejected +from cc_remote.log import logger from cc_remote.protocol import ERR_NOT_STEERABLE, ERR_STEER_UNKNOWN, Error, TurnSteered +log = logger("cc_remote.wrapper.claude_steer") + async def handle(machine, ctx, cmd, reject): if ctx.state != "running" or ctx.write_state != "writable": @@ -53,8 +56,10 @@ async def handle(machine, ctx, cmd, reject): await ctx.sdk.steer(prompt, native_id=native_id, metadata=metadata) # The command ACK transfers ownership. The replayed native UserMessage # publishes TurnSteered later, after the preceding tool/text finishes. - except ClaudeSteerRejected: + except ClaudeSteerRejected as exc: attempted = False + log.info("Claude native input rejected", session_id=ctx.session_id, + reason=str(exc)) return await reject(ERR_NOT_STEERABLE, "Claude 当前无法接收引导,本次未发送;请稍后重试或排队。") except Exception: @@ -97,6 +102,7 @@ async def run(): if previous is not None and not previous.done(): await asyncio.shield(previous) ctx.turn_task = asyncio.current_task() + ctx.claude_background_followups.pop(metadata.get("background_id"), None) origin_key = machine._claude_followup_origin_key(metadata.get("background_origin")) if origin_key is not None: ctx.claude_background_followups.pop(origin_key, None) diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py index aa94e79b..193312d7 100644 --- a/cc_remote/wrapper/history_store.py +++ b/cc_remote/wrapper/history_store.py @@ -68,7 +68,8 @@ # v41 keeps native isMeta recovery prompts inside their original human turn. # v42 replaces recovered text prefixes and bounds summary answer block counts. # v43 makes manual /compact a visible turn owning its native boundary. -_SCHEMA_VERSION = 43 +# v44 restores consumed human queued_command attachments as visible inputs. +_SCHEMA_VERSION = 44 _FINGERPRINT_SAMPLE_BYTES = 64 * 1024 _DEFAULT_MAX_ENTRIES = 128 _DEFAULT_MAX_BYTES = 64 * 1024 * 1024 @@ -1295,6 +1296,14 @@ def _select_page_row( def _ensure_schema(self) -> None: with self._connect() as connection: current = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if current in range(10, 44): + # Accepted human attachments change Claude turn ownership and + # cursors without changing transcript bytes. Rebuild only its + # derived narrative/graph; preserve other engines and assets. + for table in ("history_pages", "history_turn_details"): + connection.execute(f"DELETE FROM {table} WHERE engine='claude'") + for table in ("claude_compact_sources", "claude_compact_records", "claude_compact_queue"): + connection.execute(f"DROP TABLE IF EXISTS {table}") if current in range(10, 43): # /compact is now a visible native command. Its graph boundary # and the derived pages must agree; retain source-bound assets. @@ -1456,8 +1465,8 @@ 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): - # The independent v22-v43 invalidations above suffice. + 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): + # The independent v22-v44 invalidations above suffice. pass elif current not in (0, _SCHEMA_VERSION): # v9 changes the invariant of history_turn_details: those rows @@ -1614,6 +1623,7 @@ def _ensure_schema(self) -> None: source_offset INTEGER NOT NULL, record_bytes INTEGER NOT NULL, visible_user INTEGER NOT NULL, + public_user_id TEXT, PRIMARY KEY (source_path, source_device, source_inode, uuid) ) """ @@ -1656,7 +1666,7 @@ def get_claude_compact_index( snapshot_size: int | None, max_record_bytes: int, max_entries: int, - visible_user: Callable[[dict[str, Any]], bool], + visible_user: Callable[[dict[str, Any]], bool | str | None], ) -> ClaudeCompactChainIndex | None: """Incrementally index compact ancestry without retaining row payloads. @@ -1770,6 +1780,12 @@ def get_claude_compact_index( def text_field(name: str) -> str | None: value = row.get(name) return value if isinstance(value, str) else None + visible = visible_user(row) + # Accepted attachment edges use a different UUID from + # the native user echo. Index that public cursor without + # retaining/re-reading potentially large prompt bodies. + public_uid = (visible if isinstance(visible, str) + else uid if visible else None) new_records.append(( resolved, device, inode, uid, text_field("type"), text_field("subtype"), @@ -1777,7 +1793,7 @@ def text_field(name: str) -> str | None: text_field("logicalParentUuid"), (1 if row.get("isSidechain") is True else 0 if row.get("isSidechain") is False else None), - offset, len(line), int(bool(visible_user(row))), + offset, len(line), int(bool(visible)), public_uid, )) scan_complete = scan_offset >= target_size if new_records: @@ -1787,8 +1803,8 @@ def text_field(name: str) -> str | None: source_path, source_device, source_inode, uuid, row_type, subtype, parent_uuid, logical_parent_uuid, is_sidechain, - source_offset, record_bytes, visible_user - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + source_offset, record_bytes, visible_user, public_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT ( source_path, source_device, source_inode, uuid ) DO UPDATE SET @@ -1799,7 +1815,8 @@ def text_field(name: str) -> str | None: is_sidechain=excluded.is_sidechain, source_offset=excluded.source_offset, record_bytes=excluded.record_bytes, - visible_user=excluded.visible_user + visible_user=excluded.visible_user, + public_user_id=excluded.public_user_id """, new_records, ) @@ -1895,6 +1912,7 @@ def text_field(name: str) -> str | None: if row["is_sidechain"] == 0 else None), int(row["source_offset"]), bool(row["visible_user"]), int(row["record_bytes"]), + row["public_user_id"], ) for row in rows } diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index aa83b0ca..d8e06f9f 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -226,6 +226,7 @@ CLAUDE_DEFAULT_EFFORT, CLAUDE_DEFAULT_MODEL, ClaudeAutonomousFollowupPending, + ClaudeBackgroundBoundary, ClaudeServiceReplayRequired, SdkHandle, normalize_claude_model_selection, @@ -2994,12 +2995,16 @@ def _claude_catalog_list_sessions( ): if self._claude_config_root(profile) is None: if directory is None: - return list_sessions(limit=limit) - return list_sessions( - limit=limit, - directory=directory, - include_worktrees=include_worktrees, - ) + sessions = list_sessions(limit=limit) + else: + sessions = list_sessions( + limit=limit, + directory=directory, + include_worktrees=include_worktrees, + ) + return [claude_catalog.recover_session_cwd( + info, transcript_path(info.session_id)) if not info.cwd else info + for info in sessions] return claude_catalog.list_sessions( self._claude_catalog_root(profile), limit=limit, @@ -3016,8 +3021,11 @@ def _claude_catalog_session_info( ): if self._claude_config_root(profile) is None: if directory is None: - return get_session_info(session_id) - return get_session_info(session_id, directory=directory) + info = get_session_info(session_id) + else: + info = get_session_info(session_id, directory=directory) + return claude_catalog.recover_session_cwd( + info, transcript_path(session_id) if info is not None and not info.cwd else None) return claude_catalog.get_session_info( self._claude_catalog_root(profile), session_id, @@ -6485,6 +6493,15 @@ def _retire_claude_followup( cls, ctx: SessionContext, message: ResultMessage, ) -> bool: """Retire only the autonomous turn closed by this exact Result.""" + identities = getattr(message, "_cc_background_ends", ()) + if identities: + retired = False + for identity in identities: + retired = ctx.claude_background_followups.pop(identity, None) is not None or retired + return retired + identity = getattr(message, "_cc_background_end", None) + if identity is not None: + return ctx.claude_background_followups.pop(identity, None) is not None origin = getattr(message, "origin", None) origin_kind = origin.get("kind") if isinstance(origin, dict) else None if origin_kind == "human": @@ -6558,6 +6575,13 @@ def _observe_claude_task_lifecycle( ctx.claude_task_tracking_overflow = False return + boundary = getattr(message, "_cc_background_start", None) + if background and boundary: + overflowed = self._store_claude_followup(ctx, boundary["id"], "active") + if overflowed: + self._schedule_claude_followup_overflow_recovery(ctx) + return + if isinstance(message, ResultMessage): # Only the background callback knows that this Result closes an # injected turn. A managed human Result may overlap a notification @@ -6566,10 +6590,8 @@ def _observe_claude_task_lifecycle( if isinstance(message, UserMessage): if background and self._claude_injected_user_boundary(message): - # The pinned SDK marks every injected turn at its replayed - # top-level user boundary. This is stronger than inferring a - # continuation only from the last active task: channel/peer - # turns and each completion in a multi-task run are covered too. + # Preserve explicit boundaries for custom/legacy clients that + # do not pass through the raw stream ownership annotations. overflowed = self._activate_claude_followup(ctx, message) if overflowed: self._schedule_claude_followup_overflow_recovery(ctx) @@ -6599,19 +6621,6 @@ def _observe_claude_task_lifecycle( # injected turn: that owner still requires its exact Result. if ctx.claude_background_followups.get(key) == "notified": ctx.claude_background_followups.pop(key, None) - elif (background and isinstance(message, TaskNotificationMessage) - and not getattr(message, "_cc_service_seed", False)): - # A task_updated terminal is a task status, not evidence of a - # new model response. In particular killed tasks often emit no - # notification and no Result at all. Only a real notification - # can reserve the subsequent injected User/Result boundary. - # Reattach seeds describe already-consumed task state, not a - # fresh notification. Any unacknowledged continuation follows - # separately in the service's ordered User/Result replay. - overflowed = self._claim_claude_followup_notification( - ctx, task_id) - if overflowed: - self._schedule_claude_followup_overflow_recovery(ctx) return track = isinstance(message, (TaskStartedMessage, TaskProgressMessage)) @@ -11274,10 +11283,13 @@ async def _discard_query_queue( await asyncio.gather(task, return_exceptions=True) ctx.queued_query_drain_task = None - async def _set_state(self, ctx: SessionContext, state: State) -> None: + async def _set_state( + self, ctx: SessionContext, state: State, *, msg_id: str | None = None, + continuation: bool = False, + ) -> None: ctx.state = state ctx.queued_query_wakeup.set() - await self._emit(ctx, StateEvent(state=state)) + await self._emit(ctx, StateEvent(state=state, msg_id=msg_id, continuation=continuation)) log.info("state transition", sid=ctx.session_id, state=state) settings = getattr(ctx.sdk, "context_settings", None) if state == "idle" and ctx.engine == "codex" and settings is not None and settings.needs_apply: @@ -12319,11 +12331,12 @@ def _is_external(self, sid: str) -> bool: ) def _own_write(self, sid: str) -> bool: - """True only after this wrapper has launched the current Claude query.""" + """Whether the managed Claude connection currently owns native work.""" ctx = self._ctx_by_sid(sid) if ctx is None: return False # not resident => we cannot have written it - return bool(ctx.engine == "claude" and ctx.claude_write_active) + return bool(ctx.engine == "claude" and ( + ctx.claude_write_active or self._claude_autonomous_followup_pending(ctx))) async def _terminate_external_claude_holders( self, @@ -14572,6 +14585,7 @@ async def _build_history_source( def _read(): if claude_profile is None or claude_native_sid is None: raise ValueError("invalid Claude session route") + compact_snapshot = None if oversized_compact_page is not None: messages = oversized_compact_page.messages timestamps = oversized_compact_page.timestamps @@ -14647,6 +14661,8 @@ def _read(): claude_native_sid or sid, messages, path=source_path, timestamps=timestamps, internal_events=internal_events, + include_queued_prompts=( + oversized_compact_page is None and compact_snapshot is None), index_store=self._history_index, snapshot_size=(source_fingerprint.size if source_fingerprint else None), @@ -21615,6 +21631,14 @@ async def _on_claude_background_message( the autonomous work extends its origin turn rather than creating a new visible human turn. """ + if isinstance(message, ClaudeBackgroundBoundary): + for identity in message.identities: + ctx.claude_background_followups.pop(identity, None) + ctx.claude_background_translator = None + await self._settle_claude_lifecycle_if_quiescent(ctx) + self._schedule_pending_claude_auto_compact(ctx) + self._schedule_query_queue_drain(ctx) + return if getattr(message, "_cc_steer_cancelled", None): from cc_remote.wrapper import claude_steer @@ -21651,11 +21675,12 @@ async def _on_claude_background_message( # state. A child's background command has its own # notification/Result consumer inside that Agent. if ctx.state == "idle": - await self._set_state(ctx, "running") + await self._set_state(ctx, "running", msg_id=turn_id, continuation=True) elif ctx.state in {"interrupting", "draining"}: self._schedule_claude_autonomous_interrupt_watchdog(ctx) translator = ctx.claude_background_translator - if translator is None or self._claude_injected_user_boundary(message): + if (translator is None or self._claude_injected_user_boundary(message) + or getattr(message, "_cc_background_start", None)): # Idle system updates can create a translator before # any user turn exists. Rebind at the native injected # boundary so the answer extends its actual owner, @@ -21716,6 +21741,8 @@ async def _on_claude_background_message( managed is not None and not managed.done() ) if not managed_active: + if ctx.session_id: + self._resync_watch(self._ctx_wire_sid(ctx)) await self._settle_claude_lifecycle_if_quiescent(ctx) finally: # A task/hook notification can itself trigger Claude's @@ -27120,12 +27147,11 @@ async def _handle_switch_session(self, cmd) -> None: if c.engine == "claude" and c.session_id == native_sid), None) if ctx is None and engine == "claude" and actual_space == "code": - # Claude's catalog accepts metadata-only JSONL files (for example, - # an ai-title row) even though the native CLI cannot resume them: - # there is no message cwd or conversation chain to restore. Catch - # that exact on-disk state before _spawn so one click produces one - # session-scoped error instead of both _spawn's focused error and - # this handler's fallback error. + # Refuse an unproven cwd before _spawn falls back to the default. + # A lite catalog miss can be an oversized image rather than lost + # history; the catalog first tries bounded native-record recovery. + # If still unknown, preserve the transcript and emit one scoped + # error without claiming the user's conversation is corrupt. assert claude_profile is not None info = await asyncio.to_thread( self._claude_catalog_session_info, @@ -27138,8 +27164,8 @@ async def _handle_switch_session(self, cmd) -> None: error = Error( code=ERR_NOT_RUNNING, message=( - "Claude 会话历史不完整,无法恢复;" - "可从会话菜单删除该条目。" + "无法确认此 Claude 会话的工作目录,暂时无法恢复。" + "历史记录已保留。" ), request_id=getattr(cmd, "cmd_id", None), sid=sid, @@ -36672,6 +36698,7 @@ async def _load_history(self, ctx: SessionContext, session_id: Optional[str]) -> msgs = await asyncio.to_thread( recover_claude_native_metadata, session_id, msgs, path=path, timestamps=timestamps, internal_events=internal_events, + include_queued_prompts=compact_snapshot is None, index_store=self._history_index, snapshot_size=(os.path.getsize(path) if path else None), ) diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index 4475cfaa..9e62622c 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -26,13 +26,17 @@ from claude_agent_sdk.types import ( AssistantMessage, ResultMessage, + ServerToolResultBlock, SystemMessage, + ToolResultBlock, UserMessage, ) from mcp.server import Server from cc_remote.config import WrapperConfig -from cc_remote.claude_steering import ClaudeSteerRejected, PendingSteers, steer_message +from cc_remote.claude_steering import ( + ClaudeSteerRejected, PendingSteers, background_end_ids, is_managed_input, steer_message, +) from cc_remote.log import logger from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER from cc_remote.wrapper.child_env import claude_profile_child_env @@ -136,6 +140,14 @@ def __init__(self, error: BaseException): self.error = error +class ClaudeBackgroundBoundary: + """Retire in-turn injections in callback order without duplicating Result.""" + + def __init__(self, identities: tuple[str, ...], *, durable: bool = False): + self.identities = identities + self.durable = durable + + _MESSAGE_PUMP_END = object() @@ -338,6 +350,7 @@ def __init__( # their UserMessage/ResultMessage ``origin`` instead of assigning every # frame to whichever browser query happened to write most recently. self._turn_active = False + self._managed_input_seen = False self._turn_consumer_active = False self._message_route_owner: str | None = None self._turn_background_release: asyncio.Event | None = None @@ -712,8 +725,15 @@ async def connect( self._turn_origin_id = self.service_recovery.get("previous_origin_id") else: self._turn_active = True - self._turn_origin_id = self.service_recovery["id"] - self._message_route_owner = "managed" + if "previous_origin_id" in self.service_recovery: + # Replay from the accepted write, which can still precede + # buffered autonomous activity and the actual human echo. + self._turn_origin_id = self.service_recovery["previous_origin_id"] + self._pending_turn_origin_id = self.service_recovery["id"] + self._pending_turn_background_release = asyncio.Event() + else: + self._turn_origin_id = self.service_recovery["id"] + self._message_route_owner = "managed" elif hasattr(self.client, "description"): self._turn_origin_id = self.client.description.get("origin_id") if hasattr(self.client, "description"): @@ -967,6 +987,7 @@ def cached_recent_context_usage(self) -> dict | None: def _activate_pending_turn_route(self) -> None: """Bind post-Result background frames to the submitted browser turn.""" + self._managed_input_seen = True release = self._pending_turn_background_release if release is None: return @@ -1049,6 +1070,7 @@ async def query(self, prompt) -> None: ) self.next_turn_id = None self._turn_active = True + self._managed_input_seen = False try: await client.query(prompt) except BaseException: @@ -1490,6 +1512,7 @@ def _start_message_pump(self) -> None: self._turn_messages = asyncio.Queue(maxsize=cap) self._background_messages = asyncio.Queue(maxsize=cap) self._turn_active = False + self._managed_input_seen = False self._turn_consumer_active = False self._steers = PendingSteers() self._turn_root_id = None @@ -1541,7 +1564,11 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: if parse_raw and isinstance(data, dict) else None ) if parse_raw and isinstance(data, dict): - data = self._steers.annotate(data) + managed_active = self._turn_active and ( + self._managed_input_seen + or (self._pending_compact and self._message_route_owner != "background" + and is_managed_input(data, pending_compact=True))) + data = self._steers.annotate(data, managed_active=managed_active) 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 @@ -1557,6 +1584,10 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: message._cc_steer = steer if parse_raw and data.get("__cc_steer_cancelled"): message._cc_steer_cancelled = data["__cc_steer_cancelled"] + if parse_raw: + message._cc_background_start = data.get("__cc_background_start") + message._cc_background_end = data.get("__cc_background_end") + message._cc_background_ends = background_end_ids(data) self._observe_recent_context_usage(message) self._observe_context_boundary(message) self._observe_model_fallback(message) @@ -1576,7 +1607,8 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: self._steers.handoff_background(self._autonomous_steer_root.get("background_id")) if self.steer_adoption_callback: self.steer_adoption_callback({**steer, "background_origin": - self._autonomous_steer_root.get("background_origin")}) + self._autonomous_steer_root.get("background_origin"), + "background_id": self._autonomous_steer_root.get("background_id")}) if (getattr(message, "_cc_steer_cancelled", None) and not self._steers.pending and not self._turn_active): self._autonomous_steer_root = None @@ -1584,16 +1616,35 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: top_level_user = bool( isinstance(message, UserMessage) and not message.parent_tool_use_id + and not any(isinstance(block, (ToolResultBlock, ServerToolResultBlock)) + for block in (message.content if isinstance(message.content, list) else [])) ) if service_seed: owner = "background" + elif (getattr(message, "_cc_background_start", None) + and not message._cc_background_start["managed"]): + owner = "background" + self._message_route_owner = owner elif getattr(message, "_cc_steer_cancelled", None) and self._turn_active: owner = "managed" self._activate_pending_turn_route() self._message_route_owner = owner elif top_level_user: if origin_kind is not None and origin_kind != "human": - owner = "background" + # Code projects an absorbed queued_command attachment + # as a replayed UserMessage (uuid = source_uuid). Its + # source is non-human, but it does not start another + # response. Keep the current translator, message order + # and final-message identity through the real Result. + # Before the human echo, the same frame can belong to + # an older autonomous response already in the queue. + absorbed = bool( + origin_kind == "task-notification" + and parse_raw and data.get("isReplay") is True + and self._turn_active and self._managed_input_seen + and self._message_route_owner != "background" + ) + owner = "managed" if absorbed else "background" elif self._turn_active: owner = "managed" self._activate_pending_turn_route() @@ -1630,6 +1681,12 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: # human/legacy-unattributed result on the same stream. if origin_kind is not None and origin_kind != "human": owner = "background" + elif (getattr(message, "_cc_background_ends", ()) + and not self._managed_input_seen): + # An unannounced response started before this query's + # human echo. Its unattributed terminal cannot consume + # the newly accepted browser input. + owner = "background" elif self._turn_active: owner = "managed" self._activate_pending_turn_route() @@ -1658,40 +1715,52 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: ) if owner == "managed": + if isinstance(message, ResultMessage) and getattr(message, "_cc_background_ends", ()): + # The human Result may also settle task inputs that + # arrived during this response. Queue only their + # lifecycle boundary behind those callbacks; waiting + # here or projecting the Result twice breaks drain. + release = asyncio.Event() + release.set() + await self._queue_background_message( + ClaudeBackgroundBoundary(message._cc_background_ends, + durable=service_seq is not None), + release, self._turn_origin_id) if isinstance(message, ResultMessage) and ( intermediate or self._steers.pending): # The original response ended just before an accepted - # input was consumed. Keep the same sole consumer. + # input was consumed. Keep the same sole consumer, + # but another task replay is not absorbed by that + # ended response while the next human echo is pending. + self._managed_input_seen = False continue if steer is not None: self._turn_origin_id = steer["id"] await self._turn_messages.put(message) if isinstance(message, ResultMessage): self._turn_active = False + self._managed_input_seen = False self._pending_compact = False self._message_route_owner = None continue release = self._turn_background_release - if release is None: + # In-turn injections must drain before the pending human + # Result: waiting on its barrier fills the bounded queue + # and blocks the sole reader from ever reaching that Result. + # Only post-Result callbacks wait for managed projection. + if release is None or self._turn_active: release = asyncio.Event() release.set() - self._background_callbacks_pending += 1 - self._background_callbacks_drained.clear() - try: - background_owner = service_origin if service_seed else self._turn_origin_id - if not service_seed and self._autonomous_steer_root: - from cc_remote.claude_steering import _origin_key - - old_origin = self._autonomous_steer_root.get("background_origin") - origin = getattr(message, "origin", None) - if (isinstance(origin, dict) and isinstance(old_origin, dict) - and _origin_key(origin) == _origin_key(old_origin)): - background_owner = self._autonomous_steer_root.get("previous_origin_id") - await self._background_messages.put( - (message, release, background_owner)) - except BaseException: - self._background_callback_completed() - raise + background_owner = service_origin if service_seed else self._turn_origin_id + if not service_seed and self._autonomous_steer_root: + from cc_remote.claude_steering import _origin_key + + old_origin = self._autonomous_steer_root.get("background_origin") + origin = getattr(message, "origin", None) + if (isinstance(origin, dict) and isinstance(old_origin, dict) + and _origin_key(origin) == _origin_key(old_origin)): + background_owner = self._autonomous_steer_root.get("previous_origin_id") + await self._queue_background_message(message, release, background_owner) if isinstance(message, ResultMessage): self._message_route_owner = None except asyncio.CancelledError: @@ -1736,6 +1805,15 @@ async def _notify_message_pump_failure( error_type=type(exc).__name__, ) + async def _queue_background_message(self, message, release, turn_id) -> None: + self._background_callbacks_pending += 1 + self._background_callbacks_drained.clear() + try: + await self._background_messages.put((message, release, turn_id)) + except BaseException: + self._background_callback_completed() + raise + def _background_callback_completed(self) -> None: if self._background_callbacks_pending > 0: self._background_callbacks_pending -= 1 @@ -1764,7 +1842,8 @@ async def _background_message_worker(self) -> None: "Claude background message callback failed", error_type=type(exc).__name__) if (getattr(message, "_cc_service_seq", None) is not None - or getattr(message, "_cc_service_seed", False)): + or getattr(message, "_cc_service_seed", False) + or isinstance(message, ClaudeBackgroundBoundary) and message.durable): # ACK is cumulative. Keep reading (the native task is # healthy), but do not project/ack past this hole. A fresh # controller reconstructs state from the retained journal. @@ -1816,6 +1895,14 @@ def receive_response(self): def start_service_events(self) -> None: """Release native replay only after the machine installed its routing.""" + recovery = self.service_recovery + if self._turn_active and recovery is not None: + prompt = recovery.get("prompt") + # Older internal compactions retained the preceding prompt. Their + # reserved turn identity remains the command's recovery authority. + self._pending_compact = bool( + recovery["id"].startswith("compact-") + or (isinstance(prompt, str) and prompt.split(maxsplit=1)[:1] == ["/compact"])) ready = getattr(self.client, "ready", None) if ready is not None: ready.set() @@ -1889,6 +1976,7 @@ async def _stop_message_pump(self) -> None: self._turn_messages = None self._background_messages = None self._turn_active = False + self._managed_input_seen = False self._turn_consumer_active = False self._message_route_owner = None self._autonomous_steer_root = None diff --git a/cc_remote/wrapper/stream.py b/cc_remote/wrapper/stream.py index 9eeba33b..a15e7477 100644 --- a/cc_remote/wrapper/stream.py +++ b/cc_remote/wrapper/stream.py @@ -1814,7 +1814,41 @@ class CompactTranscriptPage: oldest_cursor: str | None +def _queued_prompt_row(row: dict[str, Any]) -> dict[str, Any] | None: + """Project an accepted native human attachment using its SDK echo identity. + + Mid-response inputs are persisted as queued_command attachments, while the + SDK's catalog drops attachments entirely. Queue-operation records alone do + not prove consumption, and task notifications must never become humans. + Keep the attachment UUID in the ancestry graph; source_uuid owns the public + prompt, its live alias and its pagination cursor. + """ + attachment = row.get("attachment") + if (row.get("type") != "attachment" or row.get("isSidechain") is True + or row.get("isMeta") or not isinstance(attachment, dict) + or attachment.get("type") != "queued_command" + or attachment.get("commandMode") != "prompt"): + return None + origin = attachment.get("origin") + if origin is not None and not ( + isinstance(origin, dict) and origin.get("kind") == "human" + ): + return None + uid = attachment.get("source_uuid") + prompt = attachment.get("prompt") + if (not isinstance(uid, str) or not _SAFE_WIRE_ID.fullmatch(uid) + or not isinstance(prompt, (str, list))): + return None + return {**row, "type": "user", "uuid": uid, + "message": {"role": "user", "content": prompt}} + + def _compact_visible_user(row: dict[str, Any]) -> bool: + if row.get("type") == "attachment": + projected = _queued_prompt_row(row) + if projected is None: + return False + row = projected origin = row.get("origin") if ( row.get("type") != "user" @@ -1851,6 +1885,14 @@ def _compact_visible_user(row: dict[str, Any]) -> bool: ) +def _compact_user_cursor(row: dict[str, Any]) -> str | None: + if not _compact_visible_user(row): + return None + if row.get("type") == "attachment": + return row["attachment"]["source_uuid"] + return row.get("uuid") + + def _transcript_graph_index( source_path: str, *, @@ -1874,7 +1916,7 @@ def _transcript_graph_index( snapshot_size=snapshot_size, max_record_bytes=max_record_bytes, max_entries=_MAX_TRANSCRIPT_CHAIN_ENTRIES, - visible_user=_compact_visible_user, + visible_user=_compact_user_cursor, ) except Exception: indexed = None @@ -1930,6 +1972,7 @@ def _transcript_graph_index( if len(rows) >= _MAX_TRANSCRIPT_CHAIN_ENTRIES \ and uid not in rows: return None + public_uid = _compact_user_cursor(row) rows[uid] = ( row.get("type"), row.get("subtype"), @@ -1937,8 +1980,9 @@ def _transcript_graph_index( row.get("logicalParentUuid"), row.get("isSidechain"), offset, - _compact_visible_user(row), + bool(public_uid), len(line), + public_uid, ) if row.get("isSidechain") is not True: leaf = uid @@ -2385,8 +2429,9 @@ def recover_claude_native_metadata( index_store=None, snapshot_size: int | None = None, max_record_bytes: int = _MAX_TRANSCRIPT_RECORD_CHARS, + include_queued_prompts: bool = True, ) -> list: - """Restore model notes and native tool results on the active ancestry only. + """Restore queued human inputs, model notes and results on active ancestry. The SDK catalog omits these rows. These positional shells are solely a UI projection, never a prompt sent back to Claude. Exact native tool results @@ -2407,12 +2452,35 @@ def recover_claude_native_metadata( visible = {getattr(message, "uuid", None) for message in messages} by_id = {getattr(message, "uuid", None): message for message in messages} insertions: dict[str, list[SimpleNamespace]] = {} + restored_prompts: set[str] = set() anchor = None restored = 0 try: with open(path, "rb") as source: for uid in chain: metadata = rows[uid] + if metadata[0] == "attachment" and metadata[6]: + public_uid = metadata[8] + if public_uid in visible: + anchor = public_uid + elif (include_queued_prompts and anchor is not None + and public_uid not in restored_prompts): + row = _indexed_transcript_row( + source, uid, metadata, max_record_bytes=max_record_bytes) + projected = _queued_prompt_row(row) if row else None + if projected is not None: + insertions.setdefault(anchor, []).append(SimpleNamespace( + type="user", uuid=public_uid, session_id=session_id, + message=projected["message"], is_queued_prompt=True, + parent_tool_use_id=None, + )) + restored_prompts.add(public_uid) + stamp = _transcript_epoch(projected) + if stamp is not None: + timestamps[public_uid] = stamp + else: + # A human outside this selected page also bounds notes. + anchor = None if metadata[0] in {"user", "assistant"} and uid not in visible: anchor = None if uid in visible: @@ -2484,6 +2552,10 @@ def _load_compact_chain_messages( return None if row.get("uuid") != uid: return None + projected = _queued_prompt_row(row) + if projected is not None: + row = projected + uid = projected["uuid"] internal = _internal_user_event_from_row(row, queued) if internal is None: internal = _compaction_event_from_row(row) @@ -2501,6 +2573,7 @@ def _load_compact_chain_messages( session_id=session_id, message=message, is_meta=bool(row.get("isMeta")), + is_queued_prompt=projected is not None, parent_tool_use_id=( row.get("parentToolUseID") or row.get("parent_tool_use_id") @@ -2673,7 +2746,7 @@ def transcript_compact_history_page( return None ordered_ids, rows, queued = indexed visible = [ - (index, uid) + (index, rows[uid][8]) for index, uid in enumerate(ordered_ids) if bool(rows[uid][6]) ] @@ -2696,7 +2769,8 @@ def transcript_compact_history_page( for uid in ordered_ids: metadata = rows[uid] payload_prefix.append(payload_prefix[-1] + ( - int(metadata[7]) if metadata[0] in {"user", "assistant"} else 0 + int(metadata[7]) + if metadata[0] in {"user", "assistant"} or metadata[6] else 0 )) while True: chain_start = 0 if start == 0 else visible[start][0] @@ -3029,7 +3103,7 @@ def close_turn( # treat such text as commentary, but history has the next user/EOF as # an authoritative turn boundary. Promote only the final top-level # ambiguous text row that was not followed by any tool activity. - if (ambiguous_final_mid is not None + if (subtype != "steered" and ambiguous_final_mid is not None and ambiguous_final_start is not None): for event_index in range(ambiguous_final_start, len(events)): event = events[event_index] @@ -3094,6 +3168,17 @@ def close_turn( # human turn. Match the SDK's isMeta filter even when reading # raw compact ancestry, without guessing from prompt text. continue + if getattr(m, "is_queued_prompt", False) and _compact_visible_user({ + "type": "user", "message": msg, + }): + # A consumed human input starts a new visible segment, exactly + # like the live TurnSteered echo. It is not proof that the + # preceding physical response finished or its text was final. + if not settled_answer_seen: + stamp = _ts(source_uid) + if stamp is not None: + last_ts = max(last_ts or stamp, stamp) + close_turn("steered", False) compact_prompt = manual_compact_prompt(content) if compact_prompt is not None: content = compact_prompt @@ -3162,6 +3247,13 @@ def close_turn( if img: imgs.append(img) made = False + text_made = False + prompt_text = "\n".join( + b["text"] for b in content + if isinstance(b, dict) and b.get("type") == "text" + and isinstance(b.get("text"), str) and b["text"] + and not _is_meta_user_text(b["text"]) + ) for block_index, b in enumerate(content): if not isinstance(b, dict): continue @@ -3224,10 +3316,11 @@ def close_turn( )) elif bt == "text": txt = b.get("text", "") - if txt and not _is_meta_user_text(txt): + if txt and not _is_meta_user_text(txt) and not text_made: close_turn() turn_start_ts = _ts(source_uid) - um = _um(message_uid, txt) + um = _um(message_uid, prompt_text) + text_made = True if imgs and not made: um.images = imgs made = True diff --git a/deploy/README.md b/deploy/README.md index bd77fb40..8b77b15d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -160,11 +160,11 @@ deployment. migration transaction, restores matching pre-release data before an older wrapper is restarted, and verifies both engines' Work ownership backfills. -Protocol v71 is a coordinated upgrade: publish freshly built Relay/Web and +Protocol v72 is a coordinated upgrade: publish freshly built Relay/Web and Wrapper artifacts from the same tagged commit. The strict protocol gate is intentional and mixed protocol versions will not communicate. `setup-vps.sh` rejects a missing or mismatched web build manifest. Stop the wrapper first; -activate the v71 relay/web release; then start the v71 wrapper. +activate the v72 relay/web release; then start the v72 wrapper. The wrapper installer treats local Work data and versioned private control state as part of the release @@ -177,8 +177,8 @@ the previous code. If data restoration fails, it leaves the wrapper stopped instead of running old code against a new schema. A manual or legacy-layout deployment must use the same order: stop the wrapper, run `work_registry_snapshot.py snapshot` from the new staging tree, activate and -verify v71, and retain that snapshot with the previous release. To roll back, -stop v71, run `work_registry_snapshot.py restore`, then switch and start the old +verify v72, and retain that snapshot with the previous release. To roll back, +stop v72, run `work_registry_snapshot.py restore`, then switch and start the old release. Never copy only `registry.sqlite3` while the wrapper is live because committed state may still be in its WAL file. Restoring a pre-release snapshot also restores pre-release Work metadata: sessions, projects, or schedule state diff --git a/deploy/install.sh b/deploy/install.sh index 65b601f2..aa726e04 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -2,7 +2,7 @@ # Download, verify, and run a role-specific cc-remote release installer. set -euo pipefail -VERSION="${CC_REMOTE_VERSION:-3.0.0}" +VERSION="${CC_REMOTE_VERSION:-4.0.0}" REPOSITORY="${CC_REMOTE_GITHUB_REPOSITORY:-muggle-stack/cc-remote}" BASE_URL="${CC_REMOTE_RELEASE_BASE_URL:-https://github.com/$REPOSITORY/releases/download/v$VERSION}" @@ -12,7 +12,7 @@ die() { } [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ - die "CC_REMOTE_VERSION must be an exact semantic version such as 3.0.0" + die "CC_REMOTE_VERSION must be an exact semantic version such as 4.0.0" usage() { cat >&2 <<'EOF' diff --git a/docs/claude-session-service.md b/docs/claude-session-service.md index ee4a33cf..fbb19f68 100644 --- a/docs/claude-session-service.md +++ b/docs/claude-session-service.md @@ -56,6 +56,34 @@ the first queued input is cancelled. Older service owners reject this operation before writing to the native stream. Upgrade them using the drain procedure below; a Wrapper-only upgrade does not add this capability to an existing owner. +Claude Code can omit replay of internal task-notification prompts. Owners with +`background_activity_steering` also recognize unsolicited top-level request or +assistant events, retain that continuation from its first event, and permit +native steering until its Result or exact input handoff. Task notifications and +child-agent output alone never claim the main session's running state. Deploy +this service change using the same drain procedure; existing owners keep their +original implementation until their native work has safely finished. + +Several injected task inputs can share one native response and one unattributed +Result. The service journals which continuations that physical terminal closes; +the controller retires them in callback order, including inputs consumed during +a human response. A delayed callback must not revive a completed response, and +replay of its terminal must not close a later continuation. Running child tasks +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. + +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 +and the attachment's `source_uuid`. After the human input has been consumed, +these task inputs stay on the same managed reader and translator until the real +Result. They do not move the final answer into a separate background segment or +change its native checkpoint/fork identity. Buffered notifications and tool +results before that human echo retain their earlier route. A display segment +with only a final answer has no process disclosure; whole-turn deferred counts +cannot invent a process item for it. + This is a cc-remote SDK service, not Claude Code's terminal background mode or the experimental PTY broker. The daily native Claude TUI keeps its existing external-ownership rules; this service does not give it shared input ownership. diff --git a/docs/installation.md b/docs/installation.md index 1970cdc9..b19e50db 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -28,11 +28,11 @@ Web,Wrapper 包只含本机控制端;两者都自带 `uv`,安装时创建 ### 1)下载并校验引导脚本 在 GitHub Release 页面确认版本与 release attestation,再在待安装机器下载同一版本的 -`install.sh` 和 `SHA256SUMS`。下例使用 `3.0.0`;请替换为已选定的已发布 tag +`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.0`;请先确认对应版本已发布,或替换为已选定的已发布 tag (变量中不带开头的 `v`)。该路径不会自动安装尚未发布的开发分支: ```bash -export CC_REMOTE_VERSION=3.0.0 +export CC_REMOTE_VERSION=4.0.0 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" @@ -166,7 +166,7 @@ npm --prefix web run build # 产出 web/dist/ 网页构建不需要任何登录密钥。 **所有目标先 staging,再改动线上服务。** 下文分别描述 Relay 和 Wrapper, -不能在 Wrapper staging 未验证时先激活 Relay。协议 v71 不允许混用旧客户端: +不能在 Wrapper staging 未验证时先激活 Relay。协议 v72 不允许混用旧客户端: 停止不兼容的旧 Wrapper,激活 Relay + Web,再激活 Wrapper 并硬刷新网页。 Wrapper 激活须通过 `deploy/work_registry_snapshot.py` 保存 Work SQLite 与私有账号 控制状态,不再按“是否来自某个旧协议”决定是否保护。回滚先恢复匹配状态,再启动 @@ -224,7 +224,7 @@ sudo bash ~/cc-remote-upload/deploy/setup-vps.sh \ 脚本会:装 `python3-venv` + Caddy、建 `ccremote` 系统用户、创建不可变 release 和 release-local venv、合并 Caddy 配置、原子切换 `current`,再重启 relay。若新 relay 重启或健康检查失败,`current`、Caddyfile、systemd unit 会作为一个事务全部 -恢复,并验证旧 release 的 `/healthz`。成功后再启动 v71 wrapper。 +恢复,并验证旧 release 的 `/healthz`。成功后再启动 v72 wrapper。 验证: diff --git a/docs/installation_en.md b/docs/installation_en.md index e433703e..bab06086 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -30,12 +30,13 @@ repository, install Node, or paste tokens into service definitions. ### 1) Download and verify the bootstrap Confirm the version and release attestation on GitHub, then download -`install.sh` and `SHA256SUMS` from that same release. The example uses `3.0.0`; -replace it with the published tag you selected (without the leading `v`). This +`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.0`; +first confirm that it is published, or replace it with the published tag you +selected (without the leading `v`). This does not select an unpublished development-branch build: ```bash -export CC_REMOTE_VERSION=3.0.0 +export CC_REMOTE_VERSION=4.0.0 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" @@ -188,7 +189,7 @@ as described in the deployment contract. No browser secret is needed for a build **Stage every target before changing live services.** The commands below describe the Relay and Wrapper separately; do not activate Relay until every Wrapper stage -has passed validation. Protocol v71 cannot be mixed with older clients. Stop old +has passed validation. Protocol v72 cannot be mixed with older clients. Stop old incompatible Wrappers, activate Relay + Web, then activate Wrappers and hard-refresh browser tabs. Wrapper activation must snapshot Work SQLite and private profile control state with `deploy/work_registry_snapshot.py`; this is not limited to @@ -250,7 +251,7 @@ The script installs `python3-venv` + Caddy, creates the `ccremote` service user, builds an immutable release and its venv, merges Caddy configuration, atomically switches `current`, and restarts the relay. If restart/readiness fails, `current`, the Caddyfile, and the systemd unit roll back as one transaction and the previous -release's `/healthz` is verified. Start the v71 wrapper after success. +release's `/healthz` is verified. Start the v72 wrapper after success. Verify: diff --git a/docs/releases/v4.0.0.md b/docs/releases/v4.0.0.md new file mode 100644 index 00000000..69e36672 --- /dev/null +++ b/docs/releases/v4.0.0.md @@ -0,0 +1,98 @@ +# cc-remote v4.0.0 + +## 中文 + +v4.0.0 集中完善 Claude Code 与 Codex 的会话连续性、历史、侧聊、文件预览和移动端 +体验,并新增终端工作台预览版。产品版本为 **4.0.0**,通信协议为 **v72**。 + +### 功能与修复 + +- **Claude 会话连续性**:可选独立会话服务保留原生任务,Wrapper 重连恢复已接受的 + 消息和待回答问题;支持运行中原生引导、思考摘要和后台任务续跑。 +- **后台任务与历史**:主回复结束后仍运行的子任务不会阻塞下一条普通消息;子任务 + 回归时按原生边界合并内容。修复持续 running、空的“已处理”、排队提示词遗漏、 + 手动压缩重复,以及大图片导致会话工作目录无法识别的问题。 +- **上下文与用量**:会话级上下文控制、压缩状态与最近请求用量;处理指示旁显示 + 原生输入/输出 token,点按可查看完整数字与缓存用量。 +- **历史与差异**:分页摘要和按需过程详情保留阅读位置;每轮文件差异独立归档, + 标明证据缺失或截断,后续工作区修改不会改写旧轮差异。 +- **侧聊与预览**:可恢复的多侧聊、附件和独立控制;会话文件浏览与静态页面预览, + 以及音频、PDF、表格、Markdown 和 Mermaid 等内容预览。 +- **阅读与手机体验**:低饱和主题、整体字体加粗、紧凑后台任务入口;修复 iOS + 流式输出抢滚动、键盘打开时抖动,以及键盘提示粘贴导致文字或附件重复的问题。 +- **终端工作台(预览)**:通过相同 Relay/Wrapper 使用 Claude/Codex 的 Code/Work, + 提供会话树、标签、Vim 编辑、排队、工具详情和文件预览;需按终端指南从源码安装 + 可选 TUI 依赖。 + +本次发布范围为 Claude/Codex。DSH、Electron 客户端和托管浏览器/MCP computer use +实验功能保留在各自开发分支。静态文件/页面预览仍包含在本次发布中。 + +### 从 v3 升级 + +- v3.0.0 使用协议 v20,本版使用 v72。**Relay、Wrapper、Web 与 TUI 必须协调升级**, + 不能混用旧客户端;升级后重新加载浏览器/PWA。 +- 按部署文档先完成 staging 和校验,保留外部配置、设备授权、账号及会话私有状态; + 使用不可变发布与配套状态快照进行激活和回滚。 +- Claude 使用已登录的日常 CLI,要求 **Claude Code ≥ 2.1.263**;已验证的 + **Claude Agent SDK 固定为 0.2.151**。独立 Claude 服务有自己的生命周期, + 不能通过普通 Wrapper 重启升级仍在运行的原生任务;服务升级须按文档等待排空。 +- Codex Code 继续使用官方 app-server。原生 CLI 与 Wrapper 应连接同账号 daemon; + Codex App 共享接入仍是单独可选配置。 + +[安装与升级](https://github.com/muggle-stack/cc-remote/blob/v4.0.0/docs/installation.md) +· [完整更新记录](https://github.com/muggle-stack/cc-remote/blob/v4.0.0/CHANGELOG_zh.md) +· [Claude 会话服务](https://github.com/muggle-stack/cc-remote/blob/v4.0.0/docs/claude-session-service.md) + +## English + +v4.0.0 improves session continuity, history, side chats, file previews and mobile +interaction for Claude Code and Codex, and adds a terminal workspace preview. +The product version is **4.0.0** and the wire protocol is **v72**. + +### Highlights + +- **Claude continuity:** an optional independent session service preserves native + work across Wrapper reconnects, recovering accepted inputs and pending + questions. Native steering, thinking summaries and background continuations + retain their original message ownership. +- **Background work and history:** running children no longer block the next + normal prompt after the main response ends. Native task returns keep the + correct timeline. Fix stuck running states, empty process disclosures, omitted + queued prompts, duplicate manual compaction and cwd recovery after large images. +- **Context and usage:** per-session context controls, compaction state, recent + request usage and native input/output token counters, with exact counts and + cache usage available on click or tap. +- **History and diffs:** paged summaries and on-demand details preserve reading + position. Archived per-turn file changes retain their original evidence and + explicitly identify missing or truncated patches. +- **Side chats and previews:** recoverable multiple side chats, attachments and + independent controls; session file browsing and static page previews, including + audio, PDF, spreadsheets, Markdown and Mermaid. +- **Readability and mobile:** muted themes, a global bold-text preference and + compact background task controls. Live output yields to user scrolling and + keyboard changes; duplicate iOS keyboard paste deliveries are suppressed. +- **Terminal workspace preview:** session trees, tabs, Vim editing, queue controls, + tool details and file previews for Claude/Codex Code/Work over the same relay. + Follow the terminal guide to install the optional TUI dependencies from source. + +This release contains Claude/Codex. DSH, Electron and managed-browser/MCP computer +use experiments remain on separate development branches. Static file and page +previews are included. + +### Upgrade from v3 + +- v3.0.0 used protocol v20; this release uses v72. **Upgrade Relay, Wrapper, Web + and TUI together**, then reload browser/PWA clients. Mixed versions are rejected. +- Stage and validate before activation. Preserve external configuration, + device authority and private account/session state; use immutable releases + with matching state snapshots for activation and rollback. +- Claude requires the signed-in daily **Claude Code CLI ≥ 2.1.263**, with the + verified **Claude Agent SDK pinned to 0.2.151**. The independent service must + drain before its own upgrade; an ordinary Wrapper restart does not upgrade + active native sessions. +- Codex Code uses the official app-server. Daily CLI and Wrapper connections + should share the same account's daemon. Codex App attachment remains optional. + +[Installation and upgrades](https://github.com/muggle-stack/cc-remote/blob/v4.0.0/docs/installation_en.md) +· [Full changelog](https://github.com/muggle-stack/cc-remote/blob/v4.0.0/CHANGELOG.md) +· [Claude session service](https://github.com/muggle-stack/cc-remote/blob/v4.0.0/docs/claude-session-service.md) diff --git a/tests/test_claude_autocompact.py b/tests/test_claude_autocompact.py index 5eed34bc..fce0bcf4 100644 --- a/tests/test_claude_autocompact.py +++ b/tests/test_claude_autocompact.py @@ -1425,11 +1425,15 @@ async def run(): "origin-turn", ) assert ctx.claude_active_tasks == set() + assert ctx.claude_background_followup_pending is False + await machine._on_claude_background_message( + ctx, UserMessage(content="background result", origin={"kind": "task-notification"}), + "origin-turn") assert ctx.claude_background_followup_pending is True assert sdk.reconnects == [] # Even an explicit idle control change cannot cut off the autonomous - # response started by the task notification. + # response after its actual native start. held = await machine._handle_set_auto_compact(SetAutoCompact( sid=SESSION_ID, mode="custom", @@ -1989,6 +1993,11 @@ async def run(): "origin-turn", ) + assert ctx.claude_background_followup_pending is False + assert ctx.state == "idle" + await machine._on_claude_background_message( + ctx, UserMessage(content="background result", origin={"kind": "task-notification"}), + "origin-turn") assert ctx.claude_background_followup_pending is True assert ctx.state == "running" @@ -2011,7 +2020,7 @@ async def run(): asyncio.run(run()) -def test_each_completed_background_task_claims_its_own_followup(): +def test_batched_task_notifications_do_not_invent_extra_native_turns(): async def run(): machine, _transport, ctx = _machine_with_sdk(_AutoCompactSdk()) ctx.claude_active_tasks.update({"task-1", "task-2"}) @@ -2029,7 +2038,7 @@ def notification(task_id: str) -> TaskNotificationMessage: tool_use_id=f"{task_id}-tool", ) - def result(task_id: str) -> ResultMessage: + def result() -> ResultMessage: return ResultMessage( subtype="success", duration_ms=1, @@ -2037,29 +2046,29 @@ def result(task_id: str) -> ResultMessage: is_error=False, num_turns=1, session_id=SESSION_ID, - origin={"kind": "task-notification", "taskId": task_id}, + origin={"kind": "task-notification"}, ) await machine._on_claude_background_message( ctx, notification("task-1"), "origin-turn") assert ctx.claude_active_tasks == {"task-2"} - assert ctx.claude_background_followup_pending is True - assert ctx.state == "running" + assert ctx.claude_background_followup_pending is False + assert ctx.state == "idle" - # Both completions may be delivered before the first autonomous turn - # reaches its Result. The first terminal must retire only task-1. + # Multiple task completions can be consumed by a single native turn. await machine._on_claude_background_message( ctx, notification("task-2"), "origin-turn") assert ctx.claude_active_tasks == set() - assert len(ctx.claude_background_followups) == 2 + assert ctx.claude_background_followups == {} await machine._on_claude_background_message( - ctx, result("task-1"), "task-1-followup") + ctx, UserMessage(content="both results", origin={"kind": "task-notification"}), + "origin-turn") assert ctx.claude_background_followup_pending is True assert ctx.state == "running" await machine._on_claude_background_message( - ctx, result("task-2"), "task-2-followup") + ctx, result(), "origin-turn") assert ctx.claude_background_followup_pending is False assert ctx.state == "idle" @@ -2438,15 +2447,11 @@ async def run(): monkeypatch.setattr(type(machine), "CLAUDE_ACTIVE_TASK_CAP", 1) ctx.state = "running" - machine._observe_claude_task_lifecycle(ctx, TaskNotificationMessage( - subtype="task_notification", data={}, task_id="task-1", - status="completed", output_file="", summary="one", - uuid="u1", session_id=SESSION_ID, tool_use_id=None, + machine._observe_claude_task_lifecycle(ctx, UserMessage( + content="one", uuid="u1", origin={"kind": "task-notification", "taskId": "task-1"}, ), background=True) - machine._observe_claude_task_lifecycle(ctx, TaskNotificationMessage( - subtype="task_notification", data={}, task_id="task-2", - status="completed", output_file="", summary="two", - uuid="u2", session_id=SESSION_ID, tool_use_id=None, + machine._observe_claude_task_lifecycle(ctx, UserMessage( + content="two", uuid="u2", origin={"kind": "task-notification", "taskId": "task-2"}, ), background=True) recovery = ctx.claude_followup_recovery_task diff --git a/tests/test_claude_background_completion.py b/tests/test_claude_background_completion.py new file mode 100644 index 00000000..5b56b639 --- /dev/null +++ b/tests/test_claude_background_completion.py @@ -0,0 +1,229 @@ +"""Native task inputs can share a terminal without blocking the next prompt.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from cc_remote.config import WrapperConfig +from cc_remote.wrapper.sdk import ClaudeBackgroundBoundary, ClaudeServiceReplayRequired, SdkHandle +from tests.test_claude_autocompact import _machine_with_sdk +from tests.test_claude_service import environment, released +from tests.test_claude_steering import NativeClient, assistant, result, until, user + + +def task_input(uid, origin): + return {**user(uid, "background task finished"), "origin": origin} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("persistent", [False, True]) +@pytest.mark.parametrize("managed", [False, True]) +@pytest.mark.parametrize("distinct_tasks", [False, True]) +@pytest.mark.parametrize("slow_callback", [False, True]) +async def test_batched_task_inputs_settle_and_background_job_does_not_block_next_prompt( + persistent, managed, distinct_tasks, slow_callback, +): + 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()) + sdk.applied_auto_compact_mode = sdk.auto_compact_mode + sdk.applied_auto_compact_threshold_tokens = sdk.auto_compact_threshold_tokens + sdk.applied_effort = sdk.effort + sdk.force_reconnect = AsyncMock(side_effect=AssertionError("must preserve the native process")) + sdk.connect = AsyncMock(side_effect=AssertionError("must use the fake native client")) + sdk.refresh_goal = AsyncMock(return_value=None) + machine, transport, ctx = _machine_with_sdk(sdk) + machine._configure_claude_sdk_callbacks(ctx, sdk) + project = sdk.background_message_callback + callback_entered, release_callback = asyncio.Event(), asyncio.Event() + + async def project_in_order(message, turn_id): + if slow_callback and getattr(message, "uuid", None) == "notification-1": + callback_entered.set() + await release_callback.wait() + await project(message, turn_id) + + sdk.background_message_callback = project_in_order + sdk.client = client + sdk._start_message_pump() + runner = None + try: + if managed: + ctx.state = "running" + ctx.active_msg_id = "human" + runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "inspect")) + inputs = native.prompts if persistent else native.inputs + await until(lambda: len(inputs) == 1) + await native.queue.put(user("native-human", "inspect")) + + for index in range(2): + origin = {"kind": "task-notification"} + if distinct_tasks: + origin["taskId"] = f"task-{index}" + await native.queue.put(task_input(f"notification-{index}", origin)) + await native.queue.put(assistant(f"answer-{index}", [ + {"type": "text", "text": f"checked task {index}"}])) + await until(lambda: bool(ctx.claude_background_followups)) + # A still-running child is independent from its parent's terminal. + await native.queue.put({ + "type": "system", "subtype": "task_started", "task_id": "still-running", + "tool_use_id": "child-tool", "task_type": "local_bash", + "description": "background check", "uuid": "child-start", + "session_id": ctx.session_id, + }) + # Code 2.1.276 omits origin on this real shared terminal. + await native.queue.put(result()) + if slow_callback: + await asyncio.wait_for(callback_entered.wait(), 3) + await until(lambda: sdk._steers.background_id is None) + # The terminal has arrived, but earlier injected input has not + # been projected yet. It must not revive a completed response. + release_callback.set() + if runner: + await asyncio.wait_for(runner, 3) + await until(lambda: ctx.state == "idle") + await until(lambda: sdk._background_callbacks_pending == 0) + assert ctx.state == "idle" + assert ctx.claude_active_tasks == {"still-running"} + assert not ctx.claude_background_followups + assert not sdk._steers.background_id + assert sum(e.type == "turn_end" for e in transport.sent) == int(managed) + if worker: + assert worker.turn is None and worker.background_start is None + assert not worker.client.closed + + # No interrupt, takeover or wait for the background child is needed. + ctx.state = "running" + ctx.active_msg_id = "next-human" + runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "next question")) + await until(lambda: len(native.prompts if persistent else native.inputs) == int(managed) + 1) + inputs = native.prompts if persistent else native.inputs + assert inputs[-1] == "next question" + await native.queue.put(user("native-next", "next question")) + await native.queue.put(assistant("next-answer", [{"type": "text", "text": "next answer"}])) + await native.queue.put(result()) + await asyncio.wait_for(runner, 3) + await until(lambda: ctx.state == "idle") + assert ctx.claude_active_tasks == {"still-running"} + + # When the child returns, native continuation can own the main + # activity again and then settle without a synthetic human turn. + await native.queue.put({ + "type": "system", "subtype": "task_notification", "task_id": "still-running", + "status": "completed", "output_file": "", "summary": "check passed", + "uuid": "child-finished", "session_id": ctx.session_id, + }) + await native.queue.put(task_input("last-notification", {"kind": "task-notification"})) + await native.queue.put(assistant("last-answer", [{"type": "text", "text": "check passed"}])) + await until(lambda: ctx.state == "running" and bool(ctx.claude_background_followups)) + await native.queue.put(result()) + await until(lambda: ctx.state == "idle" and sdk._background_callbacks_pending == 0) + assert not ctx.claude_active_tasks + assert not ctx.claude_background_followups + assert sum(e.type == "turn_end" for e in transport.sent) == int(managed) + 1 + assert any(e.type == "state" and e.state == "running" and e.continuation for e in transport.sent) + assert native.interrupts == 0 + finally: + release_callback.set() + if runner: + runner.cancel() + await asyncio.gather(runner, return_exceptions=True) + await sdk._stop_message_pump() + if persistent: + await client.detach() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("managed", [False, True]) +@pytest.mark.parametrize("later_continuation", [False, True]) +async def test_shared_terminal_replays_without_resubmission_or_retiring_later_activity( + managed, later_continuation, +): + async with environment() as (service, attach): + first = await attach() + worker = service.sessions[first.id] + native = worker.client + if managed: + first.next_turn = {"id": "human", "prompt": "inspect"} + await first.query("inspect") + await native.queue.put(user("human-echo", "inspect")) + for index in range(2): + await native.queue.put(task_input(f"notification-{index}", { + "kind": "task-notification", "taskId": f"task-{index}", + })) + await until(lambda: worker.journal.seq == 2 + int(managed)) + await first.detach() + await released(worker) + await native.queue.put(result()) + if later_continuation: + await native.queue.put(task_input("later-input", {"kind": "task-notification", "taskId": "later"})) + await native.queue.put(assistant("later-output", [{"type": "text", "text": "later report"}])) + await until(lambda: worker.journal.seq == (5 if later_continuation else 3) + int(managed)) + + sdk = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path)) + sdk.service_defer_events = True + machine, transport, ctx = _machine_with_sdk(sdk) + machine._configure_claude_sdk_callbacks(ctx, sdk) + sdk.service_metadata = worker.metadata.copy() + sdk.refresh_goal = AsyncMock(return_value=None) + try: + # The native process continued offline. Activate replays its journal; + # it must never submit the original prompt again. + await sdk.connect(resume_id="native-session", cwd="/tmp") + from cc_remote.wrapper.claude_service import activate + + await activate(machine, ctx) + await until(lambda: worker.turn is None and ctx.turn_task is None) + if later_continuation: + await until(lambda: ctx.state == "running" and sdk._background_callbacks_pending == 0) + assert len(ctx.claude_background_followups) == 1 + assert worker.background_start is not None + await native.queue.put(result()) + await until(lambda: ctx.state == "idle" and sdk._background_callbacks_pending == 0 + and worker.background_start is None) + assert not ctx.claude_background_followups + assert worker.background_start is None + assert sum(e.type == "turn_end" for e in transport.sent) == int(managed) + assert native.prompts == (["inspect"] if managed else []) + assert native.interrupts == 0 and not native.closed + finally: + if ctx.turn_task: + ctx.turn_task.cancel() + await asyncio.gather(ctx.turn_task, return_exceptions=True) + await sdk.detach_for_shutdown() + + +@pytest.mark.asyncio +async def test_failed_shared_boundary_retains_journal_for_recovery(): + async with environment() as (service, attach): + client = await attach() + worker = service.sessions[client.id] + sdk = SdkHandle(WrapperConfig()) + sdk.client = client + + async def project(message, _turn): + if isinstance(message, ClaudeBackgroundBoundary): + raise ValueError("temporary lifecycle projection failure") + + sdk.background_message_callback = project + sdk._start_message_pump() + try: + sdk.next_turn_id = "human" + await sdk.query("inspect") + await worker.client.queue.put(user("human", "inspect")) + await worker.client.queue.put(task_input("task-input", {"kind": "task-notification"})) + await worker.client.queue.put(result()) + messages = [message async for message in sdk.receive_response()] + await until(lambda: sdk._service_delivery_error is not None) + with pytest.raises(ClaudeServiceReplayRequired): + await sdk.ack_service_message(messages[-1], turn_id="human") + assert worker.turn["id"] == "human" + assert len(worker.journal.after(0)) == 3 + assert not sdk.message_pump_failed + assert not worker.client.closed and worker.client.interrupts == 0 + assert worker.client.prompts == ["inspect"] + finally: + await sdk.detach_for_shutdown() diff --git a/tests/test_claude_catalog_cwd.py b/tests/test_claude_catalog_cwd.py new file mode 100644 index 00000000..fb5bfb77 --- /dev/null +++ b/tests/test_claude_catalog_cwd.py @@ -0,0 +1,184 @@ +"""Large leading image rows must not make valid native sessions look orphaned.""" + +import asyncio +import base64 +import json + +import pytest +from claude_agent_sdk import get_session_info +from claude_agent_sdk._internal.sessions import _sanitize_path +from claude_agent_sdk.types import SDKSessionInfo + +from cc_remote.attachments import ( + MAX_ATTACHMENT_COUNT, + MAX_SINGLE_ATTACHMENT_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + validate_attachments, +) +from cc_remote.protocol import SwitchSession +from cc_remote.wrapper import claude_catalog +from tests.test_attachments import _complete_png +from tests.test_multisession import _mk_machine + + +SID = "11111111-1111-4111-8111-111111111111" +LONG_CWD = "/workspace/" + "/".join(["long-project-component"] * 12) +LONG_PREFIX = ("-workspace-" + "long-project-component-" * 12)[:200] +EMOJI_PREFIX = ("----workspace-" + "long-project-component-" * 12)[:200] + + +def write_image_transcript(root, cwd="/original-project", *, image_sizes=(100_000,), bucket=None, parent_uuid=None): + path = root / "projects" / (bucket or _sanitize_path(cwd)) / f"{SID}.jsonl" + path.parent.mkdir(parents=True) + metadata = b"Comment\x00" + overhead = len(base64.b64decode(_complete_png((b"tEXt", metadata)))) + images = [{ + "media_type": "image/png", + "data": _complete_png((b"tEXt", metadata + b"A" * (size - overhead))), + } for size in image_sizes] + assert [len(base64.b64decode(image["data"])) for image in images] == list(image_sizes) + assert validate_attachments(images, None) is None + content = [{"type": "image", "source": {"type": "base64", **image}} for image in images] + records = [ + {"type": "queue-operation", "operation": "enqueue", "content": content}, + {"type": "user", "uuid": "human", "parentUuid": parent_uuid, "sessionId": SID, + "cwd": cwd, "message": {"role": "user", "content": [*content, {"type": "text", "text": "inspect image"}]}}, + {"type": "assistant", "uuid": "answer", "parentUuid": "human", "sessionId": SID, + "cwd": cwd + "/later-shell-directory", "message": {"role": "assistant", "content": "done"}}, + {"type": "custom-title", "sessionId": SID, "customTitle": "Image review"}, + ] + path.write_text("".join(json.dumps(row) + "\n" for row in records)) + return path + + +@pytest.mark.parametrize("explicit", [False, True]) +@pytest.mark.parametrize("image_sizes", [ + (100_000,), + (3 * 1024 * 1024,), + (MAX_SINGLE_ATTACHMENT_BYTES,), + (MAX_TOTAL_ATTACHMENT_BYTES // 2,) * 2, + (MAX_TOTAL_ATTACHMENT_BYTES // MAX_ATTACHMENT_COUNT,) * MAX_ATTACHMENT_COUNT, +], ids=["sdk-window", "base64-record-boundary", "single-limit", "total-limit", "count-and-total-limit"]) +def test_large_image_session_recovers_original_cwd_and_can_be_selected(tmp_path, monkeypatch, explicit, image_sizes): + root = tmp_path / "selected-profile" + path = write_image_transcript(root, image_sizes=image_sizes) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(root)) + raw = get_session_info(SID) + assert raw is not None and raw.cwd is None # The pinned SDK reproducer. + original = path.read_bytes() + + async def run(): + machine, transport = _mk_machine() + profile = machine._claude_profiles.default + if explicit: + from cc_remote.claude_profiles import ClaudeProfileRegistry + + machine._claude_profiles = ClaudeProfileRegistry.from_json(json.dumps({ + "selected": {"label": "Selected", "config_dir": str(root), "default": True}, + "other": {"label": "Other", "config_dir": str(tmp_path / "other-profile")}, + })) + profile = machine._claude_profiles.default + info = machine._claude_catalog_session_info(profile, SID) + assert info is not None and info.cwd == "/original-project" + listed = machine._claude_catalog_list_sessions(profile, limit=10) + assert [item.cwd for item in listed if item.session_id == SID] == ["/original-project"] + + class SpawnReached(Exception): + pass + + async def spawn(**kwargs): + assert kwargs["resume_id"] == SID + raise SpawnReached # Stop before any native client is created. + + machine._spawn = spawn + wire_id = machine._claude_profiles.wire_session_id(profile.id, SID) + with pytest.raises(SpawnReached): + await machine._handle_switch_session(SwitchSession(session_id=wire_id, engine="claude", space="code")) + assert not transport.sent + + asyncio.run(run()) + assert path.read_bytes() == original + + +@pytest.mark.parametrize("cwd,bucket,parent_uuid", [ + (LONG_CWD, LONG_PREFIX + "-qyxukm", "earlier-turn"), + (LONG_CWD + "/🧪", LONG_PREFIX + "-ljv3l7", "earlier-turn"), + ("/🧪" + LONG_CWD, EMOJI_PREFIX + "-q5e8nz", "earlier-turn"), + (LONG_CWD, LONG_PREFIX + "-1legacyhash", None), +], ids=["native-ascii", "native-emoji-hash", "native-emoji-prefix", "legacy-hash"]) +def test_image_session_recovers_full_cwd_from_hashed_project_bucket(tmp_path, cwd, bucket, parent_uuid): + # Native keys above were evaluated from Claude 2.1.276's JS sanitizer. + # Non-root rows must match the native key exactly, never just its prefix. + root = tmp_path / "profile" + path = write_image_transcript(root, cwd, bucket=bucket, parent_uuid=parent_uuid) + original = path.read_bytes() + + info = claude_catalog.get_session_info(root, SID) + assert info is not None and info.cwd == cwd + listed = claude_catalog.list_sessions(root) + assert [item.cwd for item in listed if item.session_id == SID] == [cwd] + assert claude_catalog.find_session_file(root, SID, directory=cwd) == path + assert path.read_bytes() == original + + +@pytest.mark.parametrize("bucket,cwd,parent_uuid", [ + (LONG_PREFIX + "-", LONG_CWD, None), + (LONG_PREFIX + "-invalid-hash", LONG_CWD, None), + (LONG_PREFIX + "-INVALID", LONG_CWD, None), + ("-another-project-" + LONG_PREFIX[17:] + "-1legacyhash", LONG_CWD, None), + ("-short-project-1legacyhash", "/short-project", None), + (LONG_PREFIX + "-qyxukm", LONG_CWD + "/later-shell-directory", "earlier-turn"), +]) +def test_cwd_recovery_rejects_invalid_buckets_and_later_prefix_collisions(tmp_path, bucket, cwd, parent_uuid): + path = tmp_path / bucket / f"{SID}.jsonl" + path.parent.mkdir() + row = {"type": "user", "uuid": "human", "parentUuid": parent_uuid, + "sessionId": SID, "cwd": cwd, "message": {"role": "user", "content": "fixture"}} + path.write_text(json.dumps(row) + "\n") + info = SDKSessionInfo(session_id=SID, summary="fixture", last_modified=0) + assert claude_catalog.recover_session_cwd(info, path).cwd is None + + +def test_long_cwd_legacy_hash_requires_explicit_native_root(tmp_path): + path = write_image_transcript(tmp_path, LONG_CWD, bucket=LONG_PREFIX + "-1legacyhash") + rows = [json.loads(line) for line in path.read_text().splitlines()] + del rows[1]["parentUuid"] + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + info = SDKSessionInfo(session_id=SID, summary="fixture", last_modified=0) + assert claude_catalog.recover_session_cwd(info, path).cwd is None + + +def test_cwd_recovery_skips_oversized_rows_without_unbounded_allocation(tmp_path): + root = tmp_path / "profile" + path = write_image_transcript(root) + original = path.read_bytes() + path.write_bytes((json.dumps({"type": "queue-operation", "content": "A" * (claude_catalog._CWD_RECORD_BYTES + 10)}) + "\n").encode() + original) + info = claude_catalog.get_session_info(root, SID) + assert info is not None and info.cwd == "/original-project" + + +@pytest.mark.parametrize("invalid", [ + {"type": "queue-operation"}, + {"isSidechain": True}, + {"sessionId": "22222222-2222-4222-8222-222222222222"}, + {"cwd": "/a-different-project"}, + {"cwd": "relative"}, + {"cwd": None, "message": {"role": "user", "content": '{"cwd":"/original-project"}'}}, +]) +def test_cwd_recovery_does_not_infer_from_prompt_foreign_session_or_later_directory(tmp_path, invalid): + path = tmp_path / _sanitize_path("/original-project") / f"{SID}.jsonl" + path.parent.mkdir() + record = {"type": "user", "uuid": "human", "sessionId": SID, "cwd": "/original-project", + "message": {"role": "user", "content": "fixture"}, **invalid} + path.write_text(json.dumps(record) + "\n") + info = SDKSessionInfo(session_id=SID, summary="fixture", last_modified=0) + assert claude_catalog.recover_session_cwd(info, path).cwd is None + + +def test_cwd_recovery_stops_at_scan_budget_and_keeps_existing_native_metadata(tmp_path, monkeypatch): + path = write_image_transcript(tmp_path / "profile") + info = SDKSessionInfo(session_id=SID, summary="fixture", last_modified=0) + monkeypatch.setattr(claude_catalog, "_CWD_SCAN_BYTES", 64 * 1024) + assert claude_catalog.recover_session_cwd(info, path) is info + info.cwd = "/already-known" + assert claude_catalog.recover_session_cwd(info, path) is info diff --git a/tests/test_claude_external.py b/tests/test_claude_external.py index bec9e775..865d8cbc 100644 --- a/tests/test_claude_external.py +++ b/tests/test_claude_external.py @@ -7,6 +7,7 @@ from pathlib import Path from types import SimpleNamespace +import pytest from claude_agent_sdk.types import ResultMessage, SystemMessage from cc_remote.wrapper import claude_external as claude_external_module @@ -1238,6 +1239,15 @@ def test_claude_growth_classifier_rejects_partial_jsonl(): ) == ("unknown", ()) +@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, + "promptSource": "system", "origin": {"kind": "task-notification"}} + origin, owned = classify_claude_growth((json.dumps(row) + "\n").encode()) + assert origin == expected + assert owned == (("injected",) if expected == "sdk" else ()) + + def test_claude_growth_classifier_treats_atis_latch_as_neutral_metadata(): assistant_id = "11111111-1111-4111-8111-111111111111" origin, owned = classify_claude_growth( diff --git a/tests/test_claude_native_task_merge.py b/tests/test_claude_native_task_merge.py new file mode 100644 index 00000000..03a99dc9 --- /dev/null +++ b/tests/test_claude_native_task_merge.py @@ -0,0 +1,319 @@ +"""Match Code's queued_command -> replayed user projection inside a response.""" + +import asyncio +import json +from unittest.mock import AsyncMock + +import pytest + +from cc_remote.config import WrapperConfig +from cc_remote.wrapper.sdk import SdkHandle +from cc_remote.wrapper.history_store import HistoryIndexStore, materialize_history_turns +from cc_remote.wrapper.stream import transcript_compact_history_page, translate_history +from tests.test_claude_autocompact import _machine_with_sdk +from tests.test_claude_background_completion import task_input +from tests.test_claude_service import environment, released +from tests.test_claude_steering import NativeClient, assistant, result, until, user + + +HUMAN = "11111111-1111-4111-8111-111111111111" +ANSWER = "22222222-2222-4222-8222-222222222222" + + +def absorbed_task(uid): + # Native Code 2.1.276 Y$n projects a queued_command attachment with + # uuid=source_uuid and isReplay=true, including when absorbed_mid_turn. + return {**task_input(uid, {"kind": "task-notification"}), "isReplay": True} + + +def response_frames(stop_reason): + final = assistant(ANSWER, [{"type": "text", "text": "Both checks passed."}]) + final["message"]["stop_reason"] = stop_reason + return [ + user(HUMAN, "check both"), + assistant("before", [{"type": "tool_use", "id": "before-tool", + "name": "Read", "input": {"file_path": "README.md"}}]), + absorbed_task("notification-a"), + assistant("after-a", [{"type": "tool_use", "id": "after-a-tool", + "name": "Read", "input": {"file_path": "a.txt"}}]), + absorbed_task("notification-b"), + assistant("after-b", [{"type": "tool_use", "id": "after-b-tool", + "name": "Read", "input": {"file_path": "b.txt"}}]), + final, + result(), + ] + + +def controller(sdk): + sdk.applied_auto_compact_mode = sdk.auto_compact_mode + sdk.applied_auto_compact_threshold_tokens = sdk.auto_compact_threshold_tokens + sdk.applied_effort = sdk.effort + sdk.refresh_goal = AsyncMock(return_value=None) + machine, transport, ctx = _machine_with_sdk(sdk) + machine._configure_claude_sdk_callbacks(ctx, sdk) + return machine, transport, ctx + + +def assert_one_response(events): + tools = [event for event in events if event.type == "tool_use"] + assert [event.tool_use_id for event in tools] == ["before-tool", "after-a-tool", "after-b-tool"] + assert all(not event.background for event in tools) + final = [event for event in events if event.type == "assistant_msg_end" + and event.message_id == ANSWER and event.channel == "final"] + assert len(final) == 1 and not final[0].background + terminal = [event for event in events if event.type == "turn_end"] + assert len(terminal) == 1 + assert terminal[0].turn_id == ANSWER + assert terminal[0].checkpoint_id == HUMAN + assert not any(event.type == "state" and event.continuation for event in 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): + 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)) + machine, transport, ctx = controller(sdk) + sdk.connect = AsyncMock(side_effect=AssertionError("do not replace the native process")) + sdk.force_reconnect = sdk.connect + sdk.client = client + sdk._start_message_pump() + runner = None + try: + ctx.state, ctx.active_msg_id = "running", "browser-human" + 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): + await native.queue.put(frame) + await asyncio.wait_for(runner, 3) + await until(lambda: sdk._background_callbacks_pending == 0 and ctx.state == "idle") + assert_one_response(transport.sent) + assert not ctx.claude_background_followups + assert not sdk._steers.background_id + if worker: + assert worker.turn is None and worker.background_start is None + + # The parent's real Result permits normal input independently of + # other children; it must not wait for an invented second Result. + ctx.claude_active_tasks.add("another-child") + ctx.state, ctx.active_msg_id = "running", "next-human" + runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "next question")) + await until(lambda: len(inputs) == 2) + await native.queue.put(user("next-human", "next question")) + await native.queue.put(result()) + await asyncio.wait_for(runner, 3) + assert ctx.state == "idle" + assert ctx.claude_active_tasks == {"another-child"} + assert native.interrupts == 0 + finally: + if runner: + runner.cancel() + await asyncio.gather(runner, return_exceptions=True) + await sdk._stop_message_pump() + if persistent: + await client.detach() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cap", [1, 4]) +async def test_old_replayed_task_and_tool_result_cannot_claim_new_query_before_human_echo(cap): + native = NativeClient() + sdk = SdkHandle(WrapperConfig(turn_reader_queue_cap=cap)) + sdk.client = native + background = [] + + async def project(message, _turn): + background.append(message) + + sdk.background_message_callback = project + sdk._start_message_pump() + try: + await sdk.query("new question") + old = user("old-tool-result") + old["message"]["content"] = [{"type": "tool_result", "tool_use_id": "old-tool", "content": "done"}] + await native.queue.put(old) + await native.queue.put(absorbed_task("old-notification")) + await native.queue.put(assistant("old-answer", [{"type": "text", "text": "old report"}])) + await native.queue.put({**result(), "origin": {"kind": "task-notification"}}) + await native.queue.put(user(HUMAN, "new question")) + await native.queue.put(absorbed_task("new-notification")) + await native.queue.put(assistant(ANSWER, [{"type": "text", "text": "new report"}])) + await native.queue.put(result()) + managed = [message async for message in sdk.receive_response()] + await until(lambda: sdk._background_callbacks_pending == 0) + assert [message.uuid for message in managed if getattr(message, "uuid", None)] == [HUMAN, "new-notification", ANSWER] + assert [message.uuid for message in background if getattr(message, "uuid", None)] == [ + "old-tool-result", "old-notification", "old-answer"] + assert not sdk._steers.background_id + finally: + await sdk._stop_message_pump() + + +@pytest.mark.asyncio +async def test_absorbed_task_after_foreign_terminal_retains_the_open_human_response(): + native = NativeClient() + sdk = SdkHandle(WrapperConfig(turn_reader_queue_cap=1)) + sdk.client = native + background = [] + + async def project(message, _turn): + background.append(message) + + sdk.background_message_callback = project + sdk._start_message_pump() + try: + await sdk.query("check both") + frames = response_frames("end_turn") + frames[2:2] = [ + task_input("channel-input", {"kind": "channel"}), + assistant("channel-answer", [{"type": "text", "text": "channel report"}]), + {**result(), "origin": {"kind": "channel"}}, + ] + for frame in frames: + await native.queue.put(frame) + managed = [message async for message in sdk.receive_response()] + await until(lambda: sdk._background_callbacks_pending == 0) + assert [message.uuid for message in managed if getattr(message, "uuid", None)] == [ + HUMAN, "before", "notification-a", "after-a", "notification-b", "after-b", ANSWER] + assert [message.uuid for message in background if getattr(message, "uuid", None)] == [ + "channel-input", "channel-answer"] + finally: + await sdk._stop_message_pump() + + +@pytest.mark.asyncio +async def test_task_return_between_responses_does_not_merge_into_pending_steer(): + native = NativeClient() + sdk = SdkHandle(WrapperConfig(turn_reader_queue_cap=1)) + sdk.client = native + background = [] + + async def project(message, _turn): + background.append(message) + + sdk.background_message_callback = project + sdk._start_message_pump() + consumer = None + try: + await sdk.query("first question") + + async def read(): + return [message async for message in sdk.receive_response()] + + consumer = asyncio.create_task(read()) + await native.queue.put(user(HUMAN, "first question")) + await until(lambda: sdk._pending_turn_background_release is None) + await sdk.steer("next instruction", native_id="guide-echo", metadata={"id": "guide"}) + await native.queue.put(result()) # Intermediate: the guide is not consumed yet. + await native.queue.put(absorbed_task("between-responses")) + await native.queue.put(assistant("earlier-report", [{"type": "text", "text": "earlier report"}])) + await native.queue.put({**result(), "origin": {"kind": "task-notification"}}) + await native.queue.put(user("guide-echo", "next instruction")) + await native.queue.put(absorbed_task("absorbed-by-guide")) + await native.queue.put(assistant(ANSWER, [{"type": "text", "text": "guided answer"}])) + await native.queue.put(result()) + managed = await asyncio.wait_for(consumer, 3) + await until(lambda: sdk._background_callbacks_pending == 0) + assert [message.uuid for message in managed if getattr(message, "uuid", None)] == [ + HUMAN, "guide-echo", "absorbed-by-guide", ANSWER] + assert [message.uuid for message in background if getattr(message, "uuid", None)] == [ + "between-responses", "earlier-report"] + assert native.interrupts == 0 and native.consumers == 1 + finally: + if consumer: + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) + await sdk._stop_message_pump() + + +@pytest.mark.asyncio +async def test_absorbed_notifications_recover_in_order_without_resubmitting(): + async with environment() as (service, attach): + first = await attach() + worker = service.sessions[first.id] + native = worker.client + first.next_turn = {"id": "browser-human", "prompt": "check both"} + await first.query("check both") + frames = response_frames("end_turn") + for frame in frames[:3]: + await native.queue.put(frame) + await until(lambda: worker.journal.seq == 3) + await first.detach() + await released(worker) + for frame in frames[3:]: + await native.queue.put(frame) + await until(lambda: worker.journal.seq == len(frames)) + + sdk = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path)) + sdk.service_defer_events = True + machine, transport, ctx = controller(sdk) + sdk.service_metadata = worker.metadata.copy() + try: + await sdk.connect(resume_id="native-session", cwd="/tmp") + from cc_remote.wrapper.claude_service import activate + + await activate(machine, ctx) + await until(lambda: worker.turn is None and ctx.turn_task is None + and ctx.state == "idle" and sdk._background_callbacks_pending == 0) + assert_one_response(transport.sent) + assert not ctx.claude_background_followups + assert worker.background_start is None + assert native.prompts == ["check both"] + assert native.interrupts == 0 and not native.closed + finally: + if ctx.turn_task: + ctx.turn_task.cancel() + await asyncio.gather(ctx.turn_task, return_exceptions=True) + await sdk.detach_for_shutdown() + + +def test_native_attachment_history_matches_managed_live_response(tmp_path): + rows = [ + {**user("older-human", "earlier question"), "parentUuid": None, + "timestamp": "2026-09-19T13:08:00Z"}, + {**assistant("older-answer", [{"type": "text", "text": "earlier answer"}]), + "parentUuid": "older-human", "timestamp": "2026-09-19T13:08:01Z"}, + {"type": "system", "subtype": "compact_boundary", "uuid": "boundary", + "parentUuid": None, "logicalParentUuid": "older-answer", + "timestamp": "2026-09-19T13:08:02Z", + "compactMetadata": {"trigger": "auto", "preTokens": 500_000}}, + ] + parent = "boundary" + for index, frame in enumerate(response_frames("end_turn")[:-1]): + timestamp = f"2026-09-19T13:09:{index:02d}Z" + uid = frame["uuid"] + if frame.get("origin"): + # The same input is an attachment in native JSONL, not a new + # top-level human row. source_uuid is its SDK replay identity. + row = {"type": "attachment", "uuid": f"attachment-{uid}", + "attachment": {"type": "queued_command", "source_uuid": uid, + "commandMode": "task-notification", "origin": frame["origin"], + "prompt": "background task finished", "timestamp": timestamp}} + else: + row = frame.copy() + row.update(parentUuid=parent, timestamp=timestamp) + rows.append(row) + parent = row["uuid"] + if frame.get("origin"): + rows.append({"type": "queue-operation", "operation": "remove", + "reason": "absorbed_mid_turn", "timestamp": timestamp}) + path = tmp_path / "native.jsonl" + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + store = HistoryIndexStore(tmp_path / "index") + for _ in range(2): # Cold ancestry read, then the indexed summary/detail page. + page = transcript_compact_history_page(HUMAN, path=str(path), limit=1, index_store=store) + assert page is not None + events = translate_history(page.messages, 4096, page.timestamps, page.internal_events) + assert [event.msg_id for event in events if event.type == "user_msg"] == [HUMAN] + assert_one_response(events) + turn, = materialize_history_turns([event.model_dump() for event in events]) + assert turn["done"] and turn["processDetailState"] == "present" + assert turn["detailEventCount"] > 0 + answers = [block for block in turn["blocks"] if block["kind"] == "text" and block["channel"] == "final"] + assert len(answers) == 1 and answers[0]["message_id"] == ANSWER + assert not answers[0].get("background") diff --git a/tests/test_claude_pending_query.py b/tests/test_claude_pending_query.py new file mode 100644 index 00000000..7aab917f --- /dev/null +++ b/tests/test_claude_pending_query.py @@ -0,0 +1,238 @@ +"""Buffered native continuations must not finish an unconsumed browser query.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest +from claude_agent_sdk.types import ResultMessage + +from cc_remote.config import WrapperConfig +from cc_remote.wrapper.sdk import SdkHandle +from tests.test_claude_service import environment, released +from tests.test_claude_steering import NativeClient, assistant, requesting, result, until, user + + +STARTS = [ + requesting("old-request"), + assistant("old-assistant", [{"type": "text", "text": "old report"}]), + {"type": "stream_event", "uuid": "old-stream", "session_id": "native-session", + "event": {"type": "message_start", "message": {"id": "old-response", "content": []}}}, +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("persistent", [False, True]) +@pytest.mark.parametrize("start", STARTS, ids=["request", "assistant", "stream"]) +async def test_buffered_continuation_cannot_finish_pending_query(persistent, start): + 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 + background = [] + seen_terminal = asyncio.Event() + + async def project(message, turn_id): + background.append((message, turn_id)) + if isinstance(message, ResultMessage): + seen_terminal.set() + + sdk.background_message_callback = project + sdk._start_message_pump() + consumer = None + try: + sdk.next_turn_id = "parent" + await sdk.query("earlier question") + await native.queue.put(user("parent-echo")) + await native.queue.put(result()) + previous = [message async for message in sdk.receive_response()] + await sdk.ack_service_message(previous[-1], turn_id="parent") + sdk.release_background_messages() + + sdk.next_turn_id = "new-human" + await sdk.query("new question") + + async def read(): + return [message async for message in sdk.receive_response()] + + consumer = asyncio.create_task(read()) + # Already-buffered native work can reach either sole reader after + # the browser write, without replaying its internal user input. + await native.queue.put(start.copy()) + await native.queue.put(result()) + await until(lambda: seen_terminal.is_set() or consumer.done()) + assert not consumer.done(), "background Result finished the pending browser query" + assert seen_terminal.is_set() + assert {turn_id for _, turn_id in background} == {"parent"} + assert background[0][0]._cc_background_start["managed"] is False + assert background[-1][0]._cc_background_ends + assert not sdk._steers.background_id + if worker: + assert worker.terminal_seq is None and worker.turn["id"] == "new-human" + with pytest.raises(ValueError, match="terminal was not acknowledged"): + await worker.mutate("wrong-commit", "commit", { + "turn_id": "new-human", "seq": worker.journal.seq, + }) + + await native.queue.put(user("new-human-echo", "new question")) + await native.queue.put(assistant("new-answer", [{"type": "text", "text": "new report"}])) + await native.queue.put(result()) + managed = await asyncio.wait_for(consumer, 3) + assert [m.uuid for m in managed if getattr(m, "uuid", None)] == ["new-human-echo", "new-answer"] + assert sum(isinstance(m, ResultMessage) for m in managed) == 1 + await sdk.ack_service_message(managed[-1], turn_id="new-human") + sdk.release_background_messages() + await until(lambda: sdk._background_callbacks_pending == 0) + if worker: + assert worker.turn is None and worker.background_start is None + inputs = native.prompts if persistent else native.inputs + assert inputs == ["earlier question", "new question"] + assert native.interrupts == 0 + finally: + if consumer: + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) + await sdk._stop_message_pump() + + +@pytest.mark.asyncio +async def test_pending_query_recovery_preserves_pre_echo_background_identity(): + async with environment() as (service, attach): + first = await attach() + worker = service.sessions[first.id] + native = worker.client + worker.origin_id = "parent" + first.next_turn = {"id": "new-human", "prompt": "new question"} + await first.query("new question") + await first.detach() + await released(worker) + await native.queue.put(STARTS[1].copy()) + await native.queue.put(result()) + await until(lambda: worker.journal.seq == 2) + assert worker.terminal_seq is None + + sdk = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path)) + sdk.service_metadata = worker.metadata.copy() + sdk.refresh_goal = AsyncMock(return_value=None) + background = [] + + async def project(message, turn_id): + background.append((message, turn_id)) + + sdk.background_message_callback = project + consumer = None + try: + await sdk.connect(resume_id="native-session", cwd="/tmp") + + async def read(): + return [message async for message in sdk.receive_response()] + + consumer = asyncio.create_task(read()) + await until(lambda: any(isinstance(m, ResultMessage) for m, _ in background) or consumer.done()) + assert not consumer.done() + assert {turn_id for _, turn_id in background} == {"parent"} + await native.queue.put(user("new-human-echo", "new question")) + await native.queue.put(assistant("new-answer", [{"type": "text", "text": "new report"}])) + await native.queue.put(result()) + managed = await asyncio.wait_for(consumer, 3) + assert [m.uuid for m in managed if getattr(m, "uuid", None)] == ["new-human-echo", "new-answer"] + await sdk.ack_service_message(managed[-1], turn_id="new-human") + sdk.release_background_messages() + await until(lambda: worker.turn is None and worker.background_start is None) + assert native.prompts == ["new question"] and native.interrupts == 0 + finally: + if consumer: + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) + await sdk.detach_for_shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("persistent", [False, True]) +async def test_unannounced_response_between_root_result_and_steer_echo(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 + background = [] + + async def project(message, turn_id): + background.append((message, turn_id)) + + sdk.background_message_callback = project + sdk._start_message_pump() + consumer = None + try: + sdk.next_turn_id = "parent" + await sdk.query("first question") + + async def read(): + return [message async for message in sdk.receive_response()] + + consumer = asyncio.create_task(read()) + await native.queue.put(user("parent-echo", "first question")) + await until(lambda: sdk._managed_input_seen) + await sdk.steer("new question", native_id="guide-echo", metadata={"id": "guide"}) + await native.queue.put(result()) + await native.queue.put(STARTS[1].copy()) + await native.queue.put(result()) + await until(lambda: any(isinstance(m, ResultMessage) for m, _ in background)) + assert not consumer.done() + assert {turn_id for _, turn_id in background} == {"parent"} + assert not sdk._steers.background_id + if worker: + assert worker.terminal_seq is None and worker.turn["id"] == "parent" + await native.queue.put(user("guide-echo", "new question")) + await native.queue.put(assistant("guided-answer", [{"type": "text", "text": "new report"}])) + await native.queue.put(result()) + managed = await asyncio.wait_for(consumer, 3) + assert [m.uuid for m in managed if getattr(m, "uuid", None)] == [ + "parent-echo", "guide-echo", "guided-answer"] + assert sum(isinstance(m, ResultMessage) for m in managed) == 1 + await sdk.ack_service_message(managed[-1], turn_id="guide") + sdk.release_background_messages() + await until(lambda: sdk._background_callbacks_pending == 0) + if worker: + assert worker.turn is None and worker.background_start is None + 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("finish", ["compact", "error"]) +async def test_persistent_commands_can_finish_without_user_replay(finish): + async with environment() as (service, attach): + client = await attach() + worker = service.sessions[client.id] + sdk = SdkHandle(WrapperConfig()) + sdk.client = client + sdk._start_message_pump() + try: + sdk.next_turn_id = "command" + await sdk.query("/compact" if finish == "compact" else "bad request") + if finish == "compact": + await worker.client.queue.put({"type": "system", "subtype": "status", "status": "compacting"}) + await worker.client.queue.put({"type": "system", "subtype": "compact_boundary", + "compact_metadata": {"trigger": "manual", "pre_tokens": 100}}) + terminal = {**result(), "is_error": finish == "error"} + await worker.client.queue.put(terminal) + + async def read(): + return [message async for message in sdk.receive_response()] + + managed = await asyncio.wait_for(read(), 3) + assert managed[-1].is_error is (finish == "error") + assert worker.origin_id == "command" + assert worker.background_start is None + await sdk.ack_service_message(managed[-1], turn_id="command") + sdk.release_background_messages() + assert worker.turn is None + finally: + await sdk._stop_message_pump() diff --git a/tests/test_claude_queued_prompt_history.py b/tests/test_claude_queued_prompt_history.py new file mode 100644 index 00000000..1ca467c9 --- /dev/null +++ b/tests/test_claude_queued_prompt_history.py @@ -0,0 +1,208 @@ +"""Consumed human inputs survive native catalog, compaction and cached pages.""" +import json +import sqlite3 + +import pytest +from claude_agent_sdk._internal.sessions import ( + _entries_to_session_messages, + _parse_transcript_entries, +) + +from cc_remote.protocol import Delta, TurnEnd, UserMsg +from cc_remote.wrapper.history_store import ( + HistoryIndexStore, + HistorySourceFingerprint, + materialize_history_turns, +) +from cc_remote.wrapper.stream import ( + recover_claude_native_metadata, + transcript_compact_history_page, + transcript_compact_snapshot, + transcript_timestamps, + translate_history, +) +from tests.test_history_store import _page + +SID = "11111111-1111-4111-8111-111111111111" + + +def record(uid, parent, role, content, second, **extra): + return {"uuid": uid, "parentUuid": parent, "type": role, "sessionId": SID, + "timestamp": f"2026-09-19T13:26:{second:02d}Z", "isSidechain": False, + "message": {"role": role, "content": content}, **extra} + + +def queued(uid, parent, public_id, prompt, second, **extra): + row = record(uid, parent, "attachment", None, second) + row.pop("message") + row["attachment"] = {"type": "queued_command", "source_uuid": public_id, + "commandMode": "prompt", "prompt": prompt, **extra} + return row + + +def response(uid, parent, text, second, stop="tool_use"): + row = record(uid, parent, "assistant", [{"type": "text", "text": text}], second) + row["message"]["stop_reason"] = stop + return row + + +def transcript(compact, rich=False): + prefix = [record("old", None, "user", "earlier", 0), + response("old-answer", "old", "earlier answer", 1, "end_turn")] + parent = "old-answer" + if compact: + prefix.append({"type": "system", "subtype": "compact_boundary", "uuid": "compact", + "parentUuid": None, "logicalParentUuid": parent, + "timestamp": "2026-09-19T13:26:02Z"}) + parent = "compact" + # Same text with different native UUIDs is two distinct human messages. + prompt = "additional direction" + if rich: + prompt = [{"type": "text", "text": "additional"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", + "data": "aGVsbG8="}}, + {"type": "text", "text": "direction"}] + return [*prefix, record("human", parent, "user", "original question", 3), + response("working", "human", "initial work", 4), + queued("attachment-one", "working", "steer-one", prompt, 5), + # Consecutive consumed inputs also preserve their order. + queued("attachment-two", "attachment-one", "steer-two", prompt, 6, + origin={"kind": "human"}), + response("continued", "attachment-two", "continued work", 7), + queued("task", "continued", "task-source", "background result", 8, + commandMode="task-notification"), + response("answer", "task", "final answer", 9, "end_turn")] + + +def write(path, rows): + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + + +def read(path, mode, store=None): + if mode == "catalog": + messages = _entries_to_session_messages( + _parse_transcript_entries(path.read_text()), None, 0) + timestamps = transcript_timestamps(SID, path=str(path)) + internal = {} + else: + messages, timestamps, internal = transcript_compact_snapshot( + SID, path=str(path), index_store=store) + messages = recover_claude_native_metadata( + SID, messages, path=str(path), timestamps=timestamps, + internal_events=internal, index_store=store, include_queued_prompts=mode == "catalog") + # Repeating the recovery cannot duplicate already projected inputs. + messages = recover_claude_native_metadata( + SID, messages, path=str(path), timestamps=timestamps, + internal_events=internal, index_store=store, include_queued_prompts=mode == "catalog") + return translate_history(messages, 4096, timestamps, internal, + client_message_ids={"steer-two": "browser-two"}) + + +@pytest.mark.parametrize("mode", ["catalog", "compact", "indexed"]) +@pytest.mark.parametrize("rich", [False, True]) +def test_human_attachments_keep_order_identity_and_reply_owner(tmp_path, mode, rich): + path = tmp_path / f"{SID}.jsonl" + write(path, transcript(mode != "catalog", rich)) + original = path.read_bytes() + store = HistoryIndexStore(tmp_path / "index") if mode == "indexed" else None + for _ in range(2): + events = read(path, mode, store) + users = [e for e in events if isinstance(e, UserMsg)] + assert [e.msg_id for e in users] == ["old", "human", "steer-one", "steer-two"] + assert users[-1].client_msg_id == "browser-two" + assert users[-1].ts - users[-2].ts == 1 + assert users[-1].prompt == ("additional\ndirection" if rich else "additional direction") + assert bool(users[-1].images) == rich + ends = [e for e in events if isinstance(e, TurnEnd)] + assert [e.result.subtype for e in ends] == ["success", "steered", "steered", "success"] + turns = materialize_history_turns([e.model_dump() for e in events]) + assert [t["id"] for t in turns] == [e.msg_id for e in users] + assert all(t["done"] for t in turns) + assert not any(b.get("channel") == "final" for t in turns[1:-1] for b in t["blocks"]) + final = [b for b in turns[-1]["blocks"] if b.get("channel") == "final"] + assert len(final) == 1 and final[0]["text"] == "final answer" + assert not final[0].get("background") + assert [e.text for e in events if isinstance(e, Delta)].count("final answer") == 1 + assert path.read_bytes() == original + + +def test_compact_pages_use_echo_uuid_and_count_attachment_payload(tmp_path): + path = tmp_path / f"{SID}.jsonl" + rows = transcript(True) + rows[-2]["attachment"]["prompt"] = "task output" + rows[5]["attachment"]["prompt"] = "x" * 4096 + write(path, rows) + store = HistoryIndexStore(tmp_path / "index") + before = None + cursors = [] + for _ in range(4): + page = transcript_compact_history_page( + SID, path=str(path), limit=1, before=before, index_store=store) + assert page is not None + page.messages[:] = recover_claude_native_metadata( + SID, page.messages, path=str(path), timestamps=page.timestamps, + internal_events=page.internal_events, index_store=store, + include_queued_prompts=False) + events = translate_history(page.messages, 4096, page.timestamps, page.internal_events) + user, = [e for e in events if isinstance(e, UserMsg)] + assert page.oldest_cursor == user.msg_id + cursors.append(user.msg_id) + before = page.oldest_cursor + assert cursors == ["steer-two", "steer-one", "human", "old"] + assert not page.has_more + assert transcript_compact_history_page( + SID, path=str(path), limit=1, before="steer-two", index_store=store, + max_payload_bytes=1024) is None + + +@pytest.mark.parametrize("compact", [False, True]) +def test_only_accepted_humans_on_active_ancestry_are_restored(tmp_path, compact): + rows = transcript(compact) + answer = rows.pop() + rows.extend([ + queued("abandoned", "human", "abandoned-input", "rewound input", 10), + queued("sidechain", "human", "child-input", "child input", 11), + {"type": "queue-operation", "operation": "enqueue", "content": "not consumed yet"}, + ]) + rows[-2]["isSidechain"] = True + parent = "task" + for index, extra in enumerate([ + {"origin": {"kind": "task-notification"}}, + {"origin": {"kind": "agent"}}, + {"commandMode": "other"}, + {"source_uuid": None}, + ]): + uid = f"not-human-{index}" + rows.append(queued(uid, parent, uid + "-source", "hidden input", 12 + index, **extra)) + parent = uid + answer["parentUuid"] = parent + rows.append(answer) + path = tmp_path / f"{SID}.jsonl" + write(path, rows) + events = read(path, "compact" if compact else "catalog") + assert [e.msg_id for e in events if isinstance(e, UserMsg)] == [ + "old", "human", "steer-one", "steer-two"] + + +def test_v43_rebuilds_claude_projection_preserving_other_engines_and_assets(tmp_path): + path = tmp_path / f"{SID}.jsonl" + write(path, transcript(True)) + store = HistoryIndexStore(tmp_path / "index") + read(path, "indexed", store) + fingerprint = HistorySourceFingerprint.capture(path) + for engine in ("claude", "codex", "dsh"): + store.put_page(SID, engine, fingerprint, before=None, limit=4, page=_page(engine)) + store.put_image_asset(SID, engine, fingerprint, engine, "image", + "thumbnail", "image/png", 1, 1, b"image") + with sqlite3.connect(store.path) as db: + db.execute("UPDATE claude_compact_records SET visible_user=0 WHERE row_type='attachment'") + 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 + 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 + events = read(path, "indexed", reopened) + assert [e.msg_id for e in events if isinstance(e, UserMsg)][-2:] == ["steer-one", "steer-two"] diff --git a/tests/test_claude_rich_stream.py b/tests/test_claude_rich_stream.py index 6d0228c6..cd6dd8d9 100644 --- a/tests/test_claude_rich_stream.py +++ b/tests/test_claude_rich_stream.py @@ -5,6 +5,8 @@ import json from types import SimpleNamespace +import pytest + from claude_agent_sdk.types import ( AssistantMessage, HookEventMessage, @@ -1132,6 +1134,116 @@ async def _collect(source): asyncio.run(run()) +@pytest.mark.parametrize("queue_cap", [1, 4]) +@pytest.mark.parametrize("origin_kind", ["task-notification", "channel"]) +@pytest.mark.parametrize("interrupted", [False, True]) +def test_sdk_pump_drains_injected_turn_before_the_managed_result( + queue_cap, origin_kind, interrupted, +): + async def run(): + queue = asyncio.Queue() + post_result_read = asyncio.Event() + consumers = 0 + + async def receive_messages(): + nonlocal consumers + consumers += 1 + while True: + row = await queue.get() + if row.get("uuid") == "post-result": + post_result_read.set() + yield row + + async def query(_prompt): + pass + + def result(origin): + return { + "type": "result", "subtype": "success", + "duration_ms": 1, "duration_api_ms": 1, + "is_error": False, "num_turns": 1, + "session_id": "session-1", "origin": {"kind": origin}, + } + + handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=queue_cap)) + handle.client = SimpleNamespace( + _query=SimpleNamespace(receive_messages=receive_messages), query=query) + background = [] + background_terminal = asyncio.Event() + post_result_delivered = asyncio.Event() + + async def project(message, turn_id): + background.append((message, turn_id)) + if isinstance(message, ResultMessage): + background_terminal.set() + if getattr(message, "uuid", None) == "post-result": + post_result_delivered.set() + + async def collect(): + return [message async for message in handle.receive_response()] + + handle.background_message_callback = project + handle._start_message_pump() + response = None + try: + handle.next_turn_id = "human-turn" + await handle.query("work") + response = asyncio.create_task(collect()) + await queue.put({ + "type": "user", "uuid": "human-user", + "message": {"role": "user", "content": "work"}, + "origin": {"kind": "human"}, + }) + await queue.put({ + "type": "user", "uuid": "injected-user", + "message": {"role": "user", "content": "task completed"}, + "origin": {"kind": origin_kind}, + }) + frame_count = queue_cap * 3 + 1 + for index in range(frame_count): + await queue.put({ + "type": "stream_event", "uuid": f"delta-{index}", + "session_id": "session-1", "parent_tool_use_id": None, + "event": {"type": "content_block_delta", "index": 0, + "delta": {"type": "text_delta", "text": str(index)}}, + }) + await queue.put(result(origin_kind)) + await asyncio.wait_for(background_terminal.wait(), 2) + assert not response.done() + assert [turn_id for _, turn_id in background] == ["human-turn"] * (frame_count + 2) + assert [message.event["delta"]["text"] for message, _ in background + if isinstance(message, StreamEvent)] == [str(index) for index in range(frame_count)] + + terminal = result("human") + if interrupted: + terminal.update(subtype="error_during_execution", is_error=True) + await queue.put(terminal) + messages = await asyncio.wait_for(response, 2) + assert len(messages) == 2 + assert isinstance(messages[0], UserMessage) + assert isinstance(messages[-1], ResultMessage) + assert messages[-1].is_error is interrupted + + await queue.put({ + "type": "system", "subtype": "task_progress", + "uuid": "post-result", "session_id": "session-1", + "task_id": "task-1", "description": "Follow-up", + "usage": {"total_tokens": 1}, + }) + await asyncio.wait_for(post_result_read.wait(), 2) + assert not post_result_delivered.is_set() + handle.release_background_messages() + await asyncio.wait_for(post_result_delivered.wait(), 2) + assert consumers == 1 + finally: + if response is not None: + response.cancel() + await asyncio.gather(response, return_exceptions=True) + await handle._stop_message_pump() + + asyncio.run(run()) + + def test_sdk_pump_releases_turn_barrier_on_query_failure_and_disconnect(): async def run(): class Query: diff --git a/tests/test_claude_service.py b/tests/test_claude_service.py index b94445c9..afb57467 100644 --- a/tests/test_claude_service.py +++ b/tests/test_claude_service.py @@ -201,6 +201,8 @@ async def go(): worker = service.sessions[first.id] native = worker.client original_controls = worker.controls.copy() + await native.queue.put({"type": "user", "uuid": "human-echo", + "message": {"role": "user", "content": "hello"}}) await first.detach() await released(worker) @@ -263,8 +265,11 @@ async def go(): await first.query("hello") worker = service.sessions[first.id] native = worker.client + await native.queue.put({"type": "user", "uuid": "human-echo", + "message": {"role": "user", "content": "hello"}}) await native.queue.put({"type": "assistant", "message": {"content": "part one"}}) stream = first.receive_messages() + assert (await anext(stream))["uuid"] == "human-echo" assert (await anext(stream))["message"]["content"] == "part one" await first.detach() await released(worker) @@ -276,6 +281,7 @@ async def go(): assert second.id == first.id assert second.recovery["id"] == "browser-msg" replay = second.receive_messages() + assert (await anext(replay))["uuid"] == "human-echo" assert (await anext(replay))["message"]["content"] == "part one" assert (await anext(replay))["message"]["content"] == "part two" terminal = await anext(replay) @@ -456,6 +462,8 @@ async def go(): await released(worker) native = worker.client rows = [ + {"type": "user", "uuid": "human-echo", + "message": {"role": "user", "content": "hello"}}, {"type": "stream_event", "uuid": "e1", "session_id": SESSION_ID, "event": { "type": "message_start", "message": {"id": "native-assistant"}, }}, @@ -482,6 +490,7 @@ async def go(): handle.service_defer_events = True await handle.connect(resume_id=SESSION_ID, cwd="/tmp") machine, transport, ctx = _machine_with_sdk(handle) + machine._configure_claude_sdk_callbacks(ctx, handle) ctx.active_msg_id = "browser-msg" ctx.state = "running" try: @@ -495,6 +504,10 @@ async def go(): assert len([item for item in transport.sent if isinstance(item, TurnEnd)]) == 1 assert native.prompts == ["hello"] assert ctx.state == "idle" + # Replay completion is delivered behind the recovered turn's + # projection barrier; its deferred commit may still be in flight. + async with worker.changed: + await asyncio.wait_for(worker.changed.wait_for(lambda: worker.turn is None), 2) assert worker.turn is None finally: await handle.detach_for_shutdown() diff --git a/tests/test_claude_service_delivery.py b/tests/test_claude_service_delivery.py index 20d778b8..5a1a7b41 100644 --- a/tests/test_claude_service_delivery.py +++ b/tests/test_claude_service_delivery.py @@ -336,3 +336,71 @@ async def project(message, _turn): await handle.detach_for_shutdown() asyncio.run(go()) + + +@pytest.mark.parametrize("fails", [False, True]) +def test_mid_turn_background_burst_preserves_service_commit_boundary(fails): + async def go(): + async with environment() as (service, attach): + client = await attach() + worker = service.sessions[client.id] + projected = [] + + async def project(message, turn_id): + assert turn_id == "human" + projected.append(message._cc_service_seq) + if fails and message._cc_service_seq == 4: + raise ValueError("temporary projection failure") + + handle = start_handle(client, project) + response = None + try: + handle.next_turn_id = "human" + await handle.query("hello") + + async def consume(): + return [message async for message in handle.receive_response()] + + response = asyncio.create_task(consume()) + rows = [ + {"type": "user", "message": {"role": "user", "content": "hello"}, + "uuid": "human", "origin": {"kind": "human"}}, + {"type": "user", "message": {"role": "user", "content": "task done"}, + "uuid": "background", "origin": {"kind": "task-notification"}}, + *[{"type": "assistant", "message": {"id": f"background-{number}", + "model": "test", "content": [{"type": "text", "text": "progress"}]}} + for number in range(12)], + {**result(), "origin": {"kind": "task-notification"}}, + result(), + ] + for row in rows: + await worker.client.queue.put(row) + messages = await asyncio.wait_for(response, 2) + await asyncio.wait_for(handle._background_callbacks_drained.wait(), 2) + assert len(messages) == 2 + assert isinstance(messages[-1], ResultMessage) + assert messages[-1].origin == {"kind": "human"} + if fails: + with pytest.raises(ClaudeServiceReplayRequired): + await handle.ack_service_message(messages[-1], turn_id="human") + assert projected == [2, 3, 4] + assert worker.ack == 3 + assert worker.turn["id"] == "human" + assert [row["seq"] for row in worker.journal.after(0)] == list(range(1, len(rows) + 1)) + else: + await handle.ack_service_message(messages[-1], turn_id="human") + assert projected == list(range(2, len(rows))) + assert worker.turn is None + assert worker.background_turns == [] + assert worker.ack == len(rows) + assert worker.journal.after(0) == [] + assert worker.client.prompts == ["hello"] + assert worker.client.interrupts == 0 + assert not worker.client.closed + finally: + if response is not None: + response.cancel() + await asyncio.gather(response, return_exceptions=True) + await handle.detach_for_shutdown() + + asyncio.run(go()) diff --git a/tests/test_claude_steering.py b/tests/test_claude_steering.py index d19d7f56..8036aaf4 100644 --- a/tests/test_claude_steering.py +++ b/tests/test_claude_steering.py @@ -7,7 +7,7 @@ import pytest from claude_agent_sdk.types import ResultMessage -from cc_remote.claude_steering import ClaudeSteerRejected +from cc_remote.claude_steering import ClaudeSteerRejected, PendingSteers from cc_remote.protocol import Steer from cc_remote.wrapper.sdk import SdkHandle from cc_remote.wrapper.stream import StreamTranslator, replayed_user_message_id @@ -68,10 +68,110 @@ async def until(predicate): await asyncio.sleep(0.001) +def requesting(uid="request"): + return {"type": "system", "subtype": "status", "status": "requesting", "uuid": uid} + + @pytest.mark.asyncio +@pytest.mark.parametrize("persistent", [False, True]) +@pytest.mark.parametrize("finish", ["result", "steer", "stop"]) +@pytest.mark.parametrize("start", [ + requesting(), + assistant("unannounced", [{"type": "text", "text": "continue"}]), + {"type": "stream_event", "uuid": "unannounced", "session_id": "native-session", + "event": {"type": "message_start", "message": {"id": "continuation", "content": []}}}, +], ids=["request", "assistant", "stream"]) +async def test_native_continuation_without_replayed_user_is_visible_and_interactive(persistent, finish, start): + 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 + + async def interrupt(): + native.interrupts += 1 + + native.interrupt = interrupt + sdk = SdkHandle(WrapperConfig()) + sdk.applied_auto_compact_mode = sdk.auto_compact_mode + sdk.applied_auto_compact_threshold_tokens = sdk.auto_compact_threshold_tokens + machine, transport, ctx = _machine_with_sdk(sdk) + machine._configure_claude_sdk_callbacks(ctx, sdk) + sdk.refresh_goal = AsyncMock(return_value=None) + sdk.client = client + sdk._start_message_pump() + try: + sdk.next_turn_id = "parent" + await sdk.query("start background work") + await native.queue.put(user("human", "start background work")) + await native.queue.put(result()) + messages = [m async for m in sdk.receive_response()] + await sdk.ack_service_message(messages[-1], turn_id="parent") + sdk.release_background_messages() + + for task in ("task-a", "task-b"): + await native.queue.put({"type": "system", "subtype": "task_notification", + "task_id": task, "status": "completed", "output_file": "", + "summary": "finished", "uuid": task, "session_id": ctx.session_id}) + await until(lambda: native.queue.empty() and sdk._background_callbacks_pending == 0) + assert ctx.state == "idle" and not ctx.claude_background_followups + + # Claude Code 2.1.276 does not replay ordinary internal task prompts. + # Its next request and output arrive without any UserMessage first. + await native.queue.put(start.copy()) + await until(lambda: ctx.state == "running") + running = [e for e in transport.sent if e.type == "state" and e.state == "running"] + assert running[-1].msg_id == "parent" and running[-1].continuation + assert machine._own_write(ctx.key) + await native.queue.put(assistant("background-tool", [{"type": "tool_use", "id": "read", + "name": "Read", "input": {"file_path": "README.md"}}])) + await until(lambda: any(e.type == "tool_use" for e in transport.sent)) + tools = [e for e in transport.sent if e.type == "tool_use"] + assert tools[-1].turn_id == "parent" and tools[-1].background + if worker: + assert worker.background_start is not None + assert worker.journal.after(0)[0]["data"]["uuid"] == start["uuid"] + + if finish == "steer": + reply = await machine._handle_steer(Steer(sid=ctx.key, cmd_id="guide", client_id="browser", + msg_id="guide-ui", prompt="continue here")) + assert reply is None + inputs = native.prompts if persistent else native.inputs + written = [item async for item in inputs[-1]] if persistent else inputs[-1] + uid = written[0]["uuid"] + await native.queue.put(user(uid, "continue here")) + await native.queue.put(assistant("guided", [{"type": "text", "text": "guided answer"}])) + await native.queue.put(result()) + await until(lambda: ctx.turn_task is None and ctx.state == "idle") + assert len(inputs) == 2 + assert sum(e.type == "turn_steered" for e in transport.sent) == 1 + assert sum(e.type == "turn_end" for e in transport.sent) == 1 + else: + if finish == "stop": + await machine._handle_interrupt(SimpleNamespace(sid=ctx.key)) + assert native.interrupts == 1 + await native.queue.put({**result(), "origin": {"kind": "task-notification"}}) + await until(lambda: ctx.state == "idle") + assert not any(e.type == "turn_end" for e in transport.sent) + await until(lambda: sdk._background_callbacks_pending == 0) + assert not ctx.claude_background_followups and not sdk._steers.background_id + if worker: + await until(lambda: worker.turn is None and worker.background_start is None) + assert not native.closed + finally: + for task in (ctx.turn_task, ctx.claude_autonomous_interrupt_task): + if task: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await sdk._stop_message_pump() + if persistent: + await client.detach() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("origin", [{"kind": "task-notification"}, ORIGIN]) @pytest.mark.parametrize("uncertain", [False, True]) @pytest.mark.parametrize("background_result", ["before", "after", "none"]) -async def test_autonomous_steer_waits_for_exact_echo_then_uses_normal_turn(uncertain, background_result): +async def test_autonomous_steer_waits_for_exact_echo_then_uses_normal_turn(uncertain, background_result, origin): sdk = SdkHandle(WrapperConfig()) machine, transport, ctx = _machine_with_sdk(sdk) machine._configure_claude_sdk_callbacks(ctx, sdk) @@ -79,7 +179,7 @@ async def test_autonomous_steer_waits_for_exact_echo_then_uses_normal_turn(uncer sdk.client = native = NativeClient() sdk._start_message_pump() try: - await native.queue.put({**user("injected", "background task finished"), "origin": ORIGIN}) + await native.queue.put({**user("injected", "background task finished"), "origin": origin}) await until(lambda: ctx.state == "running") assert ctx.active_msg_id is None and ctx.translator is None native.fail_write = uncertain @@ -90,7 +190,7 @@ async def test_autonomous_steer_waits_for_exact_echo_then_uses_normal_turn(uncer uid = native.inputs[0][0]["uuid"] await native.queue.put(user("old-tool", [{"type": "tool_result", "tool_use_id": "old", "content": "done"}])) if background_result == "before": - await native.queue.put({**result(), "origin": ORIGIN}) + await native.queue.put({**result(), "origin": origin}) await until(lambda: sdk._background_callbacks_pending == 0 and not sdk._steers.background_id) assert ctx.state == "running" and ctx.turn_task is None assert not any(e.type in {"turn_end", "turn_steered"} for e in transport.sent) @@ -98,7 +198,7 @@ async def test_autonomous_steer_waits_for_exact_echo_then_uses_normal_turn(uncer await sdk.query("must not overtake accepted guidance") await native.queue.put(user(uid)) if background_result == "after": - await native.queue.put({**result(), "origin": ORIGIN}) + await native.queue.put({**result(), "origin": origin}) await native.queue.put(assistant("guided-reply", [{"type": "text", "text": "guided answer"}])) await native.queue.put(result()) await until(lambda: any(e.type == "turn_end" for e in transport.sent)) @@ -191,7 +291,6 @@ async def test_service_multiple_background_inputs_cancel_one_and_keep_root_commi def test_background_target_matches_exact_origin_across_native_field_spellings(): - from cc_remote.claude_steering import PendingSteers pending = PendingSteers() pending.annotate({**user("injected"), "origin": ORIGIN}) identity = pending.background_id @@ -201,17 +300,40 @@ def test_background_target_matches_exact_origin_across_native_field_spellings(): assert pending.background_id is None +def test_only_main_activity_starts_an_implicit_continuation(): + pending = PendingSteers() + frames = [requesting(), assistant("output", [])] + for frame in frames: + assert "__cc_background_start" not in pending.annotate(frame, managed_active=True) + for field in ("parent_tool_use_id", "parentToolUseID"): + assert "__cc_background_start" not in pending.annotate({**frame, field: "child"}) + for subtype in ("task_started", "task_progress", "task_notification", "task_updated"): + pending.annotate({"type": "system", "subtype": subtype, "task_id": "child", "status": "completed"}) + assert pending.background_id is None + + started = pending.annotate(requesting()) + identity = started["__cc_background_start"]["id"] + assert "__cc_background_start" not in pending.annotate(assistant("next", [])) + pending.annotate({**result(), "origin": {"kind": "human"}}) + assert pending.background_id == identity + enriched = pending.annotate({**user("injected"), "origin": ORIGIN}) + assert enriched["__cc_background_start"]["id"] == identity + ended = pending.annotate({**result(), "origin": ORIGIN}) + assert ended["__cc_background_end"] == identity and pending.background_id is None + + @pytest.mark.asyncio +@pytest.mark.parametrize("replayed_user", [False, True]) @pytest.mark.parametrize("echo_before_detach", [False, True]) @pytest.mark.parametrize("background_result", [False, True]) -async def test_service_background_steer_recovery_does_not_resubmit(echo_before_detach, background_result): +async def test_service_background_steer_recovery_does_not_resubmit(echo_before_detach, background_result, replayed_user): guide_id = "22222222-2222-4222-8222-222222222222" metadata = {"id": "guide-ui", "prompt": "guide", "fingerprint": "a" * 64} async with environment() as (service, attach): first = await attach() worker = service.sessions[first.id] native = worker.client - await native.queue.put({**user("injected"), "origin": ORIGIN}) + await native.queue.put({**user("injected"), "origin": ORIGIN} if replayed_user else requesting()) await until(lambda: worker.steers.background_id) target = worker.steers.background_id await first.steer("guide", native_id=guide_id, metadata=metadata, diff --git a/tests/test_codex_session_migration.py b/tests/test_codex_session_migration.py index 6425cf81..04b57718 100644 --- a/tests/test_codex_session_migration.py +++ b/tests/test_codex_session_migration.py @@ -91,7 +91,7 @@ async def list_sessions(_cmd): def test_session_migration_protocol_roundtrips_as_control_frames(): - assert PROTOCOL_VERSION == 71 + assert PROTOCOL_VERSION == 72 command = deserialize(serialize(_command("/tmp/new-cwd"))) assert command.type == "migrate_session" assert command.session_id == "thread-1" diff --git a/tests/test_command_reliability.py b/tests/test_command_reliability.py index c52cb76d..cf3f373f 100644 --- a/tests/test_command_reliability.py +++ b/tests/test_command_reliability.py @@ -1064,7 +1064,9 @@ async def forbidden_spawn(*_args, **_kwargs): assert isinstance(result, Error) assert result.code == "not_running" - assert "历史不完整" in result.message + assert "无法确认" in result.message and "工作目录" in result.message + assert "历史记录已保留" in result.message + assert "删除" not in result.message assert result.sid == session_id assert result.to == "client-1" assert result.request_id == "switch-metadata-only" diff --git a/tests/test_history_store.py b/tests/test_history_store.py index 7ea1ef3f..e7c5ba4f 100644 --- a/tests/test_history_store.py +++ b/tests/test_history_store.py @@ -461,7 +461,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='codex'" @@ -490,7 +490,7 @@ 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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 assert connection.execute( "SELECT engine FROM history_pages").fetchall() == ([] if old_version == 41 else [("codex",)]) assert connection.execute( @@ -565,7 +565,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 assert connection.execute( "SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0 @@ -593,7 +593,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -654,7 +654,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -711,7 +711,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -773,7 +773,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -844,7 +844,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -953,7 +953,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ( "history_pages", "history_turn_details", @@ -1000,7 +1000,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1051,7 +1051,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" @@ -1097,7 +1097,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1139,7 +1139,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -1203,7 +1203,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] == 43 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 44 assert connection.execute( "SELECT COUNT(*) FROM history_pages" ).fetchone()[0] == 0 diff --git a/tests/test_product_version.py b/tests/test_product_version.py index 0b797f02..b5808835 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -12,8 +12,8 @@ ROOT = Path(__file__).resolve().parents[1] -def test_v3_product_version_is_consistent_across_runtime_and_web_metadata(): - assert __version__ == "3.0.0" +def test_product_version_is_consistent_across_runtime_and_web_metadata(): + assert __version__ == "4.0.0" assert re.fullmatch(r"[1-9]\d*\.\d+\.\d+", __version__) package = json.loads((ROOT / "web/package.json").read_text()) @@ -42,7 +42,7 @@ def test_release_docs_distinguish_product_and_wire_protocol_versions(): assert f"产品版本:v{__version__}" in readme assert f"Product version: v{__version__}" in readme_en for document in (readme, readme_en, changelog): - assert "v3.0.0" in document + assert f"v{__version__}" in document assert f"protocol v{PROTOCOL_VERSION}" in document.lower() diff --git a/web/package-lock.json b/web/package-lock.json index bcecdcf5..97fd21ae 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "3.0.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "3.0.0", + "version": "4.0.0", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "3.14.8", diff --git a/web/package.json b/web/package.json index ee5c106a..e2fe85c8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "3.0.0", + "version": "4.0.0", "author": "muggle", "license": "MIT", "type": "module", @@ -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/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/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/public/cc-remote-build.json b/web/public/cc-remote-build.json index 36cf6839..4ffce62e 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1,4 +1,4 @@ { - "version": "3.0.0", - "protocol": 71 + "version": "4.0.0", + "protocol": 72 } diff --git a/web/src/clipboard-import.ts b/web/src/clipboard-import.ts index 803f94f7..a086db0a 100644 --- a/web/src/clipboard-import.ts +++ b/web/src/clipboard-import.ts @@ -62,14 +62,23 @@ export async function resolveClipboardImport(snapshot: ClipboardImport) { export function insertClipboardText( textarea: HTMLTextAreaElement, text: string, setText: (value: string) => void, ): void { + const previous = textarea.value; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; // Preserve native textarea undo where supported. Controlled-state fallback // also covers synthetic paste events and browser environments without it. if (document.activeElement === textarea - && typeof document.execCommand === "function" - && document.execCommand("insertText", false, text)) return; - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - setText(textarea.value.slice(0, start) + text + textarea.value.slice(end)); + && typeof document.execCommand === "function") { + const inserted = document.execCommand("insertText", false, text); + // The DOM is the authority on whether the command already inserted text. + // A false result after a mutation must not insert again at the new caret. + if (textarea.value !== previous) { + setText(textarea.value); + return; + } + if (inserted) return; + } + setText(previous.slice(0, start) + text + previous.slice(end)); requestAnimationFrame(() => { if (document.activeElement === textarea) { textarea.setSelectionRange(start + text.length, start + text.length); diff --git a/web/src/clipboard-paste-guard.ts b/web/src/clipboard-paste-guard.ts new file mode 100644 index 00000000..5c959f65 --- /dev/null +++ b/web/src/clipboard-paste-guard.ts @@ -0,0 +1,117 @@ +import { LONG_PASTE_THRESHOLD } from "./composer-pastes.ts"; +import type { ClipboardImport } from "./clipboard-import.ts"; +import type { QueryFile, QueryImg } from "./protocol.ts"; + +// A compatibility window for immediately repeated native paste deliveries, +// not a debounce of the draft. Explicit gestures always start a new action. +export const PASTE_REPLAY_MS = 120; +type Selection = { value: string; selectionStart: number; selectionEnd: number }; +type Batch = { images: QueryImg[]; files: QueryFile[]; errors: string[] }; +export interface PasteReceipt { + at: number; + epoch: number; + before: Selection; + after: Selection; + acceptText: boolean; +} + +const selection = (input: Selection): Selection => ({ + value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd, +}); +const sameSelection = (a: Selection, b: Selection) => a.value === b.value + && a.selectionStart === b.selectionStart && a.selectionEnd === b.selectionEnd; + +/** Per-composer receipts, never shared across sessions or persisted. The text + * receipt fences both a second paste event and a second native insertion. + * Attachment receipts compare imported bytes, not filenames or file sizes. */ +export class ClipboardPasteGuard { + private epoch = 0; + private text: { receipt: PasteReceipt; value: string } | null = null; + private attachments: { receipt: PasteReceipt; batch: Batch } | null = null; + private timer: ReturnType | null = null; + private readonly enabled: boolean; + private readonly now: () => number; + + constructor(enabled: boolean, now = () => performance.now()) { + this.enabled = enabled; + this.now = now; + } + + clear = (): void => { + this.epoch++; + this.text = null; + this.attachments = null; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + }; + + private recent(receipt: PasteReceipt, at = this.now()): boolean { + return this.enabled && receipt.epoch === this.epoch + && at >= receipt.at && at - receipt.at <= PASTE_REPLAY_MS; + } + + capture(input: Selection, clipboard: ClipboardImport): PasteReceipt { + const before = selection(input); + if (!this.enabled) return { + at: 0, epoch: this.epoch, before, after: before, acceptText: true, + }; + if (!clipboard.text && !clipboard.files.length && !clipboard.images.length + && !clipboard.errors.length) this.clear(); + const text = clipboard.text.replace(/\r\n?/g, "\n"); + const at = this.now(); + const prior = this.text; + const replay = !!text && !!prior && this.recent(prior.receipt, at) + && prior.value === text && sameSelection(before, prior.receipt.after); + const inline = clipboard.text.length <= LONG_PASTE_THRESHOLD; + const caret = before.selectionStart + text.length; + const receipt: PasteReceipt = { + at, epoch: this.epoch, before, acceptText: !replay, + after: !text || !inline || replay ? before : { + value: before.value.slice(0, before.selectionStart) + text + + before.value.slice(before.selectionEnd), + selectionStart: caret, selectionEnd: caret, + }, + }; + if (text && !replay) this.text = { receipt, value: text }; + // Do not retain clipboard/draft content beyond the compatibility window. + if (this.timer !== null) clearTimeout(this.timer); + this.timer = setTimeout(this.clear, PASTE_REPLAY_MS); + return receipt; + } + + blocksNativeInsert(input: Selection, type: string, data: string | null): boolean { + const prior = this.text; + if (!prior || !this.recent(prior.receipt) + || !sameSelection(input, prior.receipt.after)) return false; + if (type === "insertFromPaste") return data === null || data === prior.value; + // Keyboard suggestions may deliver their extra insertion as insertText. + // A key/pointer/composition boundary clears the receipt before real typing. + return type === "insertText" && data === prior.value; + } + + observeInput(input: Selection, type: string, data: string | null): void { + const prior = this.text; + if (prior && this.recent(prior.receipt) + && (type === "insertFromPaste" || (type === "insertText" && data === prior.value)) + && sameSelection(input, prior.receipt.after)) return; + this.clear(); + } + + acceptAttachments(receipt: PasteReceipt, batch: Batch): boolean { + // Partial imports still append their successful attachments. Compare those + // bytes even when other items failed; an explicit retry clears the receipt. + if (!this.recent(receipt) + || (!batch.images.length && !batch.files.length)) return true; + const prior = this.attachments; + if (prior && this.recent(prior.receipt, receipt.at) + && sameSelection(receipt.before, prior.receipt.after) + && batch.images.length === prior.batch.images.length + && batch.files.length === prior.batch.files.length + && batch.images.every((image, i) => image.media_type === prior.batch.images[i].media_type + && image.data === prior.batch.images[i].data) + && batch.files.every((file, i) => file.filename === prior.batch.files[i].filename + && file.data === prior.batch.files[i].data)) return false; + this.attachments = { receipt, batch }; + return true; + } +} diff --git a/web/src/components/BtwPanel.tsx b/web/src/components/BtwPanel.tsx index 6a08f780..aad75bfa 100644 --- a/web/src/components/BtwPanel.tsx +++ b/web/src/components/BtwPanel.tsx @@ -63,6 +63,8 @@ import { type ClipboardImport, } from "../clipboard-import"; import { useAttachmentDrop } from "../use-attachment-drop"; +import { useClipboardPasteGuard } from "../use-clipboard-paste-guard"; +import type { PasteReceipt } from "../clipboard-paste-guard"; import "./BtwPanel.css"; interface Props { @@ -135,6 +137,7 @@ export function BtwPanel(p: Props) { const importingRef = useRef(false); const noticeTimerRef = useRef(null); const taRef = useRef(null); + const pasteGuard = useClipboardPasteGuard(taRef, p.draftKey); const imeSubmitRef = useRef(new ImeSubmitGuard()); const buttonSendTimerRef = useRef(null); const input = draft.input; @@ -239,7 +242,7 @@ export function BtwPanel(p: Props) { }, duration); }; const onPickFiles = async ( - selected: FileList | File[] | null, clipboard?: ClipboardImport, + selected: FileList | File[] | null, clipboard?: ClipboardImport, paste?: PasteReceipt, ) => { if (attachmentsLocked) return; if (importingRef.current) { flash("附件正在导入,请稍候"); return; } @@ -255,6 +258,7 @@ export function BtwPanel(p: Props) { ]); const batch = await pickFiles( imported.files, images.length + files.length, attachmentBytes(images, files)); + if (paste && !pasteGuard.acceptAttachments(paste, batch)) return; const append = (current: ComposerDraft): ComposerDraft => ({ ...current, images: [...current.images, ...batch.images], @@ -277,15 +281,20 @@ export function BtwPanel(p: Props) { const onPaste = (event: ClipboardEvent) => { if (attachmentsLocked) return; const clipboard = readClipboardImport(event.clipboardData, images.length + files.length); + const paste = pasteGuard.capture(event.currentTarget, clipboard); const { text } = clipboard; const attachments = clipboard.files.length || clipboard.images.length || clipboard.errors.length; + if (!paste.acceptText) { + event.preventDefault(); + if (!attachments) return; + } if (!attachments && text.length <= LONG_PASTE_THRESHOLD) return; event.preventDefault(); - if (text.length > LONG_PASTE_THRESHOLD) { + if (paste.acceptText && text.length > LONG_PASTE_THRESHOLD) { const paste = makeComposerPaste(text, uuid()); updateDraft((current) => ({ ...current, pastes: [...current.pastes, paste] })); - } else if (text) insertClipboardText(event.currentTarget, text, setInput); - if (attachments) void onPickFiles(null, clipboard); + } else if (paste.acceptText && text) insertClipboardText(event.currentTarget, text, setInput); + if (attachments) void onPickFiles(null, clipboard, paste); }; const resetTaHeight = () => { if (taRef.current) taRef.current.style.height = "auto"; diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index c59efc14..56f19508 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -14,6 +14,7 @@ import { } from "react"; import { defaultRangeExtractor, + elementScroll, useVirtualizer, } from "@tanstack/react-virtual"; import type { @@ -397,6 +398,7 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud const controllerRef = useRef(null); if (!controllerRef.current) controllerRef.current = new ScrollFollowController(); const scrollCoordinatorRef = useRef(new ScrollCoordinator()); + const applyingScrollCommandRef = useRef(false); const [scrollPolicyEpoch, setScrollPolicyEpoch] = useState(0); const [scrollState, setScrollState] = useState(() => controllerRef.current!.snapshot()); @@ -727,6 +729,18 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud const virtualAnchorTo = keyedPrependActive && activeHistoryAnchor?.direction === "newer" ? "end" : "start"; + const canFollowLiveTail = useCallback(() => + activeScrollScopeRef.current === scrollScope + && renderedScrollScopeRef.current === scrollScope + && !browseMode + && !!controllerRef.current?.isFollowing() + && !userScrollIntentRef.current + && touchYRef.current === null + && !wheelGestureActiveRef.current + && textSelectionRef.current === null + && historyAnchorRef.current.current() === null + && !scrollCoordinatorRef.current.isInteractionLocked(), + [browseMode, scrollScope]); const virtualizer = useVirtualizer({ count: turns.length, getScrollElement: () => scrollRef.current, @@ -738,6 +752,16 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud // remains owned by ChatView's measured-tail observer below. anchorTo: virtualAnchorTo, followOnAppend: virtualScrollPolicy.followOnAppend, + scrollToFn: (offset, options, instance) => { + // scrollToEnd keeps reconciling its target as streamed rows or the IME + // resize. Changing followOnAppend does not cancel that pending operation. + // Check ownership at the actual DOM write, including delayed corrections; + // otherwise they can fight the reader's anchor or impersonate momentum. + // Explicit history/detail offsets and scope resets own their synchronous + // write even when output following is paused. + if (!applyingScrollCommandRef.current && !canFollowLiveTail()) return; + elementScroll(offset, options, instance); + }, scrollEndThreshold: 80, overscan: HISTORY_VIRTUAL_OVERSCAN, rangeExtractor: (range) => { @@ -904,10 +928,15 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud const el = scrollRef.current; const controller = controllerRef.current; if (!el || !controller) return; - if (command.kind === "bottom") { - virtualizer.scrollToEnd({ behavior: command.behavior }); - } else { - virtualizer.scrollToOffset(command.offset, { behavior: "auto" }); + applyingScrollCommandRef.current = true; + try { + if (command.kind === "bottom") { + virtualizer.scrollToEnd({ behavior: command.behavior }); + } else { + virtualizer.scrollToOffset(command.offset, { behavior: "auto" }); + } + } finally { + applyingScrollCommandRef.current = false; } lastScrollTopRef.current = el.scrollTop; syncScrollState(controller.recordProgrammaticScroll(readScrollMetrics(el))); @@ -915,23 +944,12 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud const maintainFollowedLiveTail = useCallback(() => { const el = scrollRef.current; - const controller = controllerRef.current; - if (!el || !controller - || activeScrollScopeRef.current !== scrollScope - || renderedScrollScopeRef.current !== scrollScope - || browseMode - || !controller.isFollowing() - || userScrollIntentRef.current - || touchYRef.current !== null - || wheelGestureActiveRef.current - || textSelectionRef.current !== null - || historyAnchorRef.current.current() !== null - || scrollCoordinatorRef.current.isInteractionLocked()) return; + if (!el || !canFollowLiveTail()) return; if (measureBottom(readScrollMetrics(el)).distance <= 0.5) return; applyScrollCommand( scrollCoordinatorRef.current.requestBottom("auto"), ); - }, [applyScrollCommand, browseMode, scrollScope]); + }, [applyScrollCommand, canFollowLiveTail]); useLayoutEffect(() => { const el = scrollRef.current; @@ -1746,7 +1764,8 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud useEffect(() => setZoom(null), [sid]); const settleUserScrollIntent = () => { - if (touchYRef.current !== null || wheelGestureActiveRef.current) { + if (touchYRef.current !== null || wheelGestureActiveRef.current + || scrollbarDragIntentRef.current?.scope === scrollScope) { if (userScrollIntentTimerRef.current !== null) { window.clearTimeout(userScrollIntentTimerRef.current); } @@ -1776,6 +1795,10 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud return; } const controller = controllerRef.current; + // A tap or a pull beyond the latest edge can leave follow enabled while + // suppressing a queued resize correction. Catch up only after the native + // gesture has settled; a history reader keeps their paused state. + if (controller?.isFollowing()) maintainFollowedLiveTail(); const point = captureHistoryBoundary(); const request = historyRequestRef.current; if (point && (!controller?.isFollowing() @@ -2250,9 +2273,11 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud ); setScrollPolicyEpoch((value) => value + 1); if (command) { - window.requestAnimationFrame(() => applyScrollCommand(command)); + window.requestAnimationFrame(() => { + if (canFollowLiveTail()) applyScrollCommand(command); + }); } - }, [applyScrollCommand]); + }, [applyScrollCommand, canFollowLiveTail]); const disposeDetailResources = useCallback(( transaction: DetailAnchorTransaction, @@ -2772,6 +2797,12 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud : t.detailEventCount ?? 0 : 0; const renderProcess = (segment?: ClaudeContinuation) => { + // Deferred counts describe the whole native turn. A continuation + // containing only an answer has no process disclosure of its own; + // borrowing the parent's count creates an empty "1 item" row. + if (segment && presentableProcessBlocks(segment.blocks, engine).length === 0) { + return null; + } const continuing = !!segment && segment === lastContinuation && enclosingTaskActive && !terminalProblem; const disclosureKey = segment @@ -2790,7 +2821,7 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud : lastContinuation && t.doneTs != null && lastContinuation.startedTs != null && t.doneTs > lastContinuation.startedTs ? undefined : t.doneTs} - deferredCount={segment ? (deferredProcessCount > 0 ? 1 : 0) : deferredProcessCount} + deferredCount={segment ? 0 : deferredProcessCount} detailLoading={t.detailLoading} detailError={processDetailError} externalPlanItemId={externalPlanItemId} diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 96941d19..a9969f60 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -31,6 +31,8 @@ import { const CommandSheet = lazy(() => import("./CommandSheet").then(m => ({ default: m.CommandSheet }))); import { attachmentBytes, snapshotAttachmentFiles } from "../img"; import { useAttachmentDrop } from "../use-attachment-drop"; +import { useClipboardPasteGuard } from "../use-clipboard-paste-guard"; +import type { PasteReceipt } from "../clipboard-paste-guard"; import { readClipboardImport, resolveClipboardImport, insertClipboardText, type ClipboardImport, @@ -201,6 +203,7 @@ export function Composer(p: Props) { const [importing, setImporting] = useState(false); const importingRef = useRef(false); const taRef = useRef(null); + const pasteGuard = useClipboardPasteGuard(taRef, p.draftKey); const imeSubmitRef = useRef(new ImeSubmitGuard()); const buttonSendTimerRef = useRef(null); const requestedSkillScopeRef = useRef(null); @@ -376,7 +379,7 @@ export function Composer(p: Props) { ]); const onPickFiles = async ( - fl: FileList | File[] | null, clipboard?: ClipboardImport, + fl: FileList | File[] | null, clipboard?: ClipboardImport, paste?: PasteReceipt, ) => { if (locked) return; if (importingRef.current) { flash("附件正在导入,请稍候"); return; } @@ -391,6 +394,7 @@ export function Composer(p: Props) { ]); const batch = await pickFiles( imported.files, images.length + files.length, attachmentBytes(images, files)); + if (paste && !pasteGuard.acceptAttachments(paste, batch)) return; if (draftKeyRef.current === targetDraftKey) { if (batch.images.length) { setImages((previous) => [...previous, ...batch.images]); @@ -421,19 +425,24 @@ export function Composer(p: Props) { // retained privately by the draft and represented only by an editable card. const onPaste = (e: ClipboardEvent) => { const clipboard = readClipboardImport(e.clipboardData, images.length + files.length); + const paste = pasteGuard.capture(e.currentTarget, clipboard); const { text } = clipboard; const attachments = clipboard.files.length || clipboard.images.length || clipboard.errors.length; + if (!paste.acceptText) { + e.preventDefault(); + if (!attachments) return; + } if (!attachments && text.length <= LONG_PASTE_THRESHOLD) return; e.preventDefault(); const textarea = e.currentTarget; - if (text.length > LONG_PASTE_THRESHOLD) { + if (paste.acceptText && text.length > LONG_PASTE_THRESHOLD) { const id = uuid(); updateDraft((current) => ({ ...current, pastes: [...current.pastes, makeComposerPaste(text, id)], })); - } else if (text) insertClipboardText(textarea, text, setInput); - if (attachments) void onPickFiles(null, clipboard); + } else if (paste.acceptText && text) insertClipboardText(textarea, text, setInput); + if (attachments) void onPickFiles(null, clipboard, paste); }; // Send prompt text to cc, honoring busy/queue/interrupt rules. diff --git a/web/src/components/NewChatView.tsx b/web/src/components/NewChatView.tsx index 8214e940..e835b7f2 100644 --- a/web/src/components/NewChatView.tsx +++ b/web/src/components/NewChatView.tsx @@ -16,6 +16,8 @@ import { } from "../clipboard-import"; import type { ClaudeProfileInfo, CodexPermissionMode, CodexProfileInfo, CodexServiceTier, CodexWebSearchMode, CollaborationModeName, PermissionProfileInfo, QueryImg, QueryFile, Space, WorkDashboard } from "../protocol"; import { ImeSubmitGuard } from "../ime-submit"; +import { useClipboardPasteGuard } from "../use-clipboard-paste-guard"; +import type { PasteReceipt } from "../clipboard-paste-guard"; import { PendingImageAttachments } from "./PendingImageAttachments"; import { CommandSheet } from "./CommandSheet"; import { CenteredSheet } from "./CenteredSheet"; @@ -184,6 +186,7 @@ export function NewChatView({ cwd, controlScopeKey, () => defaultExecutionControls(controlScopeKey)); const [permissionsOpen, setPermissionsOpen] = useState(false); const taRef = useRef(null); + const pasteGuard = useClipboardPasteGuard(taRef, `${controlScopeKey}:${cwd}:${engine}`); const imeSubmitRef = useRef(new ImeSubmitGuard()); const buttonSendTimerRef = useRef(null); @@ -299,7 +302,7 @@ export function NewChatView({ cwd, controlScopeKey, icon: candidate.ic, }))]; - const onPick = async (fl: FileList | File[] | null, clipboard?: ClipboardImport) => { + const onPick = async (fl: FileList | File[] | null, clipboard?: ClipboardImport, paste?: PasteReceipt) => { if (importingRef.current) return; importingRef.current = true; setImporting(true); @@ -311,6 +314,7 @@ export function NewChatView({ cwd, controlScopeKey, ]); const batch = await pickFiles( imported.files, images.length + files.length, attachmentBytes(images, files)); + if (paste && !pasteGuard.acceptAttachments(paste, batch)) return; if (batch.images.length) setImages((previous) => [...previous, ...batch.images]); if (batch.files.length) setFiles((previous) => [...previous, ...batch.files]); const errors = [...imported.errors, ...batch.errors]; @@ -325,15 +329,20 @@ export function NewChatView({ cwd, controlScopeKey, const onPaste = (e: ClipboardEvent) => { const clipboard = readClipboardImport(e.clipboardData, images.length + files.length); + const paste = pasteGuard.capture(e.currentTarget, clipboard); const pastedText = clipboard.text; const attachments = clipboard.files.length || clipboard.images.length || clipboard.errors.length; + if (!paste.acceptText) { + e.preventDefault(); + if (!attachments) return; + } if (!attachments && pastedText.length <= LONG_PASTE_THRESHOLD) return; e.preventDefault(); - if (pastedText.length > LONG_PASTE_THRESHOLD) { + if (paste.acceptText && pastedText.length > LONG_PASTE_THRESHOLD) { setPastes((current) => [...current, makeComposerPaste(pastedText, uuid())]); - } else if (pastedText) insertClipboardText(e.currentTarget, pastedText, setText); - if (attachments) void onPick(null, clipboard); + } else if (paste.acceptText && pastedText) insertClipboardText(e.currentTarget, pastedText, setText); + if (attachments) void onPick(null, clipboard, paste); }; const send = (value = taRef.current?.value ?? text) => { diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts index 056ad76c..6fa8eb21 100644 --- a/web/src/history-merge.ts +++ b/web/src/history-merge.ts @@ -593,6 +593,26 @@ function compactionTurnAliases(turn: Turn): Set { : [])); } +function manualCompactionKeys(turn: Turn): Set { + if (!/^\/compact(?:\s|$)/.test(turn.prompt)) return new Set(); + return new Set(turn.blocks.flatMap((block) => { + // The receipt survives summary pagination, which defers the process block. + // Its id is derived from the native boundary UUID on both live/history. + if (block.kind === "text" + && /^compact-result-[a-f0-9]{24}$/.test(block.message_id)) { + return [`receipt:${block.message_id}`]; + } + return block.kind === "process" && block.processKind === "compaction" + && block.status === "succeeded" && block.phase === "end" + ? [`boundary:${block.item_id}`] : []; + })); +} + +function sharesManualCompaction(history: Turn, live: Turn): boolean { + const keys = manualCompactionKeys(history); + return keys.size > 0 && [...manualCompactionKeys(live)].some(key => keys.has(key)); +} + function sharesCompactionTurnAlias(history: Turn, live: Turn): boolean { // Official Codex history keeps the visible user-message id as the row id and // exposes the enclosing native task as forkPointId. A live compaction marker @@ -621,6 +641,9 @@ function sharesCompactionTurnAlias(history: Turn, live: Turn): boolean { function sameTurnIdentity(history: Turn, live: Turn): boolean { if (sharesExactTurnAlias(history, live)) return true; + // Claude may not echo the manual command's native user UUID live. A proven + // completed boundary still identifies that exact execution, never its prose. + if (sharesManualCompaction(history, live)) return true; // Codex may emit contextCompaction before the clean user/message binding. // The process event then carries the only native identity available to the // optimistic row. Treat that one authoritative marker as a turn alias, but @@ -1423,6 +1446,7 @@ export function mergeInitialHistory( // compaction fallback can consume a row belonging to another live segment. reserveMatches((historyTurn, liveTurn) => historyTurn.id === liveTurn.id); reserveMatches(sharesExactTurnAlias); + reserveMatches(sharesManualCompaction); const itemOwners = indexedBlockOwners(history); // A steer and its predecessor share a native task. Reserve exact item @@ -1768,11 +1792,21 @@ export function mergeInitialHistory( const result: Turn[] = []; for (const { turn } of rows) { const duplicate = result.findIndex((row) => - sharesExactTurnAlias(row, turn)); + sharesExactTurnAlias(row, turn) || sharesManualCompaction(row, turn)); if (duplicate < 0) { result.push(turn); continue; } + if (sharesManualCompaction(result[duplicate], turn)) { + // Also heal a duplicate already saved by an older browser. Keep the + // canonical detail identity and first rendered row while retaining any + // loaded process detail from either copy. + const first = result[duplicate]; + const canonical = first.historyTurnId ? first : turn; + const other = canonical === first ? turn : first; + result[duplicate] = { ...mergeTurn(canonical, other), id: first.id }; + continue; + } // The old one-to-one matcher could leave both local ids until this final // exact-alias pass. Do not merely discard the later canonical native row: // merge it into the optimistic browser row so History blocks/detail survive. diff --git a/web/src/protocol.ts b/web/src/protocol.ts index a0f14017..1542a163 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -133,6 +133,7 @@ export interface StateEvent extends Base { phase?: "retrying" | "waiting" | null; detail?: string | null; msg_id?: string | null; + continuation?: boolean; } export interface Model extends Base { type: "model"; model: string } export interface Effort extends Base { type: "effort"; effort: string } @@ -719,7 +720,7 @@ export type ServerEvent = FilesListed | CodexContext | ProcessEvent | BackgroundProcessSync | TurnPlan | TurnDiff | TurnFileChanges | TurnBinding | TurnUsage | TurnEnd | ErrorMsg | WrapperDisconnected | WrapperReconnected | Hello; -export const PROTOCOL_VERSION = 71; +export const PROTOCOL_VERSION = 72; export const MIN_AUTO_COMPACT_TOKENS = 100_000; export const MAX_AUTO_COMPACT_TOKENS = 1_000_000; diff --git a/web/src/reducer.ts b/web/src/reducer.ts index 10546bf1..e7233b58 100644 --- a/web/src/reducer.ts +++ b/web/src/reducer.ts @@ -5257,15 +5257,18 @@ function reduceEvent( rt.mirroredRunning = false; const turns = cloneTurns(rt.turns); const turn = e.msg_id - ? turns.find((candidate) => candidate.id === e.msg_id) + ? turns.find((candidate) => turnHasIdentityAlias(candidate, e.msg_id)) : turns.at(-1); // Native commands can start without a browser optimistic query or a // user UUID echo. A sequenced, explicitly named owner gives // their existing working indicator the same exact identity contract. // The idle boundary below clears it in this same state transition. - if (boundCompletedTurns && e.msg_id - && !turn?.done && seq === rt.lastLiveSeq) { - rt.liveOwner = { turnId: e.msg_id, seq }; + // A completed human row can own a new autonomous Claude continuation. + // Its completion receipt stays intact; the explicit native owner alone + // drives current activity until the next idle boundary. + if (boundCompletedTurns && e.msg_id && seq === rt.lastLiveSeq + && (!turn?.done || e.continuation === true)) { + rt.liveOwner = { turnId: turn?.id ?? e.msg_id, seq }; } if (e.detail && turn && !turn.done) turn.progress = e.detail; else if (turn && (Object.hasOwn(e, "detail") || e.state !== "running")) { diff --git a/web/src/use-clipboard-paste-guard.ts b/web/src/use-clipboard-paste-guard.ts new file mode 100644 index 00000000..0e90fde8 --- /dev/null +++ b/web/src/use-clipboard-paste-guard.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, type RefObject } from "react"; +import { ClipboardPasteGuard } from "./clipboard-paste-guard"; + +function isIOS(): boolean { + return typeof navigator !== "undefined" && (/iPad|iPhone|iPod/.test(navigator.userAgent) + || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)); +} + +export function useClipboardPasteGuard( + input: RefObject, scope: string, +): ClipboardPasteGuard { + const guard = useRef(null); + guard.current ??= new ClipboardPasteGuard(isIOS()); + const previousScope = useRef(scope); + if (previousScope.current !== scope) { + previousScope.current = scope; + guard.current.clear(); + } + useEffect(() => { + if (!isIOS()) return; + const current = guard.current!; + const beforeInput = (event: Event) => { + if (event.target !== input.current) return; + const edit = event as InputEvent; + if (edit.isComposing) { current.clear(); return; } + if (edit.cancelable && current.blocksNativeInsert(input.current!, edit.inputType, edit.data)) { + edit.preventDefault(); + } + }; + const changed = (event: Event) => { + if (event.target !== input.current) return; + const edit = event as InputEvent; + current.observeInput(input.current!, edit.inputType, edit.data); + }; + // Listen natively: React's onBeforeInput abstraction does not expose all + // WebKit InputEvent paths. Delegation also handles a replaced textarea ref. + document.addEventListener("beforeinput", beforeInput, true); + document.addEventListener("input", changed, true); + const focusOut = (event: Event) => { + // New-chat temporarily disables its textarea during attachment import. + // That programmatic blur is not a second user action. + if (event.target === input.current && input.current?.disabled) return; + current.clear(); + }; + const boundaries = ["pointerdown", "touchstart", "keydown", "compositionstart", "copy", "cut"] as const; + for (const name of boundaries) document.addEventListener(name, current.clear, true); + document.addEventListener("focusout", focusOut, true); + window.addEventListener("blur", current.clear); + return () => { + document.removeEventListener("beforeinput", beforeInput, true); + document.removeEventListener("input", changed, true); + for (const name of boundaries) document.removeEventListener(name, current.clear, true); + document.removeEventListener("focusout", focusOut, true); + window.removeEventListener("blur", current.clear); + current.clear(); + }; + }, [input]); + return guard.current; +} diff --git a/web/tests/auto-compact.test.ts b/web/tests/auto-compact.test.ts index f24711ab..5327edb0 100644 --- a/web/tests/auto-compact.test.ts +++ b/web/tests/auto-compact.test.ts @@ -13,6 +13,48 @@ import { import { clientSlashesFor } from "../src/data.ts"; import { PROTOCOL_VERSION, type ServerEvent } from "../src/protocol.ts"; import { RelayWs } from "../src/ws.ts"; +import { historyContainsTurn, mergeInitialHistory } from "../src/history-merge.ts"; +import type { Turn } from "../src/domain/conversation.ts"; + +// Claude writes the native /compact user UUID after the boundary, while the +// live command can finish without echoing that UUID. Both projections still +// share the exact boundary-derived receipt; identical prose is not identity. +const compactReceipt = "compact-result-0123456789abcdef01234567"; +const manualLive: Turn = { + id: "compact-live", prompt: "/compact", done: true, ts: 1000, doneTs: 126960, + blocks: [{ kind: "process", processKind: "compaction", item_id: "native-boundary", + turn_id: "compact-live", phase: "end", status: "succeeded", done: true, title: "压缩上下文" }, + { kind: "text", message_id: compactReceipt, channel: "final", done: true, + text: "上下文已压缩,可以继续当前会话。" }], +}; +const manualHistory: Turn = { + ...manualLive, id: "native-command", blocks: manualLive.blocks.slice(1), + historyTurnId: "native-command", detailEventCount: 1, +}; +assert.equal(historyContainsTurn([manualHistory], manualLive), true, + "the native compact receipt binds a live command even without a user UUID echo"); +const compactMerged = mergeInitialHistory([manualHistory], [manualLive]); +assert.equal(compactMerged.length, 1); +assert.equal(compactMerged[0].id, "compact-live"); +assert.equal(compactMerged[0].historyTurnId, "native-command"); +assert.equal(compactMerged[0].blocks.filter(b => b.kind === "text").length, 1); +assert.equal(mergeInitialHistory([manualHistory], [manualHistory, manualLive]).length, 1, + "already cached canonical + live duplicates heal on the next history merge"); +assert.equal(mergeInitialHistory([], [manualHistory, manualLive]).length, 1, + "restoring a cached duplicate also uses exact receipt identity"); +const secondCompact: Turn = { ...manualLive, id: "compact-second", blocks: [ + { kind: "text", message_id: "compact-result-fedcba9876543210fedcba98", + channel: "final", done: true, text: "上下文已压缩,可以继续当前会话。" }, +] }; +assert.equal(mergeInitialHistory([manualHistory], [manualLive, secondCompact]).length, 2, + "two real compactions remain distinct even with equal prompts, text and clocks"); +assert.equal(mergeInitialHistory([manualHistory], [{ ...manualLive, prompt: "explain compact" }]).length, 2, + "only a manual command can use the manual receipt alias"); +assert.equal(mergeInitialHistory([manualHistory], [{ ...manualLive, blocks: [] }]).length, 2, + "an unproven pending command cannot be guessed from its prompt or timestamp"); +assert.equal(mergeInitialHistory([{ ...manualHistory, blocks: manualLive.blocks.slice(0, 1) }], + [{ ...manualLive, blocks: manualLive.blocks.slice(0, 1) }]).length, 1, + "full history can prove identity from the native completed boundary alone"); assert.equal(clientSlashesFor("claude").has("autocompact"), true); diff --git a/web/tests/claude-background-process.test.ts b/web/tests/claude-background-process.test.ts index b591237b..2406d5da 100644 --- a/web/tests/claude-background-process.test.ts +++ b/web/tests/claude-background-process.test.ts @@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { createServer } from "vite"; import type { Block } from "../src/domain/conversation.ts"; +import { isComposerBusy } from "../src/composer-submit.ts"; import { MAX_BACKGROUND_PROCESS_ITEMS, PROTOCOL_VERSION, @@ -197,6 +198,53 @@ try { "a task-completion follow-up keeps its source-time narrative boundary", ); + for (const detailState of [ + { detailLoaded: false }, + { detailLoaded: false, detailLoading: true }, + { detailLoaded: false, detailError: "detail unavailable" }, + { detailLoaded: true, detailHasMore: true, detailOldestCursor: "older" }, + ]) { + const finalOnlyMarkup = renderToStaticMarkup(createElement(ChatView, { + sid: "final-only-continuation", engine: "claude", + turns: [{ + id: "native-turn", prompt: "run", done: true, + ts: 1_000, doneTs: 21_000, processDetailState: "present", + detailEventCount: 28, ...detailState, + blocks: [ + { kind: "text", message_id: "original", channel: "final", + text: "Original response.", done: true, startedTs: 2_000 }, + { kind: "text", message_id: "continued-final", channel: "final", + text: "Completed follow-up.", done: true, background: true, + startedTs: 20_000, doneTs: 21_000 }, + ], + }], + })); + const continuation = finalOnlyMarkup.slice(finalOnlyMarkup.indexOf("Claude 继续处理")); + assert.match(continuation, /Completed follow-up\./); + assert.doesNotMatch(continuation, /已处理|正在处理|process-timeline/, + "parent detail counts/loading/errors must not create an empty continuation disclosure"); + assert.equal(finalOnlyMarkup.split("Completed follow-up.").length - 1, 1, + "the real answer remains visible exactly once"); + } + + const processFollowupMarkup = renderToStaticMarkup(createElement(ChatView, { + sid: "followup-with-process", engine: "claude", + turns: [{ id: "native-turn", prompt: "run", done: true, + processDetailState: "present", detailEventCount: 28, + blocks: [ + { kind: "text", message_id: "original", channel: "final", + text: "Original response.", done: true }, + { kind: "text", message_id: "continued-thought", channel: "thinking", + text: "Reviewing the background result.", done: true, background: true }, + { kind: "text", message_id: "continued-final", channel: "final", + text: "Completed follow-up.", done: true, background: true }, + ], + }], + })); + assert.match(processFollowupMarkup, + /Claude 继续处理[\s\S]*已处理[\s\S]*1 项[\s\S]*Completed follow-up\./, + "a continuation with an actual process item retains its own disclosure"); + const concurrentFollowupMarkup = renderToStaticMarkup(createElement(ChatView, { sid: "concurrent-followup-history", engine: "claude", @@ -247,11 +295,18 @@ try { let continuationState = { ...initialState, focusedSid: sid, - runtimes: { [sid]: { ...createRuntime(), turns: [{ id: "settled-parent", prompt: "run", done: true, blocks: [] }] } }, + runtimes: { [sid]: { ...createRuntime(), controlGeneration: "generation-1", + turns: [{ id: "settled-parent", prompt: "run", done: true, blocks: [] }] } }, }; const send = (body: Record) => { continuationState = reduce(continuationState, { type: "event", event: event({ sid, ...body }) }); }; + send({ type: "background_process_sync", generation: "generation-1", items: [{ + item_id: "other-child", kind: "task", status: "running", title: "Background check", + started_at: 12, updated_at: 13, + }] }); + assert.equal(isComposerBusy(continuationState.runtimes[sid].state), false, + "a background child alone must leave the composer available for a normal query"); send({ type: "state", state: "running" }); send({ type: "process", turn_id: "settled-parent", item_id: "child", kind: "agent", phase: "end", status: "succeeded", title: "Child done", background: true }); @@ -262,9 +317,22 @@ try { assert.equal(continuationState.runtimes[sid].turns[0].done, true, "the prior native completion receipt remains settled"); send({ type: "state", state: "idle" }); + assert.equal(isComposerBusy(continuationState.runtimes[sid].state), false, + "the real main terminal restores normal submission without a hard refresh"); + assert.equal(continuationState.runtimes[sid].backgroundProcesses[0]?.status, "running", + "settling the main response keeps the still-running child visible"); send({ type: "delta", turn_id: "settled-parent", message_id: "continuation", channel: "thinking", text: "Late replay", background: true }); assert.equal(continuationState.runtimes[sid].liveOwner, null, "replay into an idle session cannot revive the spark"); + send({ type: "state", state: "running", msg_id: "settled-parent", continuation: true, seq: 20 }); + assert.equal(isComposerBusy(continuationState.runtimes[sid].state), true, + "a later native continuation, rather than the child job, owns steering mode"); + assert.equal(continuationState.runtimes[sid].liveOwner?.turnId, "settled-parent", + "the native continuation owns its spark before any text or tool output"); + assert.equal(continuationState.runtimes[sid].turns[0].done, true); + send({ type: "state", state: "idle", seq: 21 }); + assert.equal(continuationState.runtimes[sid].liveOwner, null); + assert.equal(isComposerBusy(continuationState.runtimes[sid].state), false); } finally { await harness.close(); } diff --git a/web/tests/clipboard-paste-guard.test.ts b/web/tests/clipboard-paste-guard.test.ts new file mode 100644 index 00000000..549f49bb --- /dev/null +++ b/web/tests/clipboard-paste-guard.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { ClipboardPasteGuard, PASTE_REPLAY_MS } from "../src/clipboard-paste-guard.ts"; +import type { ClipboardImport } from "../src/clipboard-import.ts"; +import { LONG_PASTE_THRESHOLD } from "../src/composer-pastes.ts"; + +let now = 0; +const guard = new ClipboardPasteGuard(true, () => now); +const empty = { value: "", selectionStart: 0, selectionEnd: 0 }; +const clipboard = (text: string): ClipboardImport => ({ + text, files: [], images: [], errors: [], remainingAttachments: 8, +}); +const imageClipboard = (): ClipboardImport => ({ + ...clipboard(""), files: [new File(["source bytes"], "image.png", { type: "image/png" })], +}); +try { + const before = { value: "before REPLACE after", selectionStart: 7, selectionEnd: 14 }; + const first = guard.capture(before, clipboard("新文字")); + assert.equal(first.acceptText, true); + assert.equal(guard.blocksNativeInsert(before, "insertFromPaste", "新文字"), false); + guard.observeInput(first.after, "insertFromPaste", "新文字"); + now += 10; + assert.equal(guard.capture(first.after, clipboard("新文字")).acceptText, false, + "a second delivery must not insert again at the advanced caret"); + for (const type of ["insertFromPaste", "insertText"]) { + assert.equal(guard.blocksNativeInsert(first.after, type, "新文字"), true); + } + assert.equal(guard.blocksNativeInsert(first.after, "insertText", "other"), false); + assert.equal(guard.blocksNativeInsert(first.after, "deleteContentBackward", null), false); + assert.equal(guard.capture({ ...first.after, selectionStart: 0, selectionEnd: 0 }, + clipboard("新文字")).acceptText, true, "a different selection is a different operation"); + + guard.clear(); + const one = guard.capture(empty, clipboard("repeat")); + guard.clear(); // A key, touch, pointer, composition, focus or session boundary. + assert.equal(guard.capture(one.after, clipboard("repeat")).acceptText, true, + "two explicit actions may paste identical content even in the same millisecond"); + now += PASTE_REPLAY_MS + 1; + assert.equal(guard.capture(one.after, clipboard("repeat")).acceptText, true); + + guard.clear(); + const long = clipboard("long ".repeat(LONG_PASTE_THRESHOLD)); + assert.equal(guard.capture(empty, long).acceptText, true); + assert.equal(guard.capture(empty, long).acceptText, false, "long text cards are also single imports"); + guard.clear(); + assert.equal(guard.capture(empty, long).acceptText, true); + + guard.clear(); + const batch = { images: [{ media_type: "image/png" as const, data: "first pixels" }], + files: [{ filename: "same.txt", data: "first bytes" }], errors: [] }; + const imagePaste = guard.capture(empty, imageClipboard()); + assert.equal(guard.acceptAttachments(imagePaste, batch), true); + now += 5; + const imageReplay = guard.capture(empty, imageClipboard()); + assert.equal(guard.acceptAttachments(imageReplay, structuredClone(batch)), false); + assert.equal(guard.acceptAttachments(imageReplay, { + ...batch, files: [{ filename: "same.txt", data: "other bytes" }], + }), true, "a matching filename is not proof of duplicate bytes"); + assert.equal(guard.acceptAttachments(imageReplay, { + ...batch, images: [{ media_type: "image/png", data: "other pixels" }], + }), true); + guard.clear(); + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), batch), true); + + guard.clear(); + const failed = guard.capture(empty, imageClipboard()); + const failedBatch = { images: [], files: [], errors: ["retry"] }; + assert.equal(guard.acceptAttachments(failed, failedBatch), true); + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), batch), true, + "an entirely failed import must not block retrying its attachments"); + + for (const successful of [batch, { ...batch, images: [] }, { ...batch, files: [] }]) { + guard.clear(); + const partial = { ...successful, errors: ["broken.png 图片格式无法识别", "large.txt 超过 6 MiB"] }; + const first = guard.capture(empty, imageClipboard()); + now += 10; // Complete the first asynchronous import before its native replay. + assert.equal(guard.acceptAttachments(first, partial), true); + now += 5; + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), structuredClone(partial)), + false, "successful attachments in a partial import must not be appended twice"); + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), successful), + false, "replay deduplication compares successful bytes independently of import errors"); + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), failedBatch), true); + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), partial), + false, "an entirely failed replay must not erase the last successful receipt"); + guard.clear(); // A later explicit paste may deliberately repeat the same attachments. + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), partial), true); + now += PASTE_REPLAY_MS + 1; + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), partial), true, + "partial imports remain repeatable outside the native replay window"); + } + + guard.clear(); + const pending = guard.capture(empty, imageClipboard()); + guard.clear(); // Change session while the first import is still resolving. + assert.equal(guard.acceptAttachments(pending, batch), true); + assert.equal(guard.acceptAttachments(guard.capture(empty, imageClipboard()), batch), true, + "an old import may finish but must not seed another draft's receipt"); + + const desktop = new ClipboardPasteGuard(false, () => now); + assert.equal(desktop.capture(empty, long).acceptText, true); + assert.equal(desktop.capture(empty, long).acceptText, true); + const receipt = desktop.capture(empty, imageClipboard()); + assert.equal(desktop.acceptAttachments(receipt, batch), true); + assert.equal(desktop.acceptAttachments(receipt, batch), true); + desktop.clear(); +} finally { guard.clear(); } +console.log("clipboard paste guard tests passed"); diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index 8edb77c3..f288b273 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -1101,6 +1101,7 @@ function HistoryConversationBrowserFixture() { const streamingMath = params.has("streaming-math"); const composerAttachment = params.has("composer-attachment"); const composerResize = params.has("composer-resize"); + const mobileScroll = params.has("mobile-scroll"); const quotaComposer = params.has("quota-composer"); const queuedQueryFixture = params.has("queued-query-editor"); const migrationPickerFixture = params.has("migration-picker") @@ -1733,7 +1734,10 @@ function HistoryConversationBrowserFixture() { }; return ( -
+
{loads} {newerLoads} @@ -2114,7 +2118,7 @@ export function HistoryBrowserFixture() { engine={params.get("engine") === "claude" ? "claude" : "codex"} />; } - if (params.has("newchat-controls")) { + if (params.has("newchat-controls") || params.has("mobile-scroll")) { return ; } return ; diff --git a/web/tests/history-browser.spec.ts b/web/tests/history-browser.spec.ts index 15aff2ac..7eb18dc2 100644 --- a/web/tests/history-browser.spec.ts +++ b/web/tests/history-browser.spec.ts @@ -1359,54 +1359,62 @@ test("active process text shimmers and readable tool output settles without repl test("Claude resumes a separate live process after an idle background task", async ({ page }, info) => { const relay = await mockRightPanelRelay(page, { engine: "claude", historyReply: () => null }); + let seq = 0; + const emit = (message: PanelRelayEvent) => relay.emit({ ...message, seq: ++seq }); await page.goto("/"); await expect.poll(() => relay.commands.some((c) => c.type === "get_history")).toBe(true); const sid = "layout-parent"; const turnId = "background-parent"; - relay.emit({ type: "user_msg", sid, msg_id: turnId, prompt: "检查两个任务" }); - relay.emit({ type: "turn_binding", sid, msg_id: turnId, turn_id: turnId }); - relay.emit({ type: "state", sid, state: "running" }); - relay.emit({ type: "process", sid, turn_id: turnId, item_id: "agent-a", kind: "agent", + emit({ type: "user_msg", sid, msg_id: turnId, prompt: "检查两个任务" }); + emit({ type: "turn_binding", sid, msg_id: turnId, turn_id: turnId }); + emit({ type: "state", sid, state: "running" }); + emit({ type: "process", sid, turn_id: turnId, item_id: "agent-a", kind: "agent", phase: "start", status: "running", title: "检查结构", background: true }); - relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "interim", channel: "final" }); - relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "interim", channel: "final", text: "中间结果先给你,两路探索还在跑。" }); - relay.emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "interim", channel: "final" }); - relay.emit({ type: "turn_end", sid, turn_id: turnId, result: { subtype: "success", duration_ms: 3000, is_error: false } }); - relay.emit({ type: "state", sid, state: "idle" }); + emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "interim", channel: "final" }); + emit({ type: "delta", sid, turn_id: turnId, message_id: "interim", channel: "final", text: "中间结果先给你,两路探索还在跑。" }); + emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "interim", channel: "final" }); + emit({ type: "turn_end", sid, turn_id: turnId, result: { subtype: "success", duration_ms: 3000, is_error: false } }); + emit({ type: "state", sid, state: "idle" }); const turn = page.locator(`.turn[data-turn-id="${turnId}"]`); await expect(turn.locator(".turn-working")).toHaveCount(0); await expect(turn.locator(".turn-process-head").first()).toHaveAttribute("aria-expanded", "false"); - relay.emit({ type: "process", sid, turn_id: turnId, item_id: "agent-a", kind: "agent", + emit({ type: "process", sid, turn_id: turnId, item_id: "agent-a", kind: "agent", phase: "end", status: "succeeded", title: "检查结构", summary: "Raw agent report must stay in details", background: true }); await expect(turn.locator(".turn-working")).toHaveCount(0); await expect(turn.locator(".background-followup")).toHaveCount(0); - relay.emit({ type: "state", sid, state: "running" }); - relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", background: true }); - relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", text: "Fixture continuation reasoning", background: true }); + emit({ type: "state", sid, state: "running", msg_id: turnId, continuation: true }); + await expect(turn.locator(".turn-working")).toBeVisible(); + emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", background: true }); + emit({ type: "delta", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", text: "Fixture continuation reasoning", background: true }); const continuation = turn.locator(".background-followup").first(); await expect(turn.locator(".turn-working")).toBeVisible(); await expect(continuation.locator(".turn-process-head")).toHaveAttribute("aria-expanded", "true"); await expect(continuation.locator(".turn-process-label")).toHaveClass(/is-active/); + await expect(continuation.locator(".turn-process-label")).toContainText("正在处理"); + const workingAnimation = turn.locator(".turn-working").locator("svg"); + const initialFrame = await workingAnimation.innerHTML(); + await expect.poll(() => workingAnimation.innerHTML()).not.toBe(initialFrame); await expect(continuation.locator(".process-reasoning")).toBeVisible(); await expect(turn.locator(".turn-process-head").first()).toHaveAttribute("aria-expanded", "false"); await expect(continuation).not.toContainText("Raw agent report"); - relay.emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", background: true }); - relay.emit({ type: "tool_use", sid, turn_id: turnId, message_id: "follow-tool", tool_use_id: "verify", tool: "Bash", category: "command", input: { command: "verify camera" }, background: true }); + emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", background: true }); + emit({ type: "tool_use", sid, turn_id: turnId, message_id: "follow-tool", tool_use_id: "verify", tool: "Bash", category: "command", input: { command: "verify camera" }, background: true }); await expect(continuation.locator(".tool-group-label")).toContainText("正在调用 1 个工具"); await page.screenshot({ path: info.outputPath("claude-live-continuation.png") }); - relay.emit({ type: "tool_result", sid, turn_id: turnId, tool_use_id: "verify", content: "Verified", is_error: false, background: true }); - relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", background: true }); - relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", text: "第一路检查完成。", background: true }); - relay.emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", background: true }); - relay.emit({ type: "state", sid, state: "idle" }); + emit({ type: "tool_result", sid, turn_id: turnId, tool_use_id: "verify", content: "Verified", is_error: false, background: true }); + await expect(continuation.locator(".turn-process-label")).toContainText("正在处理"); + emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", background: true }); + emit({ type: "delta", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", text: "第一路检查完成。", background: true }); + emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", background: true }); + emit({ type: "state", sid, state: "idle" }); await expect(turn.locator(".turn-working")).toHaveCount(0); await expect(continuation.locator(".turn-process-head")).toHaveAttribute("aria-expanded", "false"); await expect(continuation).toContainText("第一路检查完成。"); - relay.emit({ type: "state", sid, state: "running" }); - relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "second-thinking", channel: "thinking", background: true }); - relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "second-thinking", channel: "thinking", text: "Fixture second continuation", background: true }); + emit({ type: "state", sid, state: "running", msg_id: turnId, continuation: true }); + emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "second-thinking", channel: "thinking", background: true }); + emit({ type: "delta", sid, turn_id: turnId, message_id: "second-thinking", channel: "thinking", text: "Fixture second continuation", background: true }); await expect(turn.locator(".background-followup")).toHaveCount(2); await expect(turn.locator(".background-followup").last().locator(".turn-process-head")).toHaveAttribute("aria-expanded", "true"); await expect(continuation.locator(".turn-process-head")).toHaveAttribute("aria-expanded", "false"); @@ -8611,6 +8619,169 @@ test("iOS pointercancel releases process interactions and output following", asy )).toBeLessThan(2); }); +// WebKit desktop cannot summon the iOS keyboard or drive native momentum. +// Exercise the same viewport events and touch ownership, and record every DOM +// scroll write: a later anchor repair can hide the fault from endpoint checks. +for (const [keyboardOpen, pullAtBottom] of [[false, false], [true, false], [true, true]]) { + test(`live append follows yield to a Claude touch drag${keyboardOpen ? " with the keyboard open" : ""}${pullAtBottom ? " at the bottom edge" : ""}`, async ({ page }) => { + await page.goto("/tests/history-browser.html?interactive-timeline=1&engine=claude" + + (keyboardOpen ? "&mobile-scroll=1"a-composer=1" : "")); + await expect(page.locator('[data-turn-id="streaming"] .turn-working')).toBeVisible(); + for (let index = 0; index < 8; index += 1) { + await page.getByTestId("grow-stream").evaluate((node) => node.click()); + await page.waitForTimeout(30); + } + await waitForScrollIdle(page); + if (keyboardOpen) { + await page.evaluate(() => { + document.querySelector("textarea")!.focus(); + Object.defineProperties(window.visualViewport!, { + height: { configurable: true, value: 420 }, + offsetTop: { configurable: true, value: 0 }, + }); + window.visualViewport!.dispatchEvent(new Event("resize")); + }); + await expect(page.locator("html")).toHaveAttribute("data-short-viewport", "ime"); + await waitForScrollIdle(page); + } + const result = await page.evaluate(async ({ keyboardOpen, pullAtBottom }) => { + const thread = document.querySelector(".thread")!; + const grow = document.querySelector('[data-testid="grow-stream"]')!; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const touch = (type: string, clientY: number) => { + const point = { identifier: 1, target: thread, clientX: 120, clientY }; + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + touches: { value: type === "touchend" ? [] : [point] }, + targetTouches: { value: type === "touchend" ? [] : [point] }, + changedTouches: { value: [point] }, + }); + thread.dispatchEvent(event); + }; + const writes: { from: number; to: number; kind: string }[] = []; + let readerOwnsScroll = false; + let simulateDrag = false; + let intended = 0; + let interrupt: (() => void) | null = null; + const nativeScroll = thread.scrollTo; + const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!; + Object.defineProperty(thread, "scrollTop", { + configurable: true, + get() { return scrollTop.get!.call(this); }, + set(value: number) { + const from = scrollTop.get!.call(this) as number; + scrollTop.set!.call(this, value); + if (readerOwnsScroll && !simulateDrag && Math.abs(value - from) > 1) { + writes.push({ from, to: value, kind: "scrollTop" }); + } + }, + }); + thread.scrollTo = function (...args: Parameters) { + const from = thread.scrollTop; + nativeScroll.apply(this, args); + if (readerOwnsScroll && Math.abs(thread.scrollTop - from) > 1) { + writes.push({ from, to: thread.scrollTop, kind: "scrollTo" }); + } + if (interrupt) { + const callback = interrupt; + interrupt = null; + // Interrupt an actual pending virtualizer bottom operation before + // its next reconciliation frame, as a finger can do on iOS. + queueMicrotask(callback); + } + }; + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("no followed-tail write")), 2_000); + interrupt = () => { + readerOwnsScroll = true; + touch("touchstart", 120); + touch("touchmove", pullAtBottom ? 60 : 300); + if (!pullAtBottom) { + simulateDrag = true; + thread.scrollTop -= 180; + simulateDrag = false; + } + intended = thread.scrollTop; + clearTimeout(timeout); + resolve(); + }; + if (keyboardOpen) { + Object.defineProperty(window.visualViewport!, "height", { + configurable: true, value: 400, + }); + window.visualViewport!.dispatchEvent(new Event("resize")); + } else grow.click(); + }); + await sleep(60); + if (!pullAtBottom) touch("touchend", 300); + for (let index = 0; index < 8; index += 1) { + await sleep(80); + grow.click(); + if (keyboardOpen) { + Object.defineProperty(window.visualViewport!, "height", { + configurable: true, value: index % 2 ? 400 : 410, + }); + window.visualViewport!.dispatchEvent(new Event("resize")); + } + } + await sleep(350); + if (pullAtBottom) { + readerOwnsScroll = false; + touch("touchend", 60); + } + return { + writes, intended, finalTop: thread.scrollTop, + editorFocused: document.activeElement?.tagName === "TEXTAREA", + paused: !!document.querySelector(".scroll-bottom-btn"), + }; + } finally { + thread.scrollTo = nativeScroll; + Reflect.deleteProperty(thread, "scrollTop"); + } + }, { keyboardOpen, pullAtBottom }); + expect(result.writes, JSON.stringify(result.writes)).toEqual([]); + if (!pullAtBottom) { + expect(result.paused, JSON.stringify(result)).toBe(true); + expect(Math.abs(result.finalTop - result.intended)).toBeLessThanOrEqual(1); + await page.locator(".scroll-bottom-btn").click(); + } + if (keyboardOpen) expect(result.editorFocused).toBe(true); + await expect.poll(() => page.locator(".thread").evaluate((node) => + Math.abs(node.scrollHeight - node.scrollTop - node.clientHeight), + )).toBeLessThanOrEqual(2); + }); +} + +test("live append follows yield to a held desktop scrollbar", async ({ page, isMobile }) => { + test.skip(isMobile, "desktop scrollbar ownership"); + await page.goto("/tests/history-browser.html?interactive-timeline=1&engine=claude"); + for (let index = 0; index < 8; index += 1) { + await page.getByTestId("grow-stream").evaluate((node) => node.click()); + await page.waitForTimeout(30); + } + await waitForScrollIdle(page); + const result = await page.locator(".thread").evaluate(async (node) => { + const rect = node.getBoundingClientRect(); + node.dispatchEvent(new PointerEvent("pointerdown", { + bubbles: true, pointerId: 41, pointerType: "mouse", isPrimary: true, + button: 0, clientX: rect.right - 2, clientY: rect.bottom - 30, + })); + await new Promise((resolve) => setTimeout(resolve, 350)); + node.scrollTop -= 180; + const intended = node.scrollTop; + await new Promise((resolve) => setTimeout(resolve, 50)); + document.querySelector('[data-testid="grow-stream"]')!.click(); + await new Promise((resolve) => setTimeout(resolve, 400)); + document.dispatchEvent(new PointerEvent("pointerup", { + bubbles: true, pointerId: 41, pointerType: "mouse", isPrimary: true, + })); + return { intended, actual: node.scrollTop }; + }); + expect(Math.abs(result.actual - result.intended)).toBeLessThanOrEqual(1); + await expect(page.locator(".scroll-bottom-btn")).toBeVisible(); +}); + test("live append follows at the bottom but not while reading history", async ({ page, }, testInfo) => { @@ -9009,8 +9180,113 @@ for (const [target, source] of [ }); } +for (const target of ["long paste", "new-chat controls", "side chat scope"]) { + for (const replay of ["paste", "insertFromPaste", "insertText"]) { + test(`${target} iOS keyboard paste ignores an immediate ${replay} replay`, async ({ page }) => { + await page.addInitScript(() => Object.defineProperty(navigator, "userAgent", { + configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X)", + })); + if (target === "side chat scope") { + await mockRightPanelRelay(page, { visible: true }); + await page.goto("/"); + } else await page.goto(target === "new-chat controls" + ? "/tests/history-browser.html?newchat-controls=1" + : "/tests/history-browser.html?codex-live-burst=1&composer-live=1&composer-paste=1"); + const input = target === "side chat scope" + ? page.locator(".btw-panel textarea") : page.locator("textarea").first(); + await input.fill("before REPLACE after"); + const result = await input.evaluate((node, replay) => { + const ta = node as HTMLTextAreaElement; + ta.focus(); ta.setSelectionRange(7, 14); + const pasted = "一份测试文字"; + const deliver = () => { + const data = new DataTransfer(); + data.setData("text/plain", pasted); + const accepted = ta.dispatchEvent(new ClipboardEvent("paste", { + bubbles: true, cancelable: true, clipboardData: data, + })); + // Synthetic paste has no default action. Use the real editor to + // simulate that default, including its input event and undo entry. + if (accepted) document.execCommand("insertText", false, pasted); + return accepted; + }; + const first = deliver(); + const second = replay === "paste" ? deliver() : ta.dispatchEvent(new InputEvent("beforeinput", { + bubbles: true, cancelable: true, inputType: replay, data: pasted, + })); + if (replay !== "paste" && second) document.execCommand("insertText", false, pasted); + const once = ta.value; + // A deliberate second action must work even before the window expires. + ta.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "v", metaKey: true })); + const third = deliver(); + return { first, second, third, once, twice: ta.value }; + }, replay); + expect(result).toEqual({ first: true, second: false, third: true, + once: "before 一份测试文字 after", twice: "before 一份测试文字一份测试文字 after" }); + await expect(input).toHaveValue(result.twice); + }); + } + + test(`${target} iOS keyboard paste imports duplicate image deliveries once`, async ({ page }) => { + await page.addInitScript(() => Object.defineProperty(navigator, "userAgent", { + configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X)", + })); + if (target === "side chat scope") { + await mockRightPanelRelay(page, { visible: true }); + await page.goto("/"); + } else await page.goto(target === "new-chat controls" + ? "/tests/history-browser.html?newchat-controls=1" + : "/tests/history-browser.html?codex-live-burst=1&composer-live=1&composer-paste=1"); + const input = target === "side chat scope" + ? page.locator(".btw-panel textarea") : page.locator("textarea").first(); + await input.focus(); + const result = await input.evaluate(async (node, encoded) => { + // Warm only the importer: a native second delivery follows a completed + // import, rather than being rejected by the ordinary import-in-flight lock. + const importer = "/src/attachment-import.ts"; + const resolver = "/src/clipboard-files.ts"; + await Promise.all([import(/* @vite-ignore */ importer), import(/* @vite-ignore */ resolver)]); + const ta = node as HTMLTextAreaElement; + const root = ta.closest(".btw-panel") ?? document; + const frames = () => new Promise(resolve => requestAnimationFrame(() => + requestAnimationFrame(() => resolve()))); + const deliver = () => { + const data = new DataTransfer(); + data.setData("text/plain", "配图文字"); + data.items.add(new File([Uint8Array.from(atob(encoded), c => c.charCodeAt(0))], + "image.png", { type: "image/png" })); + ta.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data })); + data.items.clear(); + }; + const start = performance.now(); + const appeared = new Promise(resolve => { + const observer = new MutationObserver(() => { + if (root.querySelectorAll(".attach-image-preview").length !== 1) return; + observer.disconnect(); resolve(); + }); + observer.observe(root, { childList: true, subtree: true }); + }); + deliver(); + await appeared; + const repeatAt = performance.now() - start; + deliver(); + await frames(); + const once = { text: ta.value, images: root.querySelectorAll(".attach-image-preview").length }; + ta.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" })); + deliver(); + await frames(); + return { repeatAt, once, twice: { text: ta.value, + images: root.querySelectorAll(".attach-image-preview").length } }; + }, staticPng().toString("base64")); + expect(result.repeatAt, "probe must replay inside the native duplicate window").toBeLessThan(120); + expect(result.once).toEqual({ text: "配图文字", images: 1 }); + expect(result.twice).toEqual({ text: "配图文字配图文字", images: 2 }); + }); +} + for (const target of ["composer", "new-chat controls"]) { - test(`${target === "composer" ? "long paste" : target} mixed clipboard keeps text and deduplicates supplied images`, async ({ page }) => { +for (const misreportedInsert of [false, true]) { + test(`${target === "composer" ? "long paste" : target} mixed clipboard keeps text and deduplicates supplied images${misreportedInsert ? " after a misreported native insert" : ""}`, async ({ page }) => { const alerts: string[] = []; page.on("dialog", async (dialog) => { alerts.push(dialog.message()); @@ -9029,7 +9305,7 @@ for (const target of ["composer", "new-chat controls"]) { })), ); await expect(page.locator(".attach-file")).toHaveCount(7); - await input.evaluate((node) => { + await input.evaluate((node, misreportedInsert) => { const ta = node as HTMLTextAreaElement; ta.focus(); ta.setSelectionRange(7, 14); const canvas = document.createElement("canvas"); @@ -9041,10 +9317,27 @@ for (const target of ["composer", "new-chat controls"]) { data.setData("text/plain", "pasted text"); data.setData("text/html", `
pasted text
`); data.items.add(new File([bytes], "clipboard.png", { type: "image/png" })); - ta.dispatchEvent(new ClipboardEvent("paste", { - bubbles: true, cancelable: true, clipboardData: data, - })); - }); + const nativeInsert = document.execCommand; + if (misreportedInsert) { + document.execCommand = function (command, ui, value) { + const result = nativeInsert.call(this, command, ui, value); + if (command === "insertText") { + if (ta.value !== "before pasted text after") { + throw new Error("the compatibility probe must actually insert text"); + } + return false; + } + return result; + }; + } + try { + ta.dispatchEvent(new ClipboardEvent("paste", { + bubbles: true, cancelable: true, clipboardData: data, + })); + } finally { + document.execCommand = nativeInsert; + } + }, misreportedInsert); await expect(input).toHaveValue("before pasted text after"); await expect(page.locator(".attach-image-preview")).toHaveCount(1); await expect(page.locator(".attach-file")).toHaveCount(7); @@ -9059,6 +9352,7 @@ for (const target of ["composer", "new-chat controls"]) { } }); } +} test("long paste static PNG pixels and metadata survive preview and send", async ({ page }) => { await page.goto("/tests/history-browser.html?codex-live-burst=1&composer-live=1&composer-paste=1"); diff --git a/web/tests/steer-boundary.test.ts b/web/tests/steer-boundary.test.ts index 1cb7601c..dc78ce58 100644 --- a/web/tests/steer-boundary.test.ts +++ b/web/tests/steer-boundary.test.ts @@ -41,6 +41,32 @@ assert.deepEqual(repaired.map((turn) => turn.blocks.map((block) => block.kind === "text" ? block.message_id : null)), [["answer-old"], ["answer-guided"]]); +// Claude stores an in-flight human prompt as a queued_command attachment. +// Older cold history omitted it and assigned its final answer to the root. +// A corrected authoritative page must retain both prompts and move that exact +// answer back to the consumed input, including across an IndexedDB refresh. +const claudeRoot: Turn = { + id: "claude-root", prompt: "original question", done: true, + blocks: [], processDetailState: "present", detailEventCount: 2, +}; +const claudeSteer: Turn = { + id: "claude-native-steer", clientMsgId: "claude-browser-steer", + forkPointId: "claude-final", prompt: "additional direction", done: true, + blocks: [{ kind: "text", message_id: "claude-final", channel: "final", + text: "final answer", done: true }], +}; +let collapsed: Turn[] = [{ ...claudeRoot, forkPointId: "claude-final", + blocks: [...claudeSteer.blocks] }]; +for (let refresh = 0; refresh < 2; refresh += 1) { + collapsed = mergeInitialHistory([claudeRoot, claudeSteer], collapsed, + { reconcileReplayOrphans: true }, true); + assert.deepEqual(collapsed.map(turn => turn.prompt), + ["original question", "additional direction"]); + assert.equal(collapsed[0].blocks.filter(block => block.kind === "text").length, 0); + assert.deepEqual(collapsed[1].blocks.map(block => + block.kind === "text" ? block.message_id : null), ["claude-final"]); +} + // Two steers can materialize consecutively after compaction. The first is a // real user row with no process of its own. Older clients cached the previous // segment's items and clock under it, then reported an endless detail failure.