diff --git a/coding-agent/src/coding_agent/__main__.py b/coding-agent/src/coding_agent/__main__.py index c62e7432b..d998e4cff 100644 --- a/coding-agent/src/coding_agent/__main__.py +++ b/coding-agent/src/coding_agent/__main__.py @@ -909,6 +909,63 @@ def remote_workspaces_rm(name: str, workspace_id: str) -> None: click.echo(f"Cleaned workspace {workspace_id}") +@remote.command("diff") +@click.argument("name") +@click.option("--session", "session_id", required=True, help="Remote session ID") +def remote_diff(name: str, session_id: str) -> None: + """Show changed files for a remote session workspace.""" + from coding_agent.remote.client import download_workspace_diff, get_remote + + endpoint = get_remote(name) + diff = download_workspace_diff(endpoint, session_id) + files = diff.get("files") + if not isinstance(files, list): + raise click.ClickException("Remote workspace diff response missing files") + workspace_id = diff.get("workspace_id") + workspace_label = workspace_id if isinstance(workspace_id, str) else "unknown" + additions = diff.get("additions") + deletions = diff.get("deletions") + if not isinstance(additions, int) or not isinstance(deletions, int): + raise click.ClickException("Remote workspace diff response missing totals") + + click.echo( + f"Remote workspace {workspace_label}: {len(files)} files changed, +{additions}/-{deletions}" + ) + for raw_file in files: + if not isinstance(raw_file, dict): + raise click.ClickException("Remote workspace diff file entry is invalid") + path = raw_file.get("path") + status = raw_file.get("status") + if not isinstance(path, str) or not isinstance(status, str): + raise click.ClickException("Remote workspace diff file entry is invalid") + old_path = raw_file.get("old_path") + display_path = ( + f"{old_path} -> {path}" if isinstance(old_path, str) and old_path else path + ) + if raw_file.get("binary") is True: + change_summary = "binary" + else: + file_additions = raw_file.get("additions") + file_deletions = raw_file.get("deletions") + if isinstance(file_additions, int) and isinstance(file_deletions, int): + change_summary = f"+{file_additions}/-{file_deletions}" + else: + change_summary = "+?/-?" + click.echo(f"{status.ljust(9)} {display_path} {change_summary}") + + +@remote.command("patch") +@click.argument("name") +@click.option("--session", "session_id", required=True, help="Remote session ID") +def remote_patch(name: str, session_id: str) -> None: + """Print a unified patch for a remote session workspace.""" + from coding_agent.remote.client import download_workspace_patch, get_remote + + endpoint = get_remote(name) + patch = download_workspace_patch(endpoint, session_id) + click.echo(patch, nl=False) + + @remote.command("download") @click.argument("name") @click.option("--session", "session_id", required=True, help="Remote session ID") diff --git a/coding-agent/src/coding_agent/remote/client.py b/coding-agent/src/coding_agent/remote/client.py index 29e40edac..b02f75b56 100644 --- a/coding-agent/src/coding_agent/remote/client.py +++ b/coding-agent/src/coding_agent/remote/client.py @@ -175,7 +175,7 @@ def get_remote_session(endpoint: RemoteEndpoint, session_id: str) -> dict[str, o f"/sessions/{session_id}", "get remote session", ) - return dict(data) + return data def cancel_remote_session( @@ -269,6 +269,39 @@ def download_workspace_archive( return archive_base64 +def download_workspace_diff( + endpoint: RemoteEndpoint, session_id: str +) -> dict[str, object]: + data = _get_remote_json( + endpoint, + f"/sessions/{session_id}/workspace/diff", + "download remote workspace diff", + ) + files = data.get("files") + additions = data.get("additions") + deletions = data.get("deletions") + if not isinstance(files, list): + raise click.ClickException("Remote workspace diff response missing files") + if not isinstance(additions, int) or not isinstance(deletions, int): + raise click.ClickException("Remote workspace diff response missing totals") + return dict(data) + + +def download_workspace_patch(endpoint: RemoteEndpoint, session_id: str) -> str: + data = _get_remote_json( + endpoint, + f"/sessions/{session_id}/workspace/patch", + "download remote workspace patch", + ) + patch_format = data.get("format") + patch = data.get("patch") + if patch_format != "unified_diff": + raise click.ClickException("Remote workspace patch response has invalid format") + if not isinstance(patch, str): + raise click.ClickException("Remote workspace patch response missing patch") + return patch + + def _get_remote_json( endpoint: RemoteEndpoint, path: str, diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index acab15e82..d225745af 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -940,6 +940,136 @@ def test_remote_repl_requires_repo_or_empty_workspace( ) +def test_remote_diff_prints_changed_files(tmp_path: Path, monkeypatch) -> None: + config_path = tmp_path / "remotes.json" + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, dict[str, str]]] = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "workspace_id": "ws-123", + "additions": 7, + "deletions": 2, + "files": [ + { + "path": "src/app.py", + "status": "modified", + "old_path": None, + "additions": 2, + "deletions": 1, + "binary": False, + }, + { + "path": "new.bin", + "status": "added", + "old_path": None, + "additions": None, + "deletions": None, + "binary": True, + }, + ], + } + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def get(self, path: str) -> FakeResponse: + calls.append((path, self.headers)) + return FakeResponse() + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + + result = runner.invoke( + main, + ["remote", "diff", "dev", "--session", "sess-123"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Remote workspace ws-123: 2 files changed, +7/-2" in result.output + assert "modified src/app.py +2/-1" in result.output + assert "added new.bin binary" in result.output + assert calls == [ + ( + "/sessions/sess-123/workspace/diff", + {"Authorization": "Bearer secret-token"}, + ) + ] + + +def test_remote_patch_prints_unified_diff(tmp_path: Path, monkeypatch) -> None: + config_path = tmp_path / "remotes.json" + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, dict[str, str]]] = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "workspace_id": "ws-123", + "format": "unified_diff", + "patch": "diff --git a/README.md b/README.md\n+hello\n", + } + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def get(self, path: str) -> FakeResponse: + calls.append((path, self.headers)) + return FakeResponse() + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + + result = runner.invoke( + main, + ["remote", "patch", "dev", "--session", "sess-123"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert result.output == "diff --git a/README.md b/README.md\n+hello\n" + assert calls == [ + ( + "/sessions/sess-123/workspace/patch", + {"Authorization": "Bearer secret-token"}, + ) + ] + + def test_remote_repl_with_repo_uploads_snapshot_and_downloads_workspace( tmp_path: Path, monkeypatch ) -> None: