diff --git a/backend/app/api/endpoints/subtasks.py b/backend/app/api/endpoints/subtasks.py index 63e3ebd5a8..6c88dcd73d 100644 --- a/backend/app/api/endpoints/subtasks.py +++ b/backend/app/api/endpoints/subtasks.py @@ -23,8 +23,13 @@ SubtaskListResponse, SubtaskUpdate, ) +from app.schemas.turn_file_changes import ( + TurnFileChangesDiffResponse, + TurnFileChangesRevertResponse, +) from app.services.chat.storage import session_manager from app.services.subtask import subtask_service +from app.services.turn_file_changes import turn_file_changes_service router = APIRouter() @@ -103,6 +108,40 @@ def get_subtask( ) +@router.get( + "/{subtask_id}/file-changes/diff", + response_model=TurnFileChangesDiffResponse, +) +async def get_turn_file_changes_diff( + subtask_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +) -> TurnFileChangesDiffResponse: + """Load one assistant turn's validated diff from its execution device.""" + return await turn_file_changes_service.get_diff( + db=db, + user_id=current_user.id, + subtask_id=subtask_id, + ) + + +@router.post( + "/{subtask_id}/file-changes/revert", + response_model=TurnFileChangesRevertResponse, +) +async def revert_turn_file_changes( + subtask_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +) -> TurnFileChangesRevertResponse: + """Reverse one assistant turn without overwriting later workspace changes.""" + return await turn_file_changes_service.revert( + db=db, + user_id=current_user.id, + subtask_id=subtask_id, + ) + + @router.put("/{subtask_id}", response_model=SubtaskInDB) def update_subtask( subtask_id: int, diff --git a/backend/app/schemas/turn_file_changes.py b/backend/app/schemas/turn_file_changes.py new file mode 100644 index 0000000000..1b4b8e39c1 --- /dev/null +++ b/backend/app/schemas/turn_file_changes.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Schemas for per-turn coding file changes.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class TurnFileChangeItem(BaseModel): + """One file changed during an agent turn.""" + + model_config = ConfigDict(extra="forbid") + + old_path: str | None = None + path: str = Field(min_length=1) + change_type: Literal["created", "modified", "deleted", "renamed"] + additions: int = Field(ge=0) + deletions: int = Field(ge=0) + binary: bool = False + + +class TurnFileChangesSummary(BaseModel): + """Persisted lightweight summary for a turn patch artifact.""" + + model_config = ConfigDict(extra="forbid") + + version: Literal[1] + status: Literal["active", "reverted", "conflicted", "artifact_missing"] + artifact_id: str = Field(min_length=1) + device_id: str = Field(min_length=1) + workspace_path: str = Field(min_length=1) + file_count: int = Field(ge=0) + additions: int = Field(ge=0) + deletions: int = Field(ge=0) + files: list[TurnFileChangeItem] + reverted_at: datetime | None = None + + +class TurnFileChangesDiffResponse(BaseModel): + """Validated unified diff returned from the owning device.""" + + subtask_id: int + diff: str + + +class TurnFileChangesRevertResponse(BaseModel): + """Updated summary returned after an idempotent revert.""" + + subtask_id: int + file_changes: TurnFileChangesSummary diff --git a/backend/app/services/chat/trigger/unified.py b/backend/app/services/chat/trigger/unified.py index b5c28ef7b2..efff66e351 100644 --- a/backend/app/services/chat/trigger/unified.py +++ b/backend/app/services/chat/trigger/unified.py @@ -386,6 +386,7 @@ async def build_execution_request( previous_bot_id=previous_bot_id, web_runtime_guidance=web_runtime_guidance, ) + request.device_id = device_id or request.device_id # Merge reasoning config from API/model selection into model_config. # Priority: explicit API reasoning_config > UI model_options > model think_config. diff --git a/backend/app/services/device/command_registry.py b/backend/app/services/device/command_registry.py index 5b53112347..311710a938 100644 --- a/backend/app/services/device/command_registry.py +++ b/backend/app/services/device/command_registry.py @@ -353,6 +353,156 @@ def fail(message, code=64): f"python3 -c {shlex.quote(READ_RUNTIME_AUTH_FILE_SCRIPT)}" ) +TURN_FILE_CHANGES_SCRIPT = """ +import gzip +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +MAX_PATCH_BYTES = 20 * 1024 * 1024 +ARTIFACT_PATTERN = re.compile( + r"turn-file-changes/([1-9][0-9]*)/([1-9][0-9]*)" +) + + +def finish(payload, code=0): + print(json.dumps(payload, ensure_ascii=False)) + sys.exit(code) + + +def fail(message, code=64, status=None): + payload = {"success": False, "error": message} + if status: + payload["status"] = status + finish(payload, code) + + +if len(sys.argv) != 3: + fail("mode and artifact id are required") + +mode = sys.argv[1] +artifact_id = sys.argv[2] +if mode not in {"review", "revert"}: + fail("invalid mode") + +match = ARTIFACT_PATTERN.fullmatch(artifact_id) +if not match: + fail("invalid artifact id") + +task_id = int(match.group(1)) +subtask_id = int(match.group(2)) +executor_home = Path( + os.environ.get("WEGENT_EXECUTOR_HOME", "~/.wegent-executor") +).expanduser() +artifact_root = (executor_home / "artifacts").resolve() +artifact_dir = (artifact_root / artifact_id).resolve() +if artifact_root not in artifact_dir.parents: + fail("invalid artifact id") + +metadata_path = artifact_dir / "metadata.json" +patch_path = artifact_dir / "changes.patch.gz" +if not metadata_path.is_file() or not patch_path.is_file(): + finish( + { + "success": False, + "status": "artifact_missing", + "error": "turn file changes artifact is missing", + } + ) + +try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + fail(f"invalid artifact metadata: {exc}", code=65) + +if not isinstance(metadata, dict): + fail("invalid artifact metadata", code=65) +if metadata.get("task_id") != task_id or metadata.get("subtask_id") != subtask_id: + fail("artifact metadata id mismatch", code=65) + +workspace = Path.cwd().resolve() +try: + metadata_workspace = Path(str(metadata["workspace_path"])).resolve() +except (KeyError, OSError): + fail("invalid artifact workspace", code=65) +if metadata_workspace != workspace: + fail("artifact workspace mismatch", code=65) + +try: + with gzip.open(patch_path, "rb") as patch_file: + patch = patch_file.read(MAX_PATCH_BYTES + 1) +except (OSError, gzip.BadGzipFile) as exc: + fail(f"failed to read artifact patch: {exc}", code=65) +if len(patch) > MAX_PATCH_BYTES: + fail("artifact patch exceeds size limit", code=65) +if hashlib.sha256(patch).hexdigest() != metadata.get("checksum"): + fail("artifact patch checksum mismatch", code=65) + +if mode == "review": + finish( + { + "success": True, + "diff": patch.decode("utf-8", errors="replace"), + } + ) + +temp_path = None +try: + with tempfile.NamedTemporaryFile( + prefix="wegent-validated-turn-", + suffix=".patch", + delete=False, + ) as temp_file: + temp_file.write(patch) + temp_path = Path(temp_file.name) + + check = subprocess.run( + ["git", "apply", "--reverse", "--check", "--binary", str(temp_path)], + cwd=workspace, + capture_output=True, + text=True, + ) + if check.returncode != 0: + finish( + { + "success": False, + "status": "conflicted", + "error": "patch does not apply", + } + ) + + apply_result = subprocess.run( + ["git", "apply", "--reverse", "--binary", str(temp_path)], + cwd=workspace, + capture_output=True, + text=True, + ) + if apply_result.returncode != 0: + finish( + { + "success": False, + "status": "conflicted", + "error": "patch does not apply", + } + ) + finish({"success": True, "status": "reverted"}) +finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) +""".strip() + +TURN_FILE_CHANGES_REVIEW_COMMAND = ( + f"python3 -c {shlex.quote(TURN_FILE_CHANGES_SCRIPT)} review" +) +TURN_FILE_CHANGES_REVERT_COMMAND = ( + f"python3 -c {shlex.quote(TURN_FILE_CHANGES_SCRIPT)} revert" +) + DEFAULT_LOCAL_DEVICE_COMMANDS: dict[str, LocalDeviceCommandDefinition] = { "pwd": LocalDeviceCommandDefinition(command="pwd"), @@ -438,6 +588,14 @@ def fail(message, code=64): command=READ_RUNTIME_AUTH_FILE_COMMAND, post_processor="json", ), + "turn_file_changes_review": LocalDeviceCommandDefinition( + command=TURN_FILE_CHANGES_REVIEW_COMMAND, + post_processor="json", + ), + "turn_file_changes_revert": LocalDeviceCommandDefinition( + command=TURN_FILE_CHANGES_REVERT_COMMAND, + post_processor="json", + ), } diff --git a/backend/app/services/execution/dispatcher.py b/backend/app/services/execution/dispatcher.py index 68ecb8e357..722589a125 100644 --- a/backend/app/services/execution/dispatcher.py +++ b/backend/app/services/execution/dispatcher.py @@ -177,6 +177,7 @@ def extract_completed_result(response_data: dict) -> dict: "standalone_chat_workspace_path": response_data.get( "standalone_chat_workspace_path" ), + "file_changes": response_data.get("file_changes"), "reasoning_content": reasoning_content, } diff --git a/backend/app/services/turn_file_changes.py b/backend/app/services/turn_file_changes.py new file mode 100644 index 0000000000..d6a092b86d --- /dev/null +++ b/backend/app/services/turn_file_changes.py @@ -0,0 +1,270 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Review and safely revert per-turn file change artifacts.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models.subtask import Subtask, SubtaskRole +from app.models.task import TaskResource +from app.schemas.turn_file_changes import ( + TurnFileChangesDiffResponse, + TurnFileChangesRevertResponse, + TurnFileChangesSummary, +) +from app.services.device.command_service import ( + DeviceCommandError, + DeviceCommandNotFoundError, + execute_configured_device_command, +) + +MAX_DIFF_OUTPUT_BYTES = 5 * 1024 * 1024 + + +def _error(status_code: int, code: str, message: str, **extra: Any) -> HTTPException: + return HTTPException( + status_code=status_code, + detail={"code": code, "message": message, **extra}, + ) + + +class TurnFileChangesService: + """Load stored summaries and dispatch artifact commands to their device.""" + + async def get_diff( + self, + *, + db: Session, + user_id: int, + subtask_id: int, + ) -> TurnFileChangesDiffResponse: + subtask, summary = self._load_summary(db, user_id, subtask_id) + if summary.status == "artifact_missing": + raise self._artifact_missing_error() + + payload = await self._execute( + db=db, + user_id=user_id, + summary=summary, + command_key="turn_file_changes_review", + ) + self._raise_for_artifact_status(db, subtask, payload) + diff = payload.get("diff") + if payload.get("success") is not True or not isinstance(diff, str): + raise _error( + 422, + "TURN_FILE_CHANGES_ARTIFACT_INVALID", + str(payload.get("error") or "Device returned an invalid diff artifact"), + ) + return TurnFileChangesDiffResponse(subtask_id=subtask.id, diff=diff) + + async def revert( + self, + *, + db: Session, + user_id: int, + subtask_id: int, + ) -> TurnFileChangesRevertResponse: + subtask, summary = self._load_summary(db, user_id, subtask_id) + if summary.status == "reverted": + return TurnFileChangesRevertResponse( + subtask_id=subtask.id, + file_changes=summary, + ) + if summary.status == "artifact_missing": + raise self._artifact_missing_error() + + payload = await self._execute( + db=db, + user_id=user_id, + summary=summary, + command_key="turn_file_changes_revert", + ) + self._raise_for_artifact_status(db, subtask, payload) + if payload.get("success") is True and payload.get("status") == "reverted": + updated = self._update_status( + db, + subtask, + status="reverted", + reverted_at=datetime.now(timezone.utc).isoformat(), + ) + return TurnFileChangesRevertResponse( + subtask_id=subtask.id, + file_changes=updated, + ) + if payload.get("status") == "conflicted": + updated = self._update_status(db, subtask, status="conflicted") + raise _error( + 409, + "TURN_FILE_CHANGES_CONFLICT", + str(payload.get("error") or "Patch does not apply"), + file_changes=updated.model_dump(mode="json"), + ) + raise _error( + 422, + "TURN_FILE_CHANGES_ARTIFACT_INVALID", + str(payload.get("error") or "Device returned an invalid revert result"), + ) + + def _load_summary( + self, + db: Session, + user_id: int, + subtask_id: int, + ) -> tuple[Subtask, TurnFileChangesSummary]: + record = ( + db.query(Subtask, TaskResource) + .join(TaskResource, TaskResource.id == Subtask.task_id) + .filter( + Subtask.id == subtask_id, + Subtask.role == SubtaskRole.ASSISTANT, + TaskResource.user_id == user_id, + TaskResource.kind == "Task", + TaskResource.is_active.in_(TaskResource.is_active_query()), + ) + .first() + ) + if record is None: + raise _error( + 404, + "TURN_FILE_CHANGES_NOT_FOUND", + "Assistant message file changes were not found", + ) + + subtask, task = record + result = subtask.result if isinstance(subtask.result, dict) else {} + raw_summary = result.get("file_changes") + try: + summary = TurnFileChangesSummary.model_validate(raw_summary) + except ValidationError as exc: + raise _error( + 409, + "TURN_FILE_CHANGES_INVALID_STATE", + "Stored file changes summary is invalid", + ) from exc + + task_json = task.json if isinstance(task.json, dict) else {} + task_spec = task_json.get("spec") + expected_device_id = ( + task_spec.get("device_id") if isinstance(task_spec, dict) else None + ) + if expected_device_id and expected_device_id != summary.device_id: + raise _error( + 409, + "TURN_FILE_CHANGES_DEVICE_MISMATCH", + "Stored artifact device does not match the task device", + ) + return subtask, summary + + async def _execute( + self, + *, + db: Session, + user_id: int, + summary: TurnFileChangesSummary, + command_key: str, + ) -> dict[str, Any]: + try: + result = await execute_configured_device_command( + db=db, + user_id=user_id, + device_id=summary.device_id, + command_key=command_key, + path=summary.workspace_path, + args=[summary.artifact_id], + timeout_seconds=30, + max_output_bytes=MAX_DIFF_OUTPUT_BYTES, + ) + except DeviceCommandNotFoundError as exc: + raise _error( + 409, + "TURN_FILE_CHANGES_DEVICE_UNAVAILABLE", + str(exc), + ) from exc + except DeviceCommandError as exc: + code = ( + "TURN_FILE_CHANGES_DEVICE_OFFLINE" + if "offline" in str(exc).lower() + else "TURN_FILE_CHANGES_DEVICE_UNAVAILABLE" + ) + raise _error(409, code, str(exc)) from exc + + if not isinstance(result, dict): + raise _error( + 422, + "TURN_FILE_CHANGES_ARTIFACT_INVALID", + "Device returned an invalid command result", + ) + if result.get("success") is not True: + raise _error( + 422, + "TURN_FILE_CHANGES_ARTIFACT_INVALID", + str( + result.get("error") + or result.get("stderr") + or "Artifact command failed" + ), + ) + payload = result.get("stdout") + if not isinstance(payload, dict): + raise _error( + 422, + "TURN_FILE_CHANGES_ARTIFACT_INVALID", + "Device returned malformed artifact output", + ) + return payload + + def _raise_for_artifact_status( + self, + db: Session, + subtask: Subtask, + payload: dict[str, Any], + ) -> None: + if payload.get("status") != "artifact_missing": + return + updated = self._update_status(db, subtask, status="artifact_missing") + raise _error( + 410, + "TURN_FILE_CHANGES_ARTIFACT_MISSING", + str(payload.get("error") or "Turn file changes artifact is missing"), + file_changes=updated.model_dump(mode="json"), + ) + + def _update_status( + self, + db: Session, + subtask: Subtask, + *, + status: str, + reverted_at: str | None = None, + ) -> TurnFileChangesSummary: + updated_result = dict(subtask.result or {}) + updated_file_changes = dict(updated_result["file_changes"]) + updated_file_changes["status"] = status + if reverted_at is not None: + updated_file_changes["reverted_at"] = reverted_at + updated_result["file_changes"] = updated_file_changes + subtask.result = updated_result + flag_modified(subtask, "result") + db.commit() + return TurnFileChangesSummary.model_validate(updated_file_changes) + + @staticmethod + def _artifact_missing_error() -> HTTPException: + return _error( + 410, + "TURN_FILE_CHANGES_ARTIFACT_MISSING", + "Turn file changes artifact is missing", + ) + + +turn_file_changes_service = TurnFileChangesService() diff --git a/backend/tests/api/endpoints/test_subtask_file_changes.py b/backend/tests/api/endpoints/test_subtask_file_changes.py new file mode 100644 index 0000000000..05a90e3d37 --- /dev/null +++ b/backend/tests/api/endpoints/test_subtask_file_changes.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + +from app.models.user import User + + +def test_get_subtask_file_changes_diff_requires_authentication( + test_client: TestClient, +): + response = test_client.get("/api/subtasks/123/file-changes/diff") + + assert response.status_code == 401 + + +def test_get_subtask_file_changes_diff_delegates_to_service( + test_client: TestClient, + test_token: str, + test_user: User, +): + with patch( + "app.api.endpoints.subtasks.turn_file_changes_service.get_diff", + AsyncMock( + return_value={ + "subtask_id": 123, + "diff": "diff --git a/a.txt b/a.txt\n", + } + ), + ) as get_diff: + response = test_client.get( + "/api/subtasks/123/file-changes/diff", + headers={"Authorization": f"Bearer {test_token}"}, + ) + + assert response.status_code == 200 + assert response.json()["subtask_id"] == 123 + get_diff.assert_awaited_once() + assert get_diff.await_args.kwargs["user_id"] == test_user.id + + +def test_revert_subtask_file_changes_delegates_without_request_body( + test_client: TestClient, + test_token: str, + test_user: User, +): + summary = { + "version": 1, + "status": "reverted", + "artifact_id": "turn-file-changes/10/20", + "device_id": "device-1", + "workspace_path": "/workspace/project", + "file_count": 1, + "additions": 1, + "deletions": 0, + "files": [ + { + "old_path": None, + "path": "a.txt", + "change_type": "created", + "additions": 1, + "deletions": 0, + "binary": False, + } + ], + "reverted_at": "2026-06-11T00:00:00Z", + } + with patch( + "app.api.endpoints.subtasks.turn_file_changes_service.revert", + AsyncMock( + return_value={ + "subtask_id": 123, + "file_changes": summary, + } + ), + ) as revert: + response = test_client.post( + "/api/subtasks/123/file-changes/revert", + headers={"Authorization": f"Bearer {test_token}"}, + ) + + assert response.status_code == 200 + assert response.json()["file_changes"]["status"] == "reverted" + revert.assert_awaited_once() + assert revert.await_args.kwargs["user_id"] == test_user.id diff --git a/backend/tests/services/chat/test_trigger_unified.py b/backend/tests/services/chat/test_trigger_unified.py index 2fed7b8afb..ffb3f1cec5 100644 --- a/backend/tests/services/chat/test_trigger_unified.py +++ b/backend/tests/services/chat/test_trigger_unified.py @@ -92,6 +92,35 @@ def test_apply_user_runtime_config_skips_non_codex_models(monkeypatch): @pytest.mark.unit class TestBuildExecutionRequestUserSubtaskId: + async def test_propagates_device_id_to_execution_request(self): + from app.services.chat.trigger import unified as trigger_unified + + mock_db = MagicMock() + request_from_builder = ExecutionRequest(task_id=1, subtask_id=2) + mock_builder = MagicMock() + mock_builder.build.return_value = request_from_builder + + with patch.object(trigger_unified, "SessionLocal", return_value=mock_db): + with patch( + "app.services.execution.TaskRequestBuilder", + return_value=mock_builder, + ): + task = MagicMock(id=1, json={}) + assistant_subtask = MagicMock(id=2) + team = MagicMock() + user = MagicMock(id=7) + + result = await trigger_unified.build_execution_request( + task=task, + assistant_subtask=assistant_subtask, + team=team, + user=user, + message="hello", + device_id="device-1", + ) + + assert result.device_id == "device-1" + async def test_propagates_user_subtask_id_to_execution_request(self): """Ensure user_subtask_id is always propagated for downstream RAG persistence.""" from app.services.chat.trigger import unified as trigger_unified diff --git a/backend/tests/services/chat/trigger/test_lifecycle_completed_result.py b/backend/tests/services/chat/trigger/test_lifecycle_completed_result.py index d56bf81c6e..f11bea3070 100644 --- a/backend/tests/services/chat/trigger/test_lifecycle_completed_result.py +++ b/backend/tests/services/chat/trigger/test_lifecycle_completed_result.py @@ -93,3 +93,44 @@ async def _empty_existing_result(_subtask_id: int) -> dict: }, } ] + + +@pytest.mark.asyncio +async def test_collect_completed_result_preserves_file_changes_with_blocks(monkeypatch): + async def _empty_existing_result(_subtask_id: int) -> dict: + return {} + + monkeypatch.setattr( + lifecycle, + "_get_existing_subtask_result", + _empty_existing_result, + ) + + import app.services.chat.storage as chat_storage + + monkeypatch.setattr(chat_storage, "session_manager", _SessionManager()) + + result = await lifecycle.collect_completed_result( + 1234, + status="COMPLETED", + result={ + "value": "done", + "file_changes": { + "version": 1, + "status": "active", + "artifact_id": "turn-file-changes/7/1234", + "device_id": "device-1", + "workspace_path": "/workspace/project", + "file_count": 1, + "additions": 4, + "deletions": 2, + "files": [], + "reverted_at": None, + }, + }, + ) + + assert result is not None + assert result["value"] == "done" + assert result["blocks"] + assert result["file_changes"]["file_count"] == 1 diff --git a/backend/tests/services/execution/test_extract_completed_result.py b/backend/tests/services/execution/test_extract_completed_result.py index a5fbce78ab..33720e24ad 100644 --- a/backend/tests/services/execution/test_extract_completed_result.py +++ b/backend/tests/services/execution/test_extract_completed_result.py @@ -66,6 +66,26 @@ def test_preserves_standalone_chat_workspace_path(self): result["standalone_chat_workspace_path"] == "/tmp/chats/2026-05-29/new-chat" ) + def test_preserves_file_changes(self): + file_changes = { + "version": 1, + "status": "active", + "file_count": 1, + "additions": 1, + "deletions": 0, + "files": [ + { + "path": "data.txt", + "change_type": "created", + "additions": 1, + "deletions": 0, + "binary": False, + } + ], + } + result = extract_completed_result({"file_changes": file_changes}) + assert result["file_changes"] == file_changes + def test_preserves_silent_exit_fields(self): result = extract_completed_result( { diff --git a/backend/tests/services/test_local_device_command_service.py b/backend/tests/services/test_local_device_command_service.py index bc2fbb1511..ef2ddcfc13 100644 --- a/backend/tests/services/test_local_device_command_service.py +++ b/backend/tests/services/test_local_device_command_service.py @@ -4,6 +4,8 @@ """Tests for local device command RPC service.""" +import gzip +import hashlib import json import os import subprocess @@ -13,6 +15,153 @@ import pytest +def _run_git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + ).stdout + + +def _create_turn_file_changes_artifact(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + _run_git(repo, "init", "-q") + _run_git(repo, "config", "user.email", "tests@example.com") + _run_git(repo, "config", "user.name", "Tests") + changed_file = repo / "changed.txt" + changed_file.write_text("before\n", encoding="utf-8") + _run_git(repo, "add", "--all") + _run_git(repo, "commit", "-qm", "initial") + changed_file.write_text("after\n", encoding="utf-8") + patch = _run_git(repo, "diff", "--binary", "HEAD") + executor_home = tmp_path / "executor-home" + artifact_dir = executor_home / "artifacts" / "turn-file-changes" / "10" / "20" + artifact_dir.mkdir(parents=True) + (artifact_dir / "changes.patch.gz").write_bytes(gzip.compress(patch)) + (artifact_dir / "metadata.json").write_text( + json.dumps( + { + "version": 1, + "task_id": 10, + "subtask_id": 20, + "workspace_path": str(repo.resolve()), + "checksum": hashlib.sha256(patch).hexdigest(), + } + ), + encoding="utf-8", + ) + return repo, executor_home + + +def test_turn_file_changes_commands_are_registered(): + from app.services.device.command_registry import resolve_local_device_command + + review = resolve_local_device_command("turn_file_changes_review", {}) + revert = resolve_local_device_command("turn_file_changes_revert", {}) + + assert review is not None + assert revert is not None + assert review.post_processor == "json" + assert revert.post_processor == "json" + + +@pytest.mark.parametrize( + "artifact_id", + [ + "../../etc/passwd", + "turn-file-changes/1/2/../../../secret", + ], +) +def test_turn_file_changes_command_rejects_invalid_artifact_id( + tmp_path, + artifact_id, +): + from app.services.device.command_registry import TURN_FILE_CHANGES_SCRIPT + + result = subprocess.run( + ["python3", "-c", TURN_FILE_CHANGES_SCRIPT, "review", artifact_id], + cwd=tmp_path, + env={**os.environ, "WEGENT_EXECUTOR_HOME": str(tmp_path / "home")}, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "invalid artifact id" in result.stdout + + +def test_turn_file_changes_review_returns_validated_diff(tmp_path): + from app.services.device.command_registry import TURN_FILE_CHANGES_SCRIPT + + repo, executor_home = _create_turn_file_changes_artifact(tmp_path) + result = subprocess.run( + [ + "python3", + "-c", + TURN_FILE_CHANGES_SCRIPT, + "review", + "turn-file-changes/10/20", + ], + cwd=repo, + env={**os.environ, "WEGENT_EXECUTOR_HOME": str(executor_home)}, + check=True, + capture_output=True, + text=True, + ) + + payload = json.loads(result.stdout) + assert payload["success"] is True + assert payload["diff"].startswith("diff --git a/changed.txt b/changed.txt") + + +def test_turn_file_changes_revert_is_conflict_safe(tmp_path): + from app.services.device.command_registry import TURN_FILE_CHANGES_SCRIPT + + repo, executor_home = _create_turn_file_changes_artifact(tmp_path) + (repo / "changed.txt").write_text("later change\n", encoding="utf-8") + result = subprocess.run( + [ + "python3", + "-c", + TURN_FILE_CHANGES_SCRIPT, + "revert", + "turn-file-changes/10/20", + ], + cwd=repo, + env={**os.environ, "WEGENT_EXECUTOR_HOME": str(executor_home)}, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout)["status"] == "conflicted" + assert (repo / "changed.txt").read_text(encoding="utf-8") == "later change\n" + + +def test_turn_file_changes_revert_applies_reverse_patch(tmp_path): + from app.services.device.command_registry import TURN_FILE_CHANGES_SCRIPT + + repo, executor_home = _create_turn_file_changes_artifact(tmp_path) + result = subprocess.run( + [ + "python3", + "-c", + TURN_FILE_CHANGES_SCRIPT, + "revert", + "turn-file-changes/10/20", + ], + cwd=repo, + env={**os.environ, "WEGENT_EXECUTOR_HOME": str(executor_home)}, + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout) == {"success": True, "status": "reverted"} + assert (repo / "changed.txt").read_text(encoding="utf-8") == "before\n" + + def test_local_device_command_registry_default_includes_diagnostic_commands(): """Default command registry should include basic diagnostic commands.""" from app.core.config import Settings diff --git a/backend/tests/services/test_turn_file_changes_service.py b/backend/tests/services/test_turn_file_changes_service.py new file mode 100644 index 0000000000..21ac9034cd --- /dev/null +++ b/backend/tests/services/test_turn_file_changes_service.py @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.models.subtask import Subtask, SubtaskRole, SubtaskStatus +from app.models.task import TaskResource +from app.services.device.command_service import DeviceCommandError +from app.services.turn_file_changes import turn_file_changes_service + + +def _summary(*, device_id="device-1", status="active"): + return { + "version": 1, + "status": status, + "artifact_id": "turn-file-changes/10/20", + "device_id": device_id, + "workspace_path": "/workspace/project", + "file_count": 1, + "additions": 3, + "deletions": 1, + "files": [ + { + "old_path": None, + "path": "src/app.ts", + "change_type": "modified", + "additions": 3, + "deletions": 1, + "binary": False, + } + ], + "reverted_at": None, + } + + +def _create_records(test_db, *, device_id="device-1", summary_device_id="device-1"): + task = TaskResource( + user_id=7, + kind="Task", + name="task-10", + namespace="default", + json={ + "kind": "Task", + "spec": {"title": "Task", "device_id": device_id}, + }, + is_active=TaskResource.STATE_ACTIVE, + ) + test_db.add(task) + test_db.flush() + subtask = Subtask( + user_id=7, + task_id=task.id, + team_id=1, + title="Assistant", + bot_ids=[], + role=SubtaskRole.ASSISTANT, + status=SubtaskStatus.COMPLETED, + result={ + "value": "done", + "blocks": [{"id": "tool-1", "type": "tool"}], + "file_changes": _summary(device_id=summary_device_id), + }, + ) + test_db.add(subtask) + test_db.commit() + test_db.refresh(subtask) + return task, subtask + + +def _command_result(payload): + return { + "success": True, + "exit_code": 0, + "stdout": payload, + "stderr": "", + "duration": 0.01, + } + + +@pytest.mark.asyncio +async def test_get_diff_dispatches_recorded_device_and_workspace( + test_db, + monkeypatch, +): + _, subtask = _create_records(test_db) + execute = AsyncMock( + return_value=_command_result({"success": True, "diff": "diff --git\n"}) + ) + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + execute, + ) + + response = await turn_file_changes_service.get_diff( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + + assert response.diff == "diff --git\n" + execute.assert_awaited_once_with( + db=test_db, + user_id=7, + device_id="device-1", + command_key="turn_file_changes_review", + path="/workspace/project", + args=["turn-file-changes/10/20"], + timeout_seconds=30, + max_output_bytes=5 * 1024 * 1024, + ) + + +@pytest.mark.asyncio +async def test_get_diff_rejects_device_mismatch(test_db, monkeypatch): + _, subtask = _create_records( + test_db, + device_id="device-2", + summary_device_id="device-1", + ) + execute = AsyncMock() + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + execute, + ) + + with pytest.raises(HTTPException) as exc_info: + await turn_file_changes_service.get_diff( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == "TURN_FILE_CHANGES_DEVICE_MISMATCH" + execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_get_diff_reports_offline_device(test_db, monkeypatch): + _, subtask = _create_records(test_db) + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + AsyncMock(side_effect=DeviceCommandError("Device 'device-1' is offline")), + ) + + with pytest.raises(HTTPException) as exc_info: + await turn_file_changes_service.get_diff( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == "TURN_FILE_CHANGES_DEVICE_OFFLINE" + + +@pytest.mark.asyncio +async def test_revert_updates_only_file_changes_status(test_db, monkeypatch): + _, subtask = _create_records(test_db) + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + AsyncMock( + return_value=_command_result({"success": True, "status": "reverted"}) + ), + ) + + response = await turn_file_changes_service.revert( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + test_db.refresh(subtask) + + assert response.file_changes.status == "reverted" + assert response.file_changes.reverted_at is not None + assert subtask.result["value"] == "done" + assert subtask.result["blocks"] == [{"id": "tool-1", "type": "tool"}] + + +@pytest.mark.asyncio +async def test_revert_conflict_keeps_existing_message_result(test_db, monkeypatch): + _, subtask = _create_records(test_db) + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + AsyncMock( + return_value=_command_result( + { + "success": False, + "status": "conflicted", + "error": "patch does not apply", + } + ) + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await turn_file_changes_service.revert( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + test_db.refresh(subtask) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == "TURN_FILE_CHANGES_CONFLICT" + assert subtask.result["value"] == "done" + assert subtask.result["blocks"] == [{"id": "tool-1", "type": "tool"}] + assert subtask.result["file_changes"]["status"] == "conflicted" + + +@pytest.mark.asyncio +async def test_revert_is_idempotent_after_success(test_db, monkeypatch): + _, subtask = _create_records(test_db) + result = dict(subtask.result) + result["file_changes"] = _summary(status="reverted") + subtask.result = result + test_db.commit() + execute = AsyncMock() + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + execute, + ) + + response = await turn_file_changes_service.revert( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + + assert response.file_changes.status == "reverted" + execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_missing_artifact_marks_artifact_missing(test_db, monkeypatch): + _, subtask = _create_records(test_db) + monkeypatch.setattr( + "app.services.turn_file_changes.execute_configured_device_command", + AsyncMock( + return_value=_command_result( + { + "success": False, + "status": "artifact_missing", + "error": "turn file changes artifact is missing", + } + ) + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await turn_file_changes_service.get_diff( + db=test_db, + user_id=7, + subtask_id=subtask.id, + ) + test_db.refresh(subtask) + + assert exc_info.value.status_code == 410 + assert subtask.result["file_changes"]["status"] == "artifact_missing" diff --git a/docs/en/user-guide/coding/turn-file-changes.md b/docs/en/user-guide/coding/turn-file-changes.md new file mode 100644 index 0000000000..14f54c2a48 --- /dev/null +++ b/docs/en/user-guide/coding/turn-file-changes.md @@ -0,0 +1,34 @@ +--- +sidebar_position: 5 +--- + +# Review and Revert Turn File Changes + +After a supported coding task completes, Wework displays a file changes card below the corresponding assistant message. The card covers only the changes produced during that user-and-assistant turn, not the accumulated changes for the conversation or workspace. + +## Review the Summary + +The card shows the number of changed files, added and deleted lines, and the file list. It displays the first three files by default and can expand to show the rest. + +Binary files do not have reliable text line counts, so the card displays only the file name and binary status. + +## Review the Full Diff + +Selecting **Review** loads the compressed patch from the original execution device and displays the unified diff grouped by file. + +Review is not available offline. The original device must be online and the device-side artifact must still exist. The summary remains visible while the device is offline, but Review and Revert are disabled. + +## Revert One Turn + +After selecting **Revert** and confirming, the device first checks whether the reverse patch can apply safely: + +- When the check succeeds, only the changes from that turn are reverted. +- If later work changed the same content, the card reports a conflict and the workspace is not modified. +- Revert never force-overwrites later changes. +- A successful revert is idempotent, so repeated requests do not modify files again. + +## Supported Workspaces + +Turn file changes currently support Codex and Claude Code in Git project workspaces. Non-Git directories do not produce a file changes card. + +The complete patch is stored as a compressed artifact in the task directory on the execution device. Backend stores only the file count, line statistics, file list, status, device, and artifact identifier in the assistant message's `Subtask.result`. diff --git a/docs/zh/user-guide/coding/turn-file-changes.md b/docs/zh/user-guide/coding/turn-file-changes.md new file mode 100644 index 0000000000..3b67e9e6a1 --- /dev/null +++ b/docs/zh/user-guide/coding/turn-file-changes.md @@ -0,0 +1,34 @@ +--- +sidebar_position: 5 +--- + +# 查看和撤销单轮文件变更 + +Wework 会在支持的代码任务完成后,在对应的助手消息下方显示本轮文件变更卡片。卡片只统计这一问一答期间由智能体产生的变更,不代表整个会话或工作区的累计变更。 + +## 查看变更摘要 + +卡片显示本轮修改的文件数量、增加和删除行数,以及文件列表。默认显示前三个文件,可以展开查看其余文件。 + +二进制文件没有可靠的文本行数,因此只显示文件名称和“二进制文件”状态。 + +## 审核完整变更 + +点击“审核”后,Wework 会从执行本轮任务的原设备读取压缩 patch,并按文件展示 unified diff。 + +审核不是离线功能。原设备必须在线,并且设备侧 artifact 仍然存在。设备离线时,摘要仍可查看,但审核和撤销操作不可用。 + +## 撤销本轮变更 + +点击“撤销”并确认后,设备会先检查反向 patch 是否能够安全应用: + +- 检查通过时,只撤销这一轮产生的文件变更。 +- 如果后续操作修改了相同内容,状态会变为“存在后续冲突”,工作区不会被修改。 +- 撤销不会强制覆盖后续变更。 +- 已成功撤销的操作具有幂等性,重复请求不会再次修改文件。 + +## 支持范围 + +单轮文件变更目前适用于 Codex 和 Claude Code 的 Git 项目工作区。非 Git 目录不会生成变更卡片。 + +完整 patch 以压缩 artifact 保存在执行设备的任务目录中;Backend 只在助手消息的 `Subtask.result` 中保存文件数量、增删行、文件列表、状态、设备和 artifact 标识。 diff --git a/executor/agents/base.py b/executor/agents/base.py index 9fc2c77009..4f63d01a7d 100644 --- a/executor/agents/base.py +++ b/executor/agents/base.py @@ -10,9 +10,11 @@ import os import threading from collections import OrderedDict +from pathlib import Path from typing import Any, Dict, Optional, Tuple from executor.config import config +from executor.services.turn_file_changes import TurnFileChangeTracker from shared.logger import setup_logger from shared.models import EmitterBuilder, ResponsesAPIEmitter, TransportFactory from shared.models.execution import ExecutionRequest @@ -80,6 +82,7 @@ def __init__( # Emitter is required and must be provided by caller self.emitter: ResponsesAPIEmitter = emitter + self.turn_file_change_tracker: Optional[TurnFileChangeTracker] = None self._progress_task_lock = threading.Lock() self._inflight_progress_task: Optional[asyncio.Task] = None @@ -106,6 +109,7 @@ def update_emitter(self, new_subtask_id: int) -> None: old_subtask_id = self.subtask_id self.subtask_id = new_subtask_id self.task_data.subtask_id = new_subtask_id + self.turn_file_change_tracker = None self.emitter = ( EmitterBuilder() .with_task(self.task_id, new_subtask_id) @@ -125,6 +129,61 @@ def update_emitter(self, new_subtask_id: int) -> None: f"{old_subtask_id} -> {new_subtask_id}" ) + async def start_turn_file_change_tracking(self) -> None: + """Install per-turn Git tracking when the workspace supports it.""" + device_id = getattr(self.task_data, "device_id", None) + if not self.project_path: + logger.info( + "Turn file change tracking skipped for task %s: project_path is empty", + self.task_id, + ) + return + if not device_id: + logger.info( + "Turn file change tracking skipped for task %s: device_id is empty", + self.task_id, + ) + return + tracker = TurnFileChangeTracker( + workspace=Path(self.project_path), + task_id=self.task_id, + subtask_id=self.subtask_id, + executor_home=Path(config.WEGENT_EXECUTOR_HOME), + device_id=device_id, + ) + if await tracker.start(): + self.turn_file_change_tracker = tracker + self.emitter.set_completion_fields_provider(tracker.finalize) + logger.info( + "Turn file change tracking started for task %s, subtask %s, workspace=%s", + self.task_id, + self.subtask_id, + self.project_path, + ) + else: + logger.info( + "Turn file change tracking skipped for task %s: workspace is not a Git repository with HEAD, workspace=%s", + self.task_id, + self.project_path, + ) + + async def abort_turn_file_change_tracking(self) -> None: + """Discard the current turn tracker and release its workspace lock.""" + tracker = self.turn_file_change_tracker + self.turn_file_change_tracker = None + self.emitter.set_completion_fields_provider(None) + if tracker is not None: + await tracker.abort() + + async def collect_turn_file_change_completion_fields(self) -> dict[str, Any]: + """Finalize the current turn tracker and return response.completed fields.""" + tracker = self.turn_file_change_tracker + self.turn_file_change_tracker = None + self.emitter.set_completion_fields_provider(None) + if tracker is None: + return {} + return await tracker.finalize() + async def handle( self, pre_executed: Optional[TaskStatus] = None ) -> Tuple[TaskStatus, Optional[str]]: diff --git a/executor/agents/claude_code/claude_code_agent.py b/executor/agents/claude_code/claude_code_agent.py index da52aa501f..a1dd515a2f 100644 --- a/executor/agents/claude_code/claude_code_agent.py +++ b/executor/agents/claude_code/claude_code_agent.py @@ -863,6 +863,7 @@ async def _async_execute(self) -> TaskStatus: interactive_form_answer ) + await self.start_turn_file_change_tracking() if interactive_form_payload: logger.info( "Sending interactive form answer as tool_result for tool_use_id=%s", @@ -910,6 +911,9 @@ async def _async_execute(self) -> TaskStatus: session_id=self.session_id, mcp_servers=self.options.get("mcp_servers") or self.options.get("mcpServers"), + completion_fields_provider=( + self.collect_turn_file_change_completion_fields + ), ) # Task completed or failed @@ -919,6 +923,9 @@ async def _async_execute(self) -> TaskStatus: logger.warning("No final result received from process_response") result = TaskStatus.RUNNING + if result != TaskStatus.COMPLETED: + await self.abort_turn_file_change_tracking() + # Update task state based on result if result == TaskStatus.COMPLETED: self.task_state_manager.set_state(self.task_id, TaskState.COMPLETED) @@ -935,6 +942,7 @@ async def _async_execute(self) -> TaskStatus: return result except Exception as e: + await self.abort_turn_file_change_tracking() return self._handle_execution_error(e, "async execution") async def _drain_answered_interactive_form_resume_result( diff --git a/executor/agents/claude_code/response_processor.py b/executor/agents/claude_code/response_processor.py index d5c8e57969..f679e94a71 100644 --- a/executor/agents/claude_code/response_processor.py +++ b/executor/agents/claude_code/response_processor.py @@ -3,7 +3,7 @@ import os from dataclasses import asdict from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union from claude_agent_sdk import ClaudeSDKClient from claude_agent_sdk.types import ( @@ -46,6 +46,10 @@ logger = setup_logger("claude_response_processor") +CompletionFieldsProvider = Callable[ + [], Union[Dict[str, Any], Awaitable[Dict[str, Any]]] +] + # Maximum retry count for API errors per session MAX_API_ERROR_RETRIES = 3 @@ -77,6 +81,32 @@ def _standalone_workspace_result_fields(state_manager, content: str) -> dict[str return {"standalone_chat_workspace_path": workspace_path} +async def _collect_completion_fields( + provider: Optional[CompletionFieldsProvider], +) -> Dict[str, Any]: + """Collect optional fields that must be attached to response.completed.""" + if provider is None: + return {} + + try: + fields = provider() + if hasattr(fields, "__await__"): + fields = await fields + if isinstance(fields, dict): + logger.info( + "Collected Claude response completion fields: keys=%s", + sorted(fields.keys()), + ) + return fields + logger.info( + "Claude response completion fields provider returned non-dict: %s", + type(fields).__name__, + ) + except Exception: + logger.exception("Failed to collect Claude response completion fields") + return {} + + def _tool_result_completion(block: ToolResultBlock) -> tuple[str, str | None]: """Translate Claude tool result errors into emitter terminal status.""" if not block.is_error: @@ -95,6 +125,7 @@ async def process_response( task_state_manager=None, session_id: str = None, mcp_servers: Any = None, + completion_fields_provider: Optional[CompletionFieldsProvider] = None, ) -> Union[TaskStatus, str]: """ Process the response messages from Claude @@ -106,6 +137,7 @@ async def process_response( thinking_manager: Optional ThinkingStepManager instance for adding thinking steps task_state_manager: Optional TaskStateManager instance for checking cancellation session_id: Optional session ID for retry operations + completion_fields_provider: Optional provider for response.completed fields Returns: TaskStatus: Processing status @@ -251,6 +283,7 @@ async def process_response( mcp_servers=mcp_servers, deferred_mcp_retry_count=deferred_mcp_retry_count, max_deferred_mcp_retries=MAX_DEFERRED_MCP_RETRIES, + completion_fields_provider=completion_fields_provider, ) if result_status == "RETRY": @@ -874,6 +907,7 @@ async def _process_result_message( mcp_servers: Any = None, deferred_mcp_retry_count: int = 0, max_deferred_mcp_retries: int = MAX_DEFERRED_MCP_RETRIES, + completion_fields_provider: Optional[CompletionFieldsProvider] = None, ) -> Union[TaskStatus, str, None]: """ Process a ResultMessage from Claude @@ -890,6 +924,7 @@ async def _process_result_message( propagated_silent_exit: Silent exit flag propagated from UserMessage tool results propagated_silent_exit_reason: Silent exit reason propagated from UserMessage tool results task_state_manager: Optional TaskStateManager for checking cancellation state + completion_fields_provider: Optional provider for response.completed fields Returns: TaskStatus: Processing status (COMPLETED if successful, CANCELLED, FAILED, or None) @@ -1081,12 +1116,14 @@ async def _process_result_message( if state_manager: state_manager.set_task_status(TaskStatus.COMPLETED.value) + extra_fields = await _collect_completion_fields(completion_fields_provider) await emitter.done( content="", usage=msg.usage, stop_reason=stop_reason or "tool_deferred", silent_exit=True, silent_exit_reason=WAITING_FOR_USER_INPUT_REASON, + **extra_fields, ) return TaskStatus.COMPLETED @@ -1148,6 +1185,9 @@ async def _process_result_message( extra_fields = _standalone_workspace_result_fields( state_manager, content ) + extra_fields.update( + await _collect_completion_fields(completion_fields_provider) + ) await emitter.done( content=content, usage=msg.usage, @@ -1180,6 +1220,9 @@ async def _process_result_message( extra_fields = _standalone_workspace_result_fields( state_manager, result_str ) + extra_fields.update( + await _collect_completion_fields(completion_fields_provider) + ) await emitter.done( content=result_str, usage=msg.usage, diff --git a/executor/agents/codex/codex_agent.py b/executor/agents/codex/codex_agent.py index 727ac00116..2c2b285bcf 100644 --- a/executor/agents/codex/codex_agent.py +++ b/executor/agents/codex/codex_agent.py @@ -143,6 +143,7 @@ async def execute_async(self) -> TaskStatus: input_text = self._build_turn_input() effort, summary = self._build_reasoning_params() + await self.start_turn_file_change_tracking() self._turn = await self._thread.turn( input_text, cwd=self.project_path, @@ -155,10 +156,14 @@ async def execute_async(self) -> TaskStatus: async for event in self._turn.stream(): status = await mapper.handle(event) if status is not None: + if status != TaskStatus.COMPLETED: + await self.abort_turn_file_change_tracking() return status + await self.abort_turn_file_change_tracking() return TaskStatus.FAILED except Exception as exc: logger.exception("CodeXAgent execution failed: %s", exc) + await self.abort_turn_file_change_tracking() await self.emitter.error(str(exc), "execution_error") return TaskStatus.FAILED finally: diff --git a/executor/agents/codex/event_mapper.py b/executor/agents/codex/event_mapper.py index 066228263a..b4feabf4d0 100644 --- a/executor/agents/codex/event_mapper.py +++ b/executor/agents/codex/event_mapper.py @@ -33,6 +33,7 @@ def __init__(self, emitter: ResponsesAPIEmitter): self._pending_agent_delta_text = "" self._pending_agent_deltas: dict[str, str] = {} self._tool_contexts: dict[str, dict[str, Any]] = {} + self.native_turn_diff = "" async def handle(self, event: Any) -> Optional[TaskStatus]: method = getattr(event, "method", "") @@ -73,6 +74,14 @@ async def handle(self, event: Any) -> Optional[TaskStatus]: self._handle_usage(payload) return None + if method == "turn/diff/updated": + self.native_turn_diff = str(getattr(payload, "diff", "") or "") + logger.debug( + "Received native Codex turn diff: bytes=%s", + len(self.native_turn_diff.encode("utf-8")), + ) + return None + if method == "turn/completed": return await self._handle_turn_completed(payload) @@ -362,8 +371,10 @@ def _extract_message_text(cls, item: Any) -> str: @classmethod def _call_id(cls, item: Any) -> str: - value = cls._get(item, "call_id") or cls._get(item, "callId") or cls._get( - item, "id" + value = ( + cls._get(item, "call_id") + or cls._get(item, "callId") + or cls._get(item, "id") ) return str(value or "") diff --git a/executor/services/turn_file_changes.py b/executor/services/turn_file_changes.py new file mode 100644 index 0000000000..2d08db2d4b --- /dev/null +++ b/executor/services/turn_file_changes.py @@ -0,0 +1,420 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Capture Git file changes made during one agent turn.""" + +from __future__ import annotations + +import gzip +import hashlib +import json +import logging +import os +import socket +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +ARTIFACT_VERSION = 1 +LOCK_FILE_NAME = "wegent-turn-file-changes.lock" +logger = logging.getLogger(__name__) + + +class WorkspaceBusyError(RuntimeError): + """Raised when another tracked turn owns the workspace lock.""" + + +@dataclass(frozen=True) +class GitTreeSnapshot: + """A Git tree representing the complete workspace state.""" + + tree_id: str + + +@dataclass(frozen=True) +class TurnFileArtifact: + """Paths and checksum for a persisted turn patch.""" + + artifact_id: str + patch_path: Path + metadata_path: Path + checksum: str + + +@dataclass(frozen=True) +class _ChangedPath: + path: str + change_type: str + old_path: Optional[str] = None + + +def _run_git( + workspace: Path, + *args: str, + env: Optional[dict[str, str]] = None, + text: bool = True, +) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", str(workspace), *args], + check=True, + capture_output=True, + env={**os.environ, **(env or {})}, + text=text, + ) + + +def _is_git_workspace(workspace: Path) -> bool: + result = subprocess.run( + ["git", "-C", str(workspace), "rev-parse", "--is-inside-work-tree"], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 and result.stdout.strip() == "true" + + +def _has_head(workspace: Path) -> bool: + result = subprocess.run( + ["git", "-C", str(workspace), "rev-parse", "--verify", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def _decode_path(value: bytes) -> str: + return value.decode("utf-8", errors="surrogateescape") + + +class TurnFileChangeTracker: + """Create a per-turn Git diff without mutating the repository index.""" + + def __init__( + self, + workspace: Path, + task_id: int, + subtask_id: int, + executor_home: Path, + device_id: Optional[str] = None, + ) -> None: + self.workspace = workspace.resolve() + self.task_id = task_id + self.subtask_id = subtask_id + self.executor_home = executor_home.expanduser().resolve() + self.device_id = device_id + self._before: Optional[GitTreeSnapshot] = None + self._lock_path: Optional[Path] = None + self._active = False + + async def start(self) -> bool: + """Capture the workspace state immediately before an agent turn.""" + if not _is_git_workspace(self.workspace): + return False + self._acquire_lock() + try: + self._before = self._capture_tree() + self._active = True + logger.info( + "Captured turn file change baseline: task_id=%s subtask_id=%s workspace=%s tree=%s", + self.task_id, + self.subtask_id, + self.workspace, + self._before.tree_id, + ) + return True + except Exception: + self._release_lock() + raise + + async def finalize(self) -> dict[str, Any]: + """Persist and summarize changes made since ``start``.""" + if not self._active or self._before is None: + return {} + try: + after = self._capture_tree() + patch = self._create_patch(self._before, after) + if not patch: + logger.info( + "No turn file changes detected: task_id=%s subtask_id=%s workspace=%s before=%s after=%s", + self.task_id, + self.subtask_id, + self.workspace, + self._before.tree_id, + after.tree_id, + ) + return {} + files = self._create_file_summary(self._before, after) + artifact = self._persist_artifact(patch) + logger.info( + "Persisted turn file changes: task_id=%s subtask_id=%s workspace=%s artifact_id=%s files=%s additions=%s deletions=%s", + self.task_id, + self.subtask_id, + self.workspace, + artifact.artifact_id, + len(files), + sum(item["additions"] for item in files), + sum(item["deletions"] for item in files), + ) + return { + "file_changes": { + "version": ARTIFACT_VERSION, + "status": "active", + "artifact_id": artifact.artifact_id, + "device_id": self.device_id, + "workspace_path": str(self.workspace), + "file_count": len(files), + "additions": sum(item["additions"] for item in files), + "deletions": sum(item["deletions"] for item in files), + "files": files, + "reverted_at": None, + } + } + finally: + self._active = False + self._release_lock() + + async def abort(self) -> None: + """Stop tracking and release the workspace lock.""" + self._active = False + self._before = None + self._release_lock() + + def _capture_tree(self) -> GitTreeSnapshot: + with tempfile.TemporaryDirectory(prefix="wegent-turn-index-") as temp_dir: + index_path = Path(temp_dir) / "index" + env = {"GIT_INDEX_FILE": str(index_path)} + if _has_head(self.workspace): + _run_git(self.workspace, "read-tree", "HEAD", env=env) + _run_git(self.workspace, "add", "--all", "--", ".", env=env) + tree_id = _run_git(self.workspace, "write-tree", env=env).stdout.strip() + return GitTreeSnapshot(tree_id=tree_id) + + def _create_patch( + self, + before: GitTreeSnapshot, + after: GitTreeSnapshot, + ) -> bytes: + return _run_git( + self.workspace, + "diff", + "--binary", + "--find-renames", + before.tree_id, + after.tree_id, + text=False, + ).stdout + + def _create_file_summary( + self, + before: GitTreeSnapshot, + after: GitTreeSnapshot, + ) -> list[dict[str, Any]]: + changed_paths = self._changed_paths(before, after) + files = [] + for changed in changed_paths: + additions, deletions, binary = self._line_stats(before, after, changed) + files.append( + { + "old_path": changed.old_path, + "path": changed.path, + "change_type": changed.change_type, + "additions": additions, + "deletions": deletions, + "binary": binary, + } + ) + return sorted(files, key=lambda item: item["path"]) + + def _changed_paths( + self, + before: GitTreeSnapshot, + after: GitTreeSnapshot, + ) -> list[_ChangedPath]: + output = _run_git( + self.workspace, + "diff", + "--name-status", + "-z", + "--find-renames", + before.tree_id, + after.tree_id, + text=False, + ).stdout + tokens = output.split(b"\0") + changes: list[_ChangedPath] = [] + index = 0 + while index < len(tokens) and tokens[index]: + status = _decode_path(tokens[index]) + index += 1 + old_or_current = _decode_path(tokens[index]) + index += 1 + code = status[0] + if code == "R": + new_path = _decode_path(tokens[index]) + index += 1 + changes.append( + _ChangedPath( + old_path=old_or_current, + path=new_path, + change_type="renamed", + ) + ) + continue + change_type = { + "A": "created", + "D": "deleted", + }.get(code, "modified") + changes.append(_ChangedPath(path=old_or_current, change_type=change_type)) + return changes + + def _line_stats( + self, + before: GitTreeSnapshot, + after: GitTreeSnapshot, + changed: _ChangedPath, + ) -> tuple[int, int, bool]: + paths = [changed.path] + if changed.old_path is not None: + paths.insert(0, changed.old_path) + output = _run_git( + self.workspace, + "diff", + "--numstat", + "--find-renames", + before.tree_id, + after.tree_id, + "--", + *paths, + text=False, + ).stdout + additions = 0 + deletions = 0 + binary = False + for line in output.splitlines(): + parts = line.split(b"\t", 2) + if len(parts) < 2: + continue + if parts[0] == b"-" or parts[1] == b"-": + binary = True + continue + additions += int(parts[0]) + deletions += int(parts[1]) + return additions, deletions, binary + + def _persist_artifact(self, patch: bytes) -> TurnFileArtifact: + artifact_id = f"turn-file-changes/{self.task_id}/{self.subtask_id}" + artifact_dir = self.executor_home / "artifacts" / artifact_id + artifact_dir.mkdir(parents=True, exist_ok=True) + patch_path = artifact_dir / "changes.patch.gz" + metadata_path = artifact_dir / "metadata.json" + checksum = hashlib.sha256(patch).hexdigest() + metadata = { + "version": ARTIFACT_VERSION, + "task_id": self.task_id, + "subtask_id": self.subtask_id, + "workspace_path": str(self.workspace), + "checksum": checksum, + } + self._atomic_write(patch_path, gzip.compress(patch)) + self._atomic_write( + metadata_path, + json.dumps(metadata, ensure_ascii=True, sort_keys=True).encode("utf-8"), + ) + return TurnFileArtifact( + artifact_id=artifact_id, + patch_path=patch_path, + metadata_path=metadata_path, + checksum=checksum, + ) + + @staticmethod + def _atomic_write(path: Path, content: bytes) -> None: + with tempfile.NamedTemporaryFile( + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as temp_file: + temp_path = Path(temp_file.name) + temp_file.write(content) + temp_file.flush() + os.fsync(temp_file.fileno()) + temp_path.replace(path) + + def _acquire_lock(self) -> None: + common_dir = _run_git( + self.workspace, + "rev-parse", + "--git-common-dir", + ).stdout.strip() + common_path = Path(common_dir) + if not common_path.is_absolute(): + common_path = self.workspace / common_path + self._lock_path = common_path.resolve() / LOCK_FILE_NAME + lock_metadata = { + "pid": os.getpid(), + "hostname": socket.gethostname(), + "created_at": datetime.now(timezone.utc).isoformat(), + "task_id": self.task_id, + "subtask_id": self.subtask_id, + } + for attempt in range(2): + try: + lock_fd = os.open( + self._lock_path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + with os.fdopen(lock_fd, "w", encoding="utf-8") as lock_file: + json.dump(lock_metadata, lock_file) + return + except FileExistsError: + if attempt == 0 and self._remove_stale_lock(): + continue + raise WorkspaceBusyError( + f"Workspace is busy with another tracked turn: {self.workspace}" + ) + + def _remove_stale_lock(self) -> bool: + if self._lock_path is None: + return False + try: + metadata = json.loads(self._lock_path.read_text(encoding="utf-8")) + if metadata.get("hostname") != socket.gethostname(): + return False + pid = int(metadata["pid"]) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return False + if self._pid_exists(pid): + return False + try: + self._lock_path.unlink() + return True + except FileNotFoundError: + return True + + @staticmethod + def _pid_exists(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + def _release_lock(self) -> None: + if self._lock_path is None: + return + try: + self._lock_path.unlink() + except FileNotFoundError: + pass + finally: + self._lock_path = None diff --git a/executor/tests/agents/test_claude_response_processor.py b/executor/tests/agents/test_claude_response_processor.py index c77c0e2f8e..60f845c434 100644 --- a/executor/tests/agents/test_claude_response_processor.py +++ b/executor/tests/agents/test_claude_response_processor.py @@ -15,6 +15,7 @@ _process_result_message, ) from executor.tasks.task_state_manager import TaskState, TaskStateManager +from shared.models import EmitterBuilder, GeneratorTransport from shared.status import TaskStatus @@ -172,3 +173,91 @@ async def test_cancelled_task_takes_precedence_over_error_result_message(): assert result == TaskStatus.CANCELLED assert state_manager.statuses == [TaskStatus.CANCELLED.value] + + +@pytest.mark.asyncio +async def test_claude_success_finalizes_turn_file_changes(): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + completion_fields = AsyncMock( + return_value={"file_changes": {"version": 1, "file_count": 1}} + ) + emitter.set_completion_fields_provider(completion_fields) + message = ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="session-1", + result="done", + ) + + result = await _process_result_message( + msg=message, + emitter=emitter, + state_manager=DummyStateManager(), + ) + + completed = transport.get_events()[-1][1]["response"] + assert result == TaskStatus.COMPLETED + completion_fields.assert_awaited_once() + assert completed["file_changes"]["file_count"] == 1 + + +@pytest.mark.asyncio +async def test_claude_success_uses_explicit_completion_fields_provider(): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + completion_fields = AsyncMock( + return_value={"file_changes": {"version": 1, "file_count": 1}} + ) + message = ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="session-1", + result="done", + ) + + result = await _process_result_message( + msg=message, + emitter=emitter, + state_manager=DummyStateManager(), + completion_fields_provider=completion_fields, + ) + + completed = transport.get_events()[-1][1]["response"] + assert result == TaskStatus.COMPLETED + completion_fields.assert_awaited_once() + assert completed["file_changes"]["file_count"] == 1 + + +@pytest.mark.asyncio +async def test_claude_failure_does_not_finalize_turn_file_changes(): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + completion_fields = AsyncMock( + return_value={"file_changes": {"version": 1, "file_count": 1}} + ) + emitter.set_completion_fields_provider(completion_fields) + message = ResultMessage( + subtype="error_during_execution", + duration_ms=1, + duration_api_ms=1, + is_error=True, + num_turns=1, + session_id="session-1", + result="failed", + ) + + result = await _process_result_message( + msg=message, + emitter=emitter, + state_manager=DummyStateManager(), + ) + + assert result == TaskStatus.FAILED + completion_fields.assert_not_awaited() diff --git a/executor/tests/agents/test_codex_event_mapper.py b/executor/tests/agents/test_codex_event_mapper.py index 9d322f09bd..a014d43b15 100644 --- a/executor/tests/agents/test_codex_event_mapper.py +++ b/executor/tests/agents/test_codex_event_mapper.py @@ -5,11 +5,14 @@ # SPDX-License-Identifier: Apache-2.0 from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from executor.agents.base import Agent from executor.agents.codex.event_mapper import CodeXEventMapper +from shared.models import EmitterBuilder, GeneratorTransport +from shared.models.execution import ExecutionRequest from shared.status import TaskStatus @@ -28,6 +31,70 @@ def assert_commentary_block( return block +@pytest.mark.asyncio +async def test_agent_installs_turn_file_change_completion_provider(): + emitter = MagicMock() + tracker = MagicMock() + tracker.start = AsyncMock(return_value=True) + tracker.finalize = AsyncMock(return_value={"file_changes": {"file_count": 1}}) + task_data = ExecutionRequest(task_id=1, subtask_id=2, device_id="device-1") + agent = Agent(task_data, emitter) + agent.project_path = "/tmp/project" + + with patch( + "executor.agents.base.TurnFileChangeTracker", + return_value=tracker, + ) as tracker_class: + await agent.start_turn_file_change_tracking() + + tracker_class.assert_called_once() + tracker.start.assert_awaited_once() + emitter.set_completion_fields_provider.assert_called_once_with(tracker.finalize) + assert agent.turn_file_change_tracker is tracker + + +@pytest.mark.asyncio +async def test_codex_completion_includes_turn_file_changes(): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + completion_fields = AsyncMock( + return_value={"file_changes": {"version": 1, "file_count": 1}} + ) + emitter.set_completion_fields_provider(completion_fields) + mapper = CodeXEventMapper(emitter) + + status = await mapper.handle( + SimpleNamespace( + method="turn/completed", + payload=SimpleNamespace( + turn=SimpleNamespace(status=SimpleNamespace(value="completed")) + ), + ) + ) + + completed = transport.get_events()[-1][1]["response"] + assert status == TaskStatus.COMPLETED + completion_fields.assert_awaited_once() + assert completed["file_changes"]["file_count"] == 1 + + +@pytest.mark.asyncio +async def test_codex_records_native_turn_diff_for_diagnostics_only(): + emitter = MagicMock() + mapper = CodeXEventMapper(emitter) + + result = await mapper.handle( + SimpleNamespace( + method="turn/diff/updated", + payload=SimpleNamespace(diff="diff --git a/a.txt b/a.txt\n"), + ) + ) + + assert result is None + assert mapper.native_turn_diff == "diff --git a/a.txt b/a.txt\n" + emitter.done.assert_not_called() + + @pytest.mark.asyncio async def test_codex_event_mapper_streams_final_answer_delta_and_completion(): emitter = SimpleNamespace( diff --git a/executor/tests/services/test_turn_file_changes.py b/executor/tests/services/test_turn_file_changes.py new file mode 100644 index 0000000000..89072de57f --- /dev/null +++ b/executor/tests/services/test_turn_file_changes.py @@ -0,0 +1,291 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import gzip +import hashlib +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from executor.services.turn_file_changes import ( + TurnFileChangeTracker, + WorkspaceBusyError, +) + + +def run_git(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + input=input_bytes, + ).stdout + + +def init_repo(path: Path, *, with_head: bool = True) -> Path: + repo = path / "repo" + repo.mkdir() + run_git(repo, "init", "-q") + run_git(repo, "config", "user.email", "tests@example.com") + run_git(repo, "config", "user.name", "Tests") + if with_head: + write(repo / ".gitkeep", "") + commit_all(repo, "initial") + return repo + + +def write(path: Path, content: str | bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + + +def commit_all(repo: Path, message: str) -> None: + run_git(repo, "add", "--all") + run_git(repo, "commit", "-qm", message) + + +def file_changes(summary: dict) -> dict: + return summary["file_changes"] + + +@pytest.mark.asyncio +async def test_tracker_excludes_changes_that_preexist_the_turn(tmp_path): + repo = init_repo(tmp_path) + write(repo / "existing.txt", "base\n") + commit_all(repo, "tracked") + write(repo / "existing.txt", "user dirty\n") + write(repo / "untracked.txt", "before\n") + + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=10, + subtask_id=20, + executor_home=tmp_path / "executor-home", + ) + assert await tracker.start() is True + + write(repo / "agent.txt", "created by agent\n") + summary = await tracker.finalize() + + assert [item["path"] for item in file_changes(summary)["files"]] == ["agent.txt"] + + +@pytest.mark.asyncio +async def test_tracker_counts_modified_created_deleted_and_renamed_files(tmp_path): + repo = init_repo(tmp_path) + write(repo / "modified.txt", "old\n") + write(repo / "deleted.txt", "gone\n") + write(repo / "old-name.txt", "rename me\n") + commit_all(repo, "fixtures") + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + device_id="device-1", + ) + await tracker.start() + + write(repo / "modified.txt", "new\nextra\n") + write(repo / "created.txt", "created\n") + (repo / "deleted.txt").unlink() + (repo / "old-name.txt").rename(repo / "new-name.txt") + changes = file_changes(await tracker.finalize()) + + by_path = {item["path"]: item for item in changes["files"]} + assert changes["file_count"] == 4 + assert changes["additions"] == 3 + assert changes["deletions"] == 2 + assert by_path["created.txt"]["change_type"] == "created" + assert by_path["deleted.txt"]["change_type"] == "deleted" + assert by_path["modified.txt"]["change_type"] == "modified" + assert by_path["new-name.txt"]["change_type"] == "renamed" + assert by_path["new-name.txt"]["old_path"] == "old-name.txt" + assert changes["device_id"] == "device-1" + + +@pytest.mark.asyncio +async def test_tracker_supports_repository_without_head(tmp_path): + repo = init_repo(tmp_path, with_head=False) + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + await tracker.start() + + write(repo / "first.txt", "first\n") + changes = file_changes(await tracker.finalize()) + + assert changes["files"][0]["path"] == "first.txt" + assert changes["files"][0]["change_type"] == "created" + + +@pytest.mark.asyncio +async def test_tracker_marks_binary_files_without_line_counts(tmp_path): + repo = init_repo(tmp_path) + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + await tracker.start() + + write(repo / "image.bin", b"\x00\x01\x02\x03") + changes = file_changes(await tracker.finalize()) + + assert changes["files"][0] == { + "old_path": None, + "path": "image.bin", + "change_type": "created", + "additions": 0, + "deletions": 0, + "binary": True, + } + + +@pytest.mark.asyncio +async def test_tracker_writes_gzip_patch_and_metadata_checksum(tmp_path): + repo = init_repo(tmp_path) + executor_home = tmp_path / "home" + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=12, + subtask_id=34, + executor_home=executor_home, + ) + await tracker.start() + write(repo / "hello.txt", "hello\n") + + changes = file_changes(await tracker.finalize()) + artifact_dir = executor_home / "artifacts" / "turn-file-changes" / "12" / "34" + patch = gzip.decompress((artifact_dir / "changes.patch.gz").read_bytes()) + metadata = json.loads((artifact_dir / "metadata.json").read_text()) + + assert changes["artifact_id"] == "turn-file-changes/12/34" + assert patch.startswith(b"diff --git a/hello.txt b/hello.txt") + assert metadata["checksum"] == hashlib.sha256(patch).hexdigest() + assert metadata["workspace_path"] == str(repo.resolve()) + + +@pytest.mark.asyncio +async def test_tracker_returns_empty_fields_when_workspace_is_not_git(tmp_path): + workspace = tmp_path / "plain" + workspace.mkdir() + tracker = TurnFileChangeTracker( + workspace=workspace, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + + assert await tracker.start() is False + assert await tracker.finalize() == {} + + +@pytest.mark.asyncio +async def test_tracker_reverse_patch_restores_only_the_turn(tmp_path): + repo = init_repo(tmp_path) + write(repo / "existing.txt", "committed\n") + commit_all(repo, "tracked") + write(repo / "existing.txt", "dirty before\n") + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + await tracker.start() + + write(repo / "agent.txt", "agent\n") + await tracker.finalize() + patch = gzip.decompress( + ( + tmp_path / "home/artifacts/turn-file-changes/1/2/changes.patch.gz" + ).read_bytes() + ) + run_git(repo, "apply", "--reverse", "--binary", "-", input_bytes=patch) + + assert (repo / "existing.txt").read_text() == "dirty before\n" + assert not (repo / "agent.txt").exists() + + +@pytest.mark.asyncio +async def test_tracker_does_not_change_real_git_index(tmp_path): + repo = init_repo(tmp_path) + write(repo / "staged.txt", "base\n") + commit_all(repo, "tracked") + write(repo / "staged.txt", "staged\n") + run_git(repo, "add", "staged.txt") + before_index = run_git(repo, "ls-files", "--stage") + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + + await tracker.start() + write(repo / "agent.txt", "agent\n") + await tracker.finalize() + + assert run_git(repo, "ls-files", "--stage") == before_index + + +@pytest.mark.asyncio +async def test_tracker_rejects_concurrent_turns_in_same_workspace(tmp_path): + repo = init_repo(tmp_path) + first = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + second = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=3, + executor_home=tmp_path / "home", + ) + await first.start() + + with pytest.raises(WorkspaceBusyError): + await second.start() + + await first.abort() + + +@pytest.mark.asyncio +async def test_tracker_removes_stale_same_host_lock(tmp_path): + repo = init_repo(tmp_path) + lock_path = repo / ".git" / "wegent-turn-file-changes.lock" + lock_path.write_text( + json.dumps( + { + "pid": 99999999, + "hostname": os.uname().nodename, + "task_id": 9, + "subtask_id": 9, + } + ) + ) + tracker = TurnFileChangeTracker( + workspace=repo, + task_id=1, + subtask_id=2, + executor_home=tmp_path / "home", + ) + + assert await tracker.start() is True + + await tracker.abort() + assert not lock_path.exists() diff --git a/shared/models/db/subtask.py b/shared/models/db/subtask.py index d5baff6cab..ac1655be0e 100644 --- a/shared/models/db/subtask.py +++ b/shared/models/db/subtask.py @@ -4,6 +4,8 @@ """Subtask database model.""" +from datetime import datetime + from sqlalchemy import JSON, Boolean, Column, DateTime from sqlalchemy import Enum as SQLEnum from sqlalchemy import Integer, String, Text @@ -40,7 +42,11 @@ class Subtask(Base): error_message = Column(Text) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - completed_at = Column(DateTime, nullable=False, default="1970-01-01 00:00:00") + completed_at = Column( + DateTime, + nullable=False, + default=datetime(1970, 1, 1), + ) # Group chat fields sender_type = Column( diff --git a/shared/models/execution.py b/shared/models/execution.py index 4f3810f8c1..3eb9d474c1 100644 --- a/shared/models/execution.py +++ b/shared/models/execution.py @@ -136,6 +136,7 @@ class ExecutionRequest: workspace_source: Optional[str] = None project_workspace_path: Optional[str] = None execution_target_type: Optional[str] = None + device_id: Optional[str] = None # === Git Configuration (from Task) === git_domain: Optional[str] = None diff --git a/shared/models/responses_api_emitter.py b/shared/models/responses_api_emitter.py index fda499b3d3..35c7f3fa79 100644 --- a/shared/models/responses_api_emitter.py +++ b/shared/models/responses_api_emitter.py @@ -25,17 +25,22 @@ await emitter.done(content="Hello world", usage={"input_tokens": 10}) """ +import inspect import json import logging import time import uuid from abc import ABC, abstractmethod -from typing import Any, Callable, Optional, Union +from typing import Any, Awaitable, Callable, Optional, Union from .responses_api import ResponsesAPIEventBuilder, ResponsesAPIStreamEvents logger = logging.getLogger(__name__) SHELL_TOOL_NAMES = {"exec"} +CompletionFieldsProvider = Callable[ + [], + Union[dict[str, Any], Awaitable[dict[str, Any]]], +] __all__ = [ "ResponsesAPIEmitter", @@ -87,6 +92,19 @@ def __init__( self.executor_namespace = executor_namespace self.builder = ResponsesAPIEventBuilder(subtask_id, model) self._tool_contexts: dict[str, dict[str, Any]] = {} + self._completion_fields_provider: Optional[CompletionFieldsProvider] = None + + def set_completion_fields_provider( + self, + provider: Optional[CompletionFieldsProvider], + ) -> None: + """Set a one-shot provider for fields added to the completion event.""" + self._completion_fields_provider = provider + logger.info( + "Response completion fields provider %s for subtask_id=%s", + "set" if provider is not None else "cleared", + self.subtask_id, + ) # ============================================================ # Response Lifecycle Events @@ -144,6 +162,35 @@ async def done( # Flush any buffered events before sending done await self.flush() + provider = self._completion_fields_provider + self._completion_fields_provider = None + logger.info( + "Preparing response.completed for subtask_id=%s completion_provider=%s", + self.subtask_id, + provider is not None, + ) + if provider is not None: + try: + provider_fields = provider() + if inspect.isawaitable(provider_fields): + provider_fields = await provider_fields + if isinstance(provider_fields, dict): + logger.info( + "Collected response completion fields for subtask_id=%s keys=%s", + self.subtask_id, + sorted(provider_fields.keys()), + ) + for key, value in provider_fields.items(): + extra_fields.setdefault(key, value) + else: + logger.info( + "Response completion fields provider returned non-dict for subtask_id=%s: %s", + self.subtask_id, + type(provider_fields).__name__, + ) + except Exception: + logger.exception("Failed to collect response completion fields") + data = self.builder.response_completed( content=content, usage=usage, diff --git a/shared/tests/test_emitter_factory.py b/shared/tests/test_emitter_factory.py index 21ba6e9631..932a2e54db 100644 --- a/shared/tests/test_emitter_factory.py +++ b/shared/tests/test_emitter_factory.py @@ -372,6 +372,69 @@ async def test_send_publishes_to_redis(self): class TestResponsesAPIEmitter: + @pytest.mark.asyncio + async def test_done_merges_completion_provider_fields(self): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + + async def completion_fields(): + return {"file_changes": {"version": 1, "file_count": 2}} + + emitter.set_completion_fields_provider(completion_fields) + + await emitter.done(content="done") + + completed = transport.get_events()[-1][1]["response"] + assert completed["file_changes"] == { + "version": 1, + "file_count": 2, + } + + @pytest.mark.asyncio + async def test_done_ignores_empty_completion_provider_fields(self): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + emitter.set_completion_fields_provider(lambda: {}) + + await emitter.done(content="done") + + completed = transport.get_events()[-1][1]["response"] + assert "file_changes" not in completed + + @pytest.mark.asyncio + async def test_done_logs_provider_failure_and_still_emits_completion(self, caplog): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + + async def fail(): + raise RuntimeError("snapshot failed") + + emitter.set_completion_fields_provider(fail) + + await emitter.done(content="done") + + completed = transport.get_events()[-1][1]["response"] + assert completed["output"][0]["content"][0]["text"] == "done" + assert "Failed to collect response completion fields" in caplog.text + + @pytest.mark.asyncio + async def test_done_consumes_completion_provider_once(self): + transport = GeneratorTransport() + emitter = EmitterBuilder().with_task(1, 2).with_transport(transport).build() + calls = 0 + + async def completion_fields(): + nonlocal calls + calls += 1 + return {"file_changes": {"version": 1}} + + emitter.set_completion_fields_provider(completion_fields) + + await emitter.done(content="first") + await emitter.done(content="second") + + assert calls == 1 + @pytest.mark.asyncio async def test_reasoning_emits_summary_text_delta(self): transport = GeneratorTransport() diff --git a/wework/src/api/http.test.ts b/wework/src/api/http.test.ts index d6576b4a20..b17514c0c6 100644 --- a/wework/src/api/http.test.ts +++ b/wework/src/api/http.test.ts @@ -97,6 +97,32 @@ describe('createHttpClient', () => { }) }) + test('preserves structured error details and codes', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + detail: { + code: 'TURN_FILE_CHANGES_CONFLICT', + message: 'Patch does not apply', + file_changes: { status: 'conflicted' }, + }, + }), + }) + + const client = createHttpClient({ baseUrl: '/api' }) + + await expect(client.post('/subtasks/9/file-changes/revert')).rejects.toMatchObject({ + message: 'Patch does not apply', + status: 409, + errorCode: 'TURN_FILE_CHANGES_CONFLICT', + detail: { + file_changes: { status: 'conflicted' }, + }, + }) + }) + test('posts FormData without forcing a json content type', async () => { localStorage.setItem('auth_token', 'token-1') fetchMock.mockResolvedValueOnce({ diff --git a/wework/src/api/http.ts b/wework/src/api/http.ts index 920aaae2b8..f2356f0467 100644 --- a/wework/src/api/http.ts +++ b/wework/src/api/http.ts @@ -4,12 +4,19 @@ import { redirectToLogin } from '@/features/auth/redirect' export class ApiError extends Error { status: number errorCode?: string | number - - constructor(message: string, status: number, errorCode?: string | number) { + detail?: unknown + + constructor( + message: string, + status: number, + errorCode?: string | number, + detail?: unknown, + ) { super(message) this.name = 'ApiError' this.status = status this.errorCode = errorCode + this.detail = detail } } @@ -33,14 +40,20 @@ async function parseError(response: Response): Promise { const errorText = await response.text() let message = errorText let errorCode: string | number | undefined + let detail: unknown try { const json = JSON.parse(errorText) + detail = json.detail if (typeof json.detail === 'string') { message = json.detail - } else if (json.detail?.error_code) { - message = String(json.detail.error_code) - errorCode = json.detail.error_code + } else if (json.detail && typeof json.detail === 'object') { + if (typeof json.detail.message === 'string') { + message = json.detail.message + } + if (json.detail.error_code || json.detail.code) { + errorCode = json.detail.error_code ?? json.detail.code + } } if (json.error_code) { errorCode = json.error_code @@ -49,7 +62,7 @@ async function parseError(response: Response): Promise { message = errorText || `HTTP ${response.status}` } - return new ApiError(message, response.status, errorCode) + return new ApiError(message, response.status, errorCode, detail) } export function createHttpClient(options: HttpClientOptions): HttpClient { diff --git a/wework/src/api/tasks.ts b/wework/src/api/tasks.ts index 6eb8eb369f..62e2024ddb 100644 --- a/wework/src/api/tasks.ts +++ b/wework/src/api/tasks.ts @@ -5,6 +5,8 @@ import type { TaskArchiveResponse, TaskDetail, TaskListResponse, + TurnFileChangesDiffResponse, + TurnFileChangesRevertResponse, } from '@/types/api' import type { HttpClient } from './http' @@ -40,6 +42,16 @@ export function createTaskApi(client: HttpClient) { getTaskDetail(taskId: number): Promise { return client.get(`/tasks/${taskId}?client_origin=${WEWORK_CLIENT_ORIGIN}`) }, + getTurnFileChangesDiff( + subtaskId: number, + ): Promise { + return client.get(`/subtasks/${subtaskId}/file-changes/diff`) + }, + revertTurnFileChanges( + subtaskId: number, + ): Promise { + return client.post(`/subtasks/${subtaskId}/file-changes/revert`) + }, renameTask(taskId: number, title: string): Promise { return client.put(`/tasks/${taskId}?client_origin=${WEWORK_CLIENT_ORIGIN}`, { title, diff --git a/wework/src/components/chat/FileChangesCard.test.tsx b/wework/src/components/chat/FileChangesCard.test.tsx new file mode 100644 index 0000000000..5631405683 --- /dev/null +++ b/wework/src/components/chat/FileChangesCard.test.tsx @@ -0,0 +1,177 @@ +import '@/i18n' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' +import type { TurnFileChangesSummary } from '@/types/api' +import { FileChangesCard } from './FileChangesCard' +import { parseUnifiedDiff } from './parseUnifiedDiff' + +const summary: TurnFileChangesSummary = { + version: 1, + status: 'active', + artifact_id: 'turn-8-21', + device_id: 'device-1', + workspace_path: '/workspace/project', + file_count: 6, + additions: 107, + deletions: 121, + files: Array.from({ length: 6 }, (_, index) => ({ + path: `src/file-${index + 1}.ts`, + change_type: 'modified', + additions: index + 1, + deletions: index, + binary: false, + })), +} + +function renderCard( + overrides: Partial<{ + summary: TurnFileChangesSummary + deviceOnline: boolean + onLoadDiff: (subtaskId: number) => Promise + onRevert: (subtaskId: number) => Promise + }> = {}, +) { + const onLoadDiff = + overrides.onLoadDiff ?? + vi.fn().mockResolvedValue( + 'diff --git a/src/file-1.ts b/src/file-1.ts\n--- a/src/file-1.ts\n+++ b/src/file-1.ts\n@@ -1 +1 @@\n-old\n+new\n', + ) + const onRevert = + overrides.onRevert ?? + vi.fn().mockResolvedValue({ ...summary, status: 'reverted' }) + + render( + , + ) + return { onLoadDiff, onRevert } +} + +describe('FileChangesCard', () => { + test('shows summary and expands files after the first three', async () => { + renderCard() + + expect(screen.getByText('已编辑 6 个文件')).toBeInTheDocument() + expect(screen.getByText('+107')).toBeInTheDocument() + expect(screen.getByText('-121')).toBeInTheDocument() + expect(screen.getAllByTestId('file-change-row')).toHaveLength(3) + + await userEvent.click(screen.getByTestId('toggle-file-changes-button')) + + expect(screen.getAllByTestId('file-change-row')).toHaveLength(6) + }) + + test('loads diff only when review opens', async () => { + const { onLoadDiff } = renderCard() + + expect(onLoadDiff).not.toHaveBeenCalled() + await userEvent.click(screen.getByTestId('review-file-changes-button')) + + await waitFor(() => expect(onLoadDiff).toHaveBeenCalledWith(21)) + expect(await screen.findAllByText('src/file-1.ts')).toHaveLength(2) + }) + + test('disables review and revert while the owning device is offline', () => { + renderCard({ deviceOnline: false }) + + expect(screen.getByTestId('review-file-changes-button')).toBeDisabled() + expect(screen.getByTestId('revert-file-changes-button')).toBeDisabled() + expect( + screen.getByText('设备离线,无法审核或撤销'), + ).toBeInTheDocument() + }) + + test('confirms before reverting', async () => { + const { onRevert } = renderCard() + + await userEvent.click(screen.getByTestId('revert-file-changes-button')) + expect(onRevert).not.toHaveBeenCalled() + + await userEvent.click( + screen.getByTestId('confirm-revert-file-changes-button'), + ) + await waitFor(() => expect(onRevert).toHaveBeenCalledWith(21)) + }) + + test('shows status without a revert action after a successful revert', () => { + renderCard({ summary: { ...summary, status: 'reverted' } }) + + expect(screen.getByText('已撤销')).toBeInTheDocument() + expect( + screen.queryByTestId('revert-file-changes-button'), + ).not.toBeInTheDocument() + expect(screen.getByTestId('review-file-changes-button')).toBeEnabled() + }) + + test('keeps review available after a revert conflict', () => { + renderCard({ summary: { ...summary, status: 'conflicted' } }) + + expect( + screen.getByText('存在后续冲突,未修改工作区'), + ).toBeInTheDocument() + expect(screen.getByTestId('review-file-changes-button')).toBeEnabled() + expect( + screen.queryByTestId('revert-file-changes-button'), + ).not.toBeInTheDocument() + }) + + test('labels binary files without line counts', () => { + renderCard({ + summary: { + ...summary, + file_count: 1, + additions: 0, + deletions: 0, + files: [ + { + path: 'assets/logo.png', + change_type: 'modified', + additions: 0, + deletions: 0, + binary: true, + }, + ], + }, + }) + + expect(screen.getByText('二进制文件')).toBeInTheDocument() + }) +}) + +describe('parseUnifiedDiff', () => { + test('groups unified diff by file', () => { + expect( + parseUnifiedDiff( + 'diff --git a/a.ts b/a.ts\n+one\ndiff --git a/b.ts b/b.ts\n-two', + ), + ).toEqual([ + { + oldPath: 'a.ts', + path: 'a.ts', + lines: ['diff --git a/a.ts b/a.ts', '+one'], + }, + { + oldPath: 'b.ts', + path: 'b.ts', + lines: ['diff --git a/b.ts b/b.ts', '-two'], + }, + ]) + }) + + test('supports quoted file paths with spaces', () => { + expect( + parseUnifiedDiff( + 'diff --git "a/src/file one.ts" "b/src/file one.ts"\n+one', + )[0], + ).toMatchObject({ + oldPath: 'src/file one.ts', + path: 'src/file one.ts', + }) + }) +}) diff --git a/wework/src/components/chat/FileChangesCard.tsx b/wework/src/components/chat/FileChangesCard.tsx new file mode 100644 index 0000000000..4476d29ef9 --- /dev/null +++ b/wework/src/components/chat/FileChangesCard.tsx @@ -0,0 +1,308 @@ +import { + Check, + ChevronDown, + ChevronUp, + FileDiff, + RotateCcw, + X, +} from 'lucide-react' +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { ApiError } from '@/api/http' +import { Button } from '@/components/ui/button' +import { useEscapeKey } from '@/hooks/useEscapeKey' +import { useTranslation } from '@/hooks/useTranslation' +import type { + TurnFileChangeItem, + TurnFileChangesSummary, +} from '@/types/api' +import { FileChangesReviewDialog } from './FileChangesReviewDialog' + +const DEFAULT_VISIBLE_FILE_COUNT = 3 + +interface FileChangesCardProps { + subtaskId: number + summary: TurnFileChangesSummary + deviceOnline: boolean + onLoadDiff: (subtaskId: number) => Promise + onRevert: (subtaskId: number) => Promise +} + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof ApiError || error instanceof Error) return error.message + return fallback +} + +function FileChangeRow({ file }: { file: TurnFileChangeItem }) { + const { t } = useTranslation('chat') + + return ( +
+ + {file.change_type === 'renamed' && file.old_path + ? `${file.old_path} → ${file.path}` + : file.path} + + {file.binary ? ( + + {t('file_changes.binary_file')} + + ) : ( + + +{file.additions} + -{file.deletions} + + )} +
+ ) +} + +export function FileChangesCard({ + subtaskId, + summary, + deviceOnline, + onLoadDiff, + onRevert, +}: FileChangesCardProps) { + const { t } = useTranslation('chat') + const [expanded, setExpanded] = useState(false) + const [reviewOpen, setReviewOpen] = useState(false) + const [reviewLoading, setReviewLoading] = useState(false) + const [diff, setDiff] = useState('') + const [reviewError, setReviewError] = useState() + const [confirmOpen, setConfirmOpen] = useState(false) + const [reverting, setReverting] = useState(false) + const [actionError, setActionError] = useState() + const hiddenCount = Math.max( + 0, + summary.files.length - DEFAULT_VISIBLE_FILE_COUNT, + ) + const visibleFiles = expanded + ? summary.files + : summary.files.slice(0, DEFAULT_VISIBLE_FILE_COUNT) + const actionsDisabled = + !deviceOnline || summary.status === 'artifact_missing' + const canRevert = summary.status === 'active' + + const openReview = async () => { + setReviewOpen(true) + if (diff || reviewLoading) return + setReviewLoading(true) + setReviewError(undefined) + try { + setDiff(await onLoadDiff(subtaskId)) + } catch (error) { + setReviewError( + getErrorMessage(error, t('file_changes.review_failed')), + ) + } finally { + setReviewLoading(false) + } + } + + const revert = async () => { + setReverting(true) + setActionError(undefined) + try { + await onRevert(subtaskId) + setConfirmOpen(false) + } catch (error) { + setActionError( + getErrorMessage(error, t('file_changes.revert_failed')), + ) + setConfirmOpen(false) + } finally { + setReverting(false) + } + } + + return ( + <> +
+
+ + + +
+

+ {t('file_changes.edited_files', { + count: summary.file_count, + })} +

+

+ +{summary.additions} + -{summary.deletions} +

+
+
+ {summary.status === 'reverted' ? ( + + + {t('file_changes.reverted')} + + ) : null} + + {canRevert ? ( + + ) : null} +
+
+ {summary.status === 'conflicted' ? ( +

+ {t('file_changes.conflicted')} +

+ ) : null} + {summary.status === 'artifact_missing' ? ( +

+ {t('file_changes.artifact_missing')} +

+ ) : null} + {!deviceOnline ? ( +

+ {t('file_changes.device_offline')} +

+ ) : null} + {actionError ? ( +

+ {actionError} +

+ ) : null} +
+ {visibleFiles.map(file => ( + + ))} +
+ {hiddenCount > 0 ? ( + + ) : null} +
+ setReviewOpen(false)} + /> + setConfirmOpen(false)} + onConfirm={() => void revert()} + /> + + ) +} + +function ConfirmRevertDialog({ + open, + submitting, + onClose, + onConfirm, +}: { + open: boolean + submitting: boolean + onClose: () => void + onConfirm: () => void +}) { + const { t } = useTranslation('chat') + useEscapeKey(onClose, open && !submitting) + + if (!open) return null + + return createPortal( +
+
+
+
+

+ {t('file_changes.confirm_revert_title')} +

+

+ {t('file_changes.confirm_revert_description')} +

+
+ +
+
+ + +
+
+
, + document.body, + ) +} diff --git a/wework/src/components/chat/FileChangesReviewDialog.tsx b/wework/src/components/chat/FileChangesReviewDialog.tsx new file mode 100644 index 0000000000..5603074f3d --- /dev/null +++ b/wework/src/components/chat/FileChangesReviewDialog.tsx @@ -0,0 +1,153 @@ +import { ChevronDown, ChevronRight, X } from 'lucide-react' +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { useEscapeKey } from '@/hooks/useEscapeKey' +import { useTranslation } from '@/hooks/useTranslation' +import { cn } from '@/lib/utils' +import { parseUnifiedDiff } from './parseUnifiedDiff' + +interface FileChangesReviewDialogProps { + open: boolean + loading: boolean + diff: string + error?: string + onClose: () => void +} + +export function FileChangesReviewDialog({ + open, + loading, + diff, + error, + onClose, +}: FileChangesReviewDialogProps) { + const { t } = useTranslation('chat') + useEscapeKey(onClose, open) + + const [collapsed, setCollapsed] = useState>(new Set()) + + const toggleSection = (index: number) => { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + } + + if (!open) return null + + const sections = parseUnifiedDiff(diff) + + return createPortal( +
+
e.stopPropagation()} + > +
+

+ {t('file_changes.review_title')} +

+ +
+
+ {loading ? ( +

+ {t('file_changes.loading_diff')} +

+ ) : error ? ( +

+ {error} +

+ ) : sections.length === 0 ? ( +

+ {t('file_changes.empty_diff')} +

+ ) : ( +
+ {sections.map((section, index) => { + const additions = section.lines.filter( + (l) => l.startsWith('+') && !l.startsWith('+++'), + ).length + const deletions = section.lines.filter( + (l) => l.startsWith('-') && !l.startsWith('---'), + ).length + + return ( +
+ + {!collapsed.has(index) && ( +
+                      {section.lines.map((line, lineIndex) => (
+                        
+                          {line || ' '}
+                        
+                      ))}
+                    
+ )} +
+ ); + })} +
+ )} +
+
+
, + document.body, + ) +} diff --git a/wework/src/components/chat/MessageList.test.tsx b/wework/src/components/chat/MessageList.test.tsx index a98d8e5e5e..0f59709070 100644 --- a/wework/src/components/chat/MessageList.test.tsx +++ b/wework/src/components/chat/MessageList.test.tsx @@ -3,8 +3,60 @@ import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, test, vi } from 'vitest' import type { Attachment } from '@/types/api' import { MessageList } from './MessageList' +import '@/i18n' describe('MessageList', () => { + test('renders one assistant turn file changes under its message', () => { + render( + , + ) + + expect(screen.getByTestId('file-changes-card')).toHaveTextContent( + 'src/main.ts', + ) + }) + test('uses compact spacing between messages and hover actions', () => { render( Promise + onRevertFileChanges?: ( + subtaskId: number, + ) => Promise } const USER_MESSAGE_COLLAPSE_LINES = 10 const USER_MESSAGE_COLLAPSE_CHARACTERS = 600 -export function MessageList({ messages }: MessageListProps) { +export function MessageList({ + messages, + devices = [], + onLoadFileChangesDiff, + onRevertFileChanges, +}: MessageListProps) { if (messages.length === 0) { return null } @@ -45,7 +60,12 @@ export function MessageList({ messages }: MessageListProps) { {message.role === 'user' ? ( ) : ( - + )} ))} @@ -332,7 +352,19 @@ function renderUserContent(content: string) { return parts } -function AssistantMessage({ message }: { message: WorkbenchMessage }) { +function AssistantMessage({ + message, + devices, + onLoadFileChangesDiff, + onRevertFileChanges, +}: { + message: WorkbenchMessage + devices: DeviceInfo[] + onLoadFileChangesDiff?: (subtaskId: number) => Promise + onRevertFileChanges?: ( + subtaskId: number, + ) => Promise +}) { const hasBlocks = message.blocks && message.blocks.length > 0 const hasContent = Boolean(message.content) const isStreaming = message.status === 'streaming' @@ -401,6 +433,22 @@ function AssistantMessage({ message }: { message: WorkbenchMessage }) { {message.status === 'failed' && message.error && (

{message.error}

)} + {message.fileChanges && + message.subtaskId && + onLoadFileChangesDiff && + onRevertFileChanges ? ( + + device.device_id === message.fileChanges?.device_id && + device.status === 'online', + )} + onLoadDiff={onLoadFileChangesDiff} + onRevert={onRevertFileChanges} + /> + ) : null} {message.status !== 'streaming' && (hasContent || message.status === 'failed') && ( )} diff --git a/wework/src/components/chat/ScrollableMessageArea.tsx b/wework/src/components/chat/ScrollableMessageArea.tsx index 53eb9f3291..f9ef53c30c 100644 --- a/wework/src/components/chat/ScrollableMessageArea.tsx +++ b/wework/src/components/chat/ScrollableMessageArea.tsx @@ -9,6 +9,10 @@ import { } from 'react' import { useTranslation } from '@/hooks/useTranslation' import { cn } from '@/lib/utils' +import type { + DeviceInfo, + TurnFileChangesSummary, +} from '@/types/api' import type { WorkbenchMessage } from '@/types/workbench' import { MessageList } from './MessageList' @@ -22,6 +26,11 @@ interface ScrollableMessageAreaProps { scrollButtonClassName?: string scrollTestId?: string conversationKey?: string | number | null + devices?: DeviceInfo[] + onLoadFileChangesDiff?: (subtaskId: number) => Promise + onRevertFileChanges?: ( + subtaskId: number, + ) => Promise } export function ScrollableMessageArea({ @@ -31,6 +40,9 @@ export function ScrollableMessageArea({ scrollButtonClassName, scrollTestId = 'chat-message-scroll-area', conversationKey, + devices, + onLoadFileChangesDiff, + onRevertFileChanges, }: ScrollableMessageAreaProps) { const { t } = useTranslation('common') const scrollRef = useRef(null) @@ -219,7 +231,12 @@ export function ScrollableMessageArea({

) : ( - + )} diff --git a/wework/src/components/chat/parseUnifiedDiff.ts b/wework/src/components/chat/parseUnifiedDiff.ts new file mode 100644 index 0000000000..778d1daf17 --- /dev/null +++ b/wework/src/components/chat/parseUnifiedDiff.ts @@ -0,0 +1,28 @@ +export interface DiffFileSection { + oldPath?: string + path: string + lines: string[] +} + +const DIFF_HEADER_PATTERN = /^diff --git "?a\/(.+?)"? "?b\/(.+?)"?$/ + +export function parseUnifiedDiff(diff: string): DiffFileSection[] { + const sections: DiffFileSection[] = [] + let current: DiffFileSection | null = null + + for (const line of diff.split('\n')) { + const match = line.match(DIFF_HEADER_PATTERN) + if (match) { + current = { + oldPath: match[1], + path: match[2], + lines: [line], + } + sections.push(current) + continue + } + current?.lines.push(line) + } + + return sections +} diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index 4ff3fb1dd4..8c5993d13d 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -19,6 +19,7 @@ import type { ProjectWithTasks, TaskDetail, TaskListResponse, + TurnFileChangesSummary, } from '@/types/api' import type { EnvironmentInfo } from '@/types/environment' import type { DeviceUpgradeState } from '@/types/device-events' @@ -95,6 +96,10 @@ interface DesktopWorkbenchLayoutProps { onSendQueuedAsGuidance?: (id: string) => void onEditQueuedMessage?: (id: string) => void onCancelGuidanceMessage?: (id: string) => void + onLoadFileChangesDiff?: (subtaskId: number) => Promise + onRevertFileChanges?: ( + subtaskId: number, + ) => Promise onLogout: () => void } @@ -151,6 +156,8 @@ export function DesktopWorkbenchLayout({ onSendQueuedAsGuidance = () => {}, onEditQueuedMessage = () => {}, onCancelGuidanceMessage = () => {}, + onLoadFileChangesDiff, + onRevertFileChanges, onLogout, }: DesktopWorkbenchLayoutProps) { const { sidebarCollapsed, setSidebarCollapsed } = @@ -372,6 +379,8 @@ export function DesktopWorkbenchLayout({ onSendQueuedAsGuidance={onSendQueuedAsGuidance} onEditQueuedMessage={onEditQueuedMessage} onCancelGuidanceMessage={onCancelGuidanceMessage} + onLoadFileChangesDiff={onLoadFileChangesDiff} + onRevertFileChanges={onRevertFileChanges} topBarLeftActions={ sidebarCollapsed ? ( void onEditQueuedMessage: (id: string) => void onCancelGuidanceMessage: (id: string) => void + onLoadFileChangesDiff?: (subtaskId: number) => Promise + onRevertFileChanges?: ( + subtaskId: number, + ) => Promise topBarLeftActions?: ReactNode } @@ -106,6 +115,8 @@ export function DesktopWorkbenchMain({ onSendQueuedAsGuidance, onEditQueuedMessage, onCancelGuidanceMessage, + onLoadFileChangesDiff, + onRevertFileChanges, topBarLeftActions, }: DesktopWorkbenchMainProps) { const { t } = useTranslation('common') @@ -190,12 +201,15 @@ export function DesktopWorkbenchMain({ conversationKey={currentTask?.id ?? null} className="h-full" scrollTestId="desktop-chat-scroll" - scrollerClassName={hasQueuedComposerRows ? 'pb-72' : 'pb-52'} + scrollerClassName={hasQueuedComposerRows ? 'pb-44' : 'pb-32'} scrollButtonClassName={ hasQueuedComposerRows ? DESKTOP_QUEUED_SCROLL_TO_BOTTOM_BUTTON_CLASS : DESKTOP_SCROLL_TO_BOTTOM_BUTTON_CLASS } + devices={devices} + onLoadFileChangesDiff={onLoadFileChangesDiff} + onRevertFileChanges={onRevertFileChanges} />
void onEditQueuedMessage?: (id: string) => void onCancelGuidanceMessage?: (id: string) => void + onLoadFileChangesDiff?: (subtaskId: number) => Promise + onRevertFileChanges?: ( + subtaskId: number, + ) => Promise onLogout: () => void } @@ -151,6 +156,8 @@ export function MobileWorkbenchLayout({ onSendQueuedAsGuidance = () => {}, onEditQueuedMessage = () => {}, onCancelGuidanceMessage = () => {}, + onLoadFileChangesDiff, + onRevertFileChanges, }: MobileWorkbenchLayoutProps) { const { t } = useTranslation('common') const [drawerOpen, setDrawerOpen] = useState(false) @@ -328,6 +335,9 @@ export function MobileWorkbenchLayout({ conversationKey={state.currentTask?.id ?? null} className="h-full" scrollerClassName="pb-28 pt-16" + devices={state.devices} + onLoadFileChangesDiff={onLoadFileChangesDiff} + onRevertFileChanges={onRevertFileChanges} />
['createGitWorkspaceProject'] } gitApi?: ReturnType - taskApi: Omit, 'searchTasks'> & { + taskApi: Omit< + ReturnType, + 'searchTasks' | 'getTurnFileChangesDiff' | 'revertTurnFileChanges' + > & { searchTasks?: ReturnType['searchTasks'] + getTurnFileChangesDiff?: ReturnType< + typeof createTaskApi + >['getTurnFileChangesDiff'] + revertTurnFileChanges?: ReturnType< + typeof createTaskApi + >['revertTurnFileChanges'] } deviceApi: ReturnType userApi?: ReturnType @@ -217,6 +228,10 @@ export interface WorkbenchContextValue { sendQueuedAsGuidance: (id: string) => Promise editQueuedMessage: (id: string) => void cancelGuidanceMessage: (id: string) => void + loadTurnFileChangesDiff: (subtaskId: number) => Promise + revertTurnFileChanges: ( + subtaskId: number, + ) => Promise } interface WorkbenchProviderProps { @@ -254,6 +269,7 @@ function getTaskRouteKey(taskId: number, projectId?: number): string { interface SubtaskResult { value?: string blocks?: unknown[] + fileChanges?: TurnFileChangesSummary } function isRecord(value: unknown): value is Record { @@ -265,6 +281,7 @@ function getSubtaskResult(result: unknown): SubtaskResult | undefined { return { value: typeof result.value === 'string' ? result.value : undefined, blocks: Array.isArray(result.blocks) ? result.blocks : undefined, + fileChanges: normalizeTurnFileChanges(result.file_changes), } } @@ -403,6 +420,7 @@ function subtaskToMessage(subtask: Subtask): WorkbenchMessage { status: subtask.status === 'FAILED' ? 'failed' : 'done', attachments: getSubtaskAttachments(subtask), blocks: blocks.length > 0 ? blocks : undefined, + fileChanges: result?.fileChanges, createdAt: subtask.created_at, } } @@ -893,6 +911,7 @@ export function WorkbenchProvider({ ? payload.result.value : undefined, blocks: getResultBlocks(payload.subtask_id, payload.result), + fileChanges: normalizeTurnFileChanges(payload.result.file_changes), }) }, onChatError: payload => { @@ -1786,6 +1805,51 @@ export function WorkbenchProvider({ setGuidanceMessages(items => items.filter(item => item.id !== id)) }, []) + const loadTurnFileChangesDiff = useCallback( + async (subtaskId: number) => { + const loadDiff = resolvedServices.taskApi.getTurnFileChangesDiff + if (!loadDiff) throw new Error('File changes review is unavailable') + const response = await loadDiff(subtaskId) + return response.diff + }, + [resolvedServices.taskApi], + ) + + const revertTurnFileChanges = useCallback( + async (subtaskId: number) => { + const revert = resolvedServices.taskApi.revertTurnFileChanges + if (!revert) throw new Error('File changes revert is unavailable') + try { + const response = await revert(subtaskId) + const fileChanges = normalizeTurnFileChanges(response.file_changes) + if (!fileChanges) { + throw new Error('Invalid file changes response') + } + dispatchMessages({ + type: 'file_changes_updated', + subtaskId, + fileChanges, + }) + return fileChanges + } catch (error) { + if (error instanceof ApiError && isRecord(error.detail)) { + const fileChanges = normalizeTurnFileChanges( + error.detail.file_changes, + ) + if (fileChanges) { + dispatchMessages({ + type: 'file_changes_updated', + subtaskId, + fileChanges, + }) + } + } + throw error + } + }, + [resolvedServices.taskApi], + ) + const pauseCurrentResponse = useCallback(async () => { if (!activeAssistantMessage?.subtaskId) return @@ -2038,6 +2102,8 @@ export function WorkbenchProvider({ sendQueuedAsGuidance, editQueuedMessage, cancelGuidanceMessage, + loadTurnFileChangesDiff, + revertTurnFileChanges, } return ( diff --git a/wework/src/features/workbench/messageReducer.test.ts b/wework/src/features/workbench/messageReducer.test.ts index bb7d1df09f..8a112fe5c7 100644 --- a/wework/src/features/workbench/messageReducer.test.ts +++ b/wework/src/features/workbench/messageReducer.test.ts @@ -158,4 +158,50 @@ describe('messageReducer', () => { blocks: [{ id: 'thinking-real', type: 'thinking', status: 'done' }], }) }) + + test('stores file changes on completion and updates them after revert', () => { + const activeFileChanges = { + version: 1 as const, + status: 'active' as const, + artifact_id: 'turn-1', + device_id: 'device-1', + workspace_path: '/workspace/project', + file_count: 1, + additions: 3, + deletions: 1, + files: [ + { + path: 'src/main.ts', + change_type: 'modified' as const, + additions: 3, + deletions: 1, + binary: false, + }, + ], + } + const state = messageReducer( + messageReducer([], { + type: 'assistant_started', + taskId: 1, + subtaskId: 9, + }), + { + type: 'assistant_done', + subtaskId: 9, + fileChanges: activeFileChanges, + }, + ) + const reverted = messageReducer(state, { + type: 'file_changes_updated', + subtaskId: 9, + fileChanges: { + ...activeFileChanges, + status: 'reverted', + reverted_at: '2026-06-11T10:00:00Z', + }, + }) + + expect(state[0].fileChanges).toEqual(activeFileChanges) + expect(reverted[0].fileChanges?.status).toBe('reverted') + }) }) diff --git a/wework/src/features/workbench/messageReducer.ts b/wework/src/features/workbench/messageReducer.ts index 9a437dcf45..159c3772dc 100644 --- a/wework/src/features/workbench/messageReducer.ts +++ b/wework/src/features/workbench/messageReducer.ts @@ -3,6 +3,7 @@ import type { ToolBlockStatus, WorkbenchMessage, } from '@/types/workbench' +import type { TurnFileChangesSummary } from '@/types/api' type ProcessingBlockUpdate = { content?: string @@ -28,6 +29,12 @@ export type MessageAction = subtaskId: number content?: string blocks?: ProcessingBlock[] + fileChanges?: TurnFileChangesSummary + } + | { + type: 'file_changes_updated' + subtaskId: number + fileChanges: TurnFileChangesSummary } | { type: 'assistant_error'; subtaskId: number; error: string } | { type: 'block_created'; subtaskId: number; block: ProcessingBlock } @@ -114,9 +121,16 @@ export function messageReducer( content: action.content ?? message.content, status: 'done' as const, blocks: action.blocks ?? finalizeProcessingBlocks(message.blocks), + fileChanges: action.fileChanges ?? message.fileChanges, } : message ) + case 'file_changes_updated': + return state.map(message => + message.subtaskId === action.subtaskId + ? { ...message, fileChanges: action.fileChanges } + : message + ) case 'assistant_error': return state.map(message => message.subtaskId === action.subtaskId diff --git a/wework/src/features/workbench/turnFileChanges.test.ts b/wework/src/features/workbench/turnFileChanges.test.ts new file mode 100644 index 0000000000..58bb5cb6bf --- /dev/null +++ b/wework/src/features/workbench/turnFileChanges.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest' +import { normalizeTurnFileChanges } from './turnFileChanges' + +const summary = { + version: 1, + status: 'active', + artifact_id: 'turn-8-21', + device_id: 'device-1', + workspace_path: '/workspace/project', + file_count: 1, + additions: 4, + deletions: 2, + files: [ + { + path: 'src/main.ts', + change_type: 'modified', + additions: 4, + deletions: 2, + binary: false, + }, + ], +} + +describe('normalizeTurnFileChanges', () => { + test('normalizes a valid per-turn summary', () => { + expect(normalizeTurnFileChanges(summary)).toEqual(summary) + }) + + test('rejects malformed files and inconsistent file counts', () => { + expect( + normalizeTurnFileChanges({ + ...summary, + file_count: 2, + }), + ).toBeUndefined() + expect( + normalizeTurnFileChanges({ + ...summary, + files: [{ ...summary.files[0], additions: -1 }], + }), + ).toBeUndefined() + }) +}) diff --git a/wework/src/features/workbench/turnFileChanges.ts b/wework/src/features/workbench/turnFileChanges.ts new file mode 100644 index 0000000000..363899acc4 --- /dev/null +++ b/wework/src/features/workbench/turnFileChanges.ts @@ -0,0 +1,107 @@ +import type { + TurnFileChangeItem, + TurnFileChangesStatus, + TurnFileChangesSummary, +} from '@/types/api' + +const STATUSES = new Set([ + 'active', + 'reverted', + 'conflicted', + 'artifact_missing', +]) +const CHANGE_TYPES = new Set([ + 'created', + 'modified', + 'deleted', + 'renamed', +]) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0 +} + +function normalizeFile(value: unknown): TurnFileChangeItem | null { + if (!isRecord(value)) return null + if ( + typeof value.path !== 'string' || + value.path.length === 0 || + typeof value.change_type !== 'string' || + !CHANGE_TYPES.has(value.change_type as TurnFileChangeItem['change_type']) || + !isNonNegativeInteger(value.additions) || + !isNonNegativeInteger(value.deletions) || + typeof value.binary !== 'boolean' + ) { + return null + } + if ( + value.old_path !== undefined && + value.old_path !== null && + typeof value.old_path !== 'string' + ) { + return null + } + + return { + old_path: value.old_path as string | null | undefined, + path: value.path, + change_type: value.change_type as TurnFileChangeItem['change_type'], + additions: value.additions, + deletions: value.deletions, + binary: value.binary, + } +} + +export function normalizeTurnFileChanges( + value: unknown, +): TurnFileChangesSummary | undefined { + if (!isRecord(value) || !Array.isArray(value.files)) return undefined + if ( + value.version !== 1 || + typeof value.status !== 'string' || + !STATUSES.has(value.status as TurnFileChangesStatus) || + typeof value.artifact_id !== 'string' || + value.artifact_id.length === 0 || + typeof value.device_id !== 'string' || + value.device_id.length === 0 || + typeof value.workspace_path !== 'string' || + value.workspace_path.length === 0 || + !isNonNegativeInteger(value.file_count) || + !isNonNegativeInteger(value.additions) || + !isNonNegativeInteger(value.deletions) + ) { + return undefined + } + + const files = value.files.map(normalizeFile) + if ( + files.some(file => file === null) || + files.length !== value.file_count + ) { + return undefined + } + if ( + value.reverted_at !== undefined && + value.reverted_at !== null && + typeof value.reverted_at !== 'string' + ) { + return undefined + } + + return { + version: 1, + status: value.status as TurnFileChangesStatus, + artifact_id: value.artifact_id, + device_id: value.device_id, + workspace_path: value.workspace_path, + file_count: value.file_count, + additions: value.additions, + deletions: value.deletions, + files: files as TurnFileChangeItem[], + reverted_at: value.reverted_at as string | null | undefined, + } +} diff --git a/wework/src/i18n/index.ts b/wework/src/i18n/index.ts index 0db899094a..04fc3bc540 100644 --- a/wework/src/i18n/index.ts +++ b/wework/src/i18n/index.ts @@ -3,14 +3,16 @@ import { initReactI18next } from 'react-i18next' import LanguageDetector from 'i18next-browser-languagedetector' import enCommon from './locales/en/common.json' import zhCommon from './locales/zh-CN/common.json' +import enChat from './locales/en/chat.json' +import zhChat from './locales/zh-CN/chat.json' i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources: { - en: { common: enCommon }, - 'zh-CN': { common: zhCommon }, + en: { chat: enChat, common: enCommon }, + 'zh-CN': { chat: zhChat, common: zhCommon }, }, lng: 'zh-CN', fallbackLng: 'zh-CN', diff --git a/wework/src/i18n/locales/en/chat.json b/wework/src/i18n/locales/en/chat.json new file mode 100644 index 0000000000..ea14309606 --- /dev/null +++ b/wework/src/i18n/locales/en/chat.json @@ -0,0 +1,25 @@ +{ + "file_changes": { + "edited_files": "Edited {{count}} files", + "show_more": "Show {{count}} more files", + "show_less": "Show less", + "review": "Review", + "review_title": "Changes from this turn", + "revert": "Revert", + "confirm_revert_title": "Revert this turn's file changes?", + "confirm_revert_description": "The workspace changes only when the reverse patch applies safely. Later changes are never overwritten.", + "confirm_revert": "Confirm revert", + "cancel": "Cancel", + "close": "Close", + "reverting": "Reverting", + "loading_diff": "Loading changes from the device...", + "empty_diff": "No text changes to display", + "review_failed": "Failed to load file changes", + "revert_failed": "Failed to revert file changes", + "device_offline": "The device is offline. Review and revert are unavailable.", + "reverted": "Reverted", + "conflicted": "Later changes conflict, so the workspace was not modified", + "artifact_missing": "The change artifact is no longer available", + "binary_file": "Binary file" + } +} diff --git a/wework/src/i18n/locales/zh-CN/chat.json b/wework/src/i18n/locales/zh-CN/chat.json new file mode 100644 index 0000000000..08cc23a327 --- /dev/null +++ b/wework/src/i18n/locales/zh-CN/chat.json @@ -0,0 +1,25 @@ +{ + "file_changes": { + "edited_files": "已编辑 {{count}} 个文件", + "show_more": "再显示 {{count}} 个文件", + "show_less": "收起", + "review": "审核", + "review_title": "本轮文件变更", + "revert": "撤销", + "confirm_revert_title": "撤销本轮文件变更?", + "confirm_revert_description": "仅当反向补丁可安全应用时才会修改工作区,不会覆盖后续变更。", + "confirm_revert": "确认撤销", + "cancel": "取消", + "close": "关闭", + "reverting": "撤销中", + "loading_diff": "正在从设备加载变更...", + "empty_diff": "没有可展示的文本变更", + "review_failed": "加载文件变更失败", + "revert_failed": "撤销文件变更失败", + "device_offline": "设备离线,无法审核或撤销", + "reverted": "已撤销", + "conflicted": "存在后续冲突,未修改工作区", + "artifact_missing": "变更文件已不存在", + "binary_file": "二进制文件" + } +} diff --git a/wework/src/pages/WorkbenchPage.tsx b/wework/src/pages/WorkbenchPage.tsx index 324e16b55b..c63203d8f1 100644 --- a/wework/src/pages/WorkbenchPage.tsx +++ b/wework/src/pages/WorkbenchPage.tsx @@ -61,6 +61,8 @@ export function WorkbenchPage() { sendQueuedAsGuidance, editQueuedMessage, cancelGuidanceMessage, + loadTurnFileChangesDiff, + revertTurnFileChanges, } = useWorkbench() const Layout = isMobile ? MobileWorkbenchLayout : DesktopWorkbenchLayout const projectWork = { @@ -128,6 +130,8 @@ export function WorkbenchPage() { onSendQueuedAsGuidance={sendQueuedAsGuidance} onEditQueuedMessage={editQueuedMessage} onCancelGuidanceMessage={cancelGuidanceMessage} + onLoadFileChangesDiff={loadTurnFileChangesDiff} + onRevertFileChanges={revertTurnFileChanges} onLogout={logout} /> ) diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts index d66e8ebcdc..4d73110cd4 100644 --- a/wework/src/types/api.ts +++ b/wework/src/types/api.ts @@ -329,6 +329,44 @@ export interface Subtask { sender_user_name?: string } +export type TurnFileChangesStatus = + | 'active' + | 'reverted' + | 'conflicted' + | 'artifact_missing' + +export interface TurnFileChangeItem { + old_path?: string | null + path: string + change_type: 'created' | 'modified' | 'deleted' | 'renamed' + additions: number + deletions: number + binary: boolean +} + +export interface TurnFileChangesSummary { + version: 1 + status: TurnFileChangesStatus + artifact_id: string + device_id: string + workspace_path: string + file_count: number + additions: number + deletions: number + files: TurnFileChangeItem[] + reverted_at?: string | null +} + +export interface TurnFileChangesDiffResponse { + subtask_id: number + diff: string +} + +export interface TurnFileChangesRevertResponse { + subtask_id: number + file_changes: TurnFileChangesSummary +} + export interface TaskDetail extends Task { subtasks?: Subtask[] } @@ -422,6 +460,7 @@ export type ChatResultPayload = Record & { error?: string reasoning_chunk?: string blocks?: ChatBlock[] + file_changes?: TurnFileChangesSummary } export interface ChatChunkPayload { diff --git a/wework/src/types/workbench.ts b/wework/src/types/workbench.ts index efb436f97b..b146ee123f 100644 --- a/wework/src/types/workbench.ts +++ b/wework/src/types/workbench.ts @@ -1,4 +1,12 @@ -import type { Attachment, DeviceInfo, ProjectWithTasks, Task, Team, User } from './api' +import type { + Attachment, + DeviceInfo, + ProjectWithTasks, + Task, + Team, + TurnFileChangesSummary, + User, +} from './api' export type MessageRole = 'user' | 'assistant' | 'system' export type MessageStatus = 'pending' | 'streaming' | 'done' | 'failed' @@ -37,6 +45,7 @@ export interface WorkbenchMessage { error?: string attachments?: Attachment[] blocks?: ProcessingBlock[] + fileChanges?: TurnFileChangesSummary createdAt: string }