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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions coding-agent/src/coding_agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
35 changes: 34 additions & 1 deletion coding-agent/src/coding_agent/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions coding-agent/tests/cli/test_remote_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down