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
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Goal:
Add the missing ADR-0019 remote approval prompt choices so remote attach/repl users can approve once, approve for the session, or reject with feedback from the CLI.

Scope:
- Extend `src/coding_agent/remote/client.py` remote approval prompting to capture session-scope approval and rejection feedback.
- Add focused CLI regression tests for the remote approval request flow.
- Keep the change local to the remote client approval UX without changing server approval semantics.

Out of scope:
- Do not change HTTP approval schema or session-manager approval coordination.
- Do not add retries, validation loops, or broader remote attach UX refactors.

Context:
- ADRs:
- `docs/adr/0019-remote-client-cloud-workspace-deployment.md`
- Relevant files:
- `src/coding_agent/remote/client.py`
- `src/coding_agent/ui/approval_prompt.py`
- `tests/cli/test_remote_client.py`
- `tests/ui/test_http_server.py`

Target tests:
- `uv run pytest tests/cli/test_remote_client.py -k "approval_request or formats_approval" -v`
- `uv run pytest tests/ui/test_http_server.py -k "approval and session" -v`
- `uv run basedpyright --level error src/coding_agent/remote/client.py tests/cli/test_remote_client.py`

Loop policy:
- Engineer implements the smallest correct change and runs the target tests.
- Reviewer reviews only the resulting diff and affected tests.
- Reviewer reports only P1/P2 findings.
- Engineer fixes only accepted P1/P2 findings and reruns the same target tests.
- Verifier reruns the exact target tests and reports pass/fail only.

Stop conditions:
- At most one review/fix/retest cycle.
- Escalate architectural redirection or scope expansion to the human.
- Ignore non-blocking optimization suggestions.
26 changes: 20 additions & 6 deletions coding-agent/src/coding_agent/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,27 @@ def _prompt_for_approval(payload: dict[str, object]) -> _ApprovalDecision:
sort_keys=True,
)
)
# TODO(adr-0019): Extend the remote approval prompt to capture session-scope
# approvals and user feedback instead of the current yes/no confirmation.
approved = click.confirm(
f"Approve remote tool request {tool_name}?",
default=False,
click.echo(
"[y]=approve [a]=approve all (session) [n]=reject [r]=reject with reason"
)
return _ApprovalDecision(approved=approved)
choice = cast(
str, click.prompt("→", default="n", show_default=False)
).strip().lower()
if choice in ("y", "yes"):
return _ApprovalDecision(approved=True, scope="once")
if choice in ("a", "all"):
return _ApprovalDecision(approved=True, scope="session")
if choice in ("r", "reason"):
feedback = cast(
str,
click.prompt("Reason", default="Rejected by user", show_default=False),
)
return _ApprovalDecision(
approved=False,
feedback=feedback.strip() or "Rejected by user",
scope="once",
)
return _ApprovalDecision(approved=False, feedback="Rejected by user", scope="once")


def _end_inline_stream_line(line_open: bool) -> bool:
Expand Down
96 changes: 87 additions & 9 deletions coding-agent/tests/cli/test_remote_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ def fake_stream_prompt(

def test_remote_approval_request_prompts_before_submitting_decision(monkeypatch) -> None:
approvals: list[dict[str, object] | None] = []
prompts: list[tuple[str, bool]] = []
prompts: list[str] = []

class FakeResponse:
def raise_for_status(self) -> None:
Expand All @@ -847,12 +847,13 @@ def post(
approvals.append(json)
return FakeResponse()

def fake_confirm(text: str, default: bool) -> bool:
prompts.append((text, default))
return False
def fake_prompt(text: str, default: str | None = None, show_default: bool = True) -> str:
del default, show_default
prompts.append(text)
return "a"

monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient)
monkeypatch.setattr("coding_agent.remote.client.click.confirm", fake_confirm)
monkeypatch.setattr("coding_agent.remote.client.click.prompt", fake_prompt)

from coding_agent.remote.client import handle_sse_event

Expand All @@ -875,9 +876,79 @@ def fake_confirm(text: str, default: bool) -> bool:

assert status is None
assert line_open is False
assert prompts == [("Approve remote tool request bash_run?", False)]
assert prompts == ["→"]
assert approvals == [
{"request_id": "req-1", "approved": False, "scope": "once"}
{"request_id": "req-1", "approved": True, "scope": "session"}
]


def test_remote_approval_request_can_reject_with_reason(monkeypatch) -> None:
approvals: list[dict[str, object] | None] = []
prompts: list[str] = []

class FakeResponse:
def raise_for_status(self) -> None:
return None

def json(self) -> dict[str, object]:
return {"status": "ok"}

class FakeClient:
def __init__(self, **kwargs: object) -> None:
del kwargs

def __enter__(self) -> FakeClient:
return self

def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
return None

def post(
self, path: str, json: dict[str, object] | None = None
) -> FakeResponse:
del path
approvals.append(json)
return FakeResponse()

answers = iter(["r", "Need a safer command"])

def fake_prompt(text: str, default: str | None = None, show_default: bool = True) -> str:
del default, show_default
prompts.append(text)
return next(answers)

monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient)
monkeypatch.setattr("coding_agent.remote.client.click.prompt", fake_prompt)

from coding_agent.remote.client import handle_sse_event

status, line_open = handle_sse_event(
base_url="http://agent.example",
session_id="sess-approval",
headers={},
event="ApprovalRequest",
data=json.dumps(
{
"request_id": "req-1",
"tool_call": {
"tool_name": "bash_run",
"arguments": {"command": "rm -rf scratch"},
"call_id": "call-1",
},
}
),
)

assert status is None
assert line_open is False
assert prompts == ["→", "Reason"]
assert approvals == [
{
"request_id": "req-1",
"approved": False,
"scope": "once",
"feedback": "Need a safer command",
}
]


Expand Down Expand Up @@ -912,7 +983,8 @@ def post(

monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient)
monkeypatch.setattr(
"coding_agent.remote.client.click.confirm", lambda text, default: False
"coding_agent.remote.client.click.prompt",
lambda text, default=None, show_default=True: "n",
)

from coding_agent.remote.client import handle_sse_event
Expand Down Expand Up @@ -949,12 +1021,18 @@ def post(
assert status is None
assert line_open is False
assert approvals == [
{"request_id": "req-1", "approved": False, "scope": "once"}
{
"request_id": "req-1",
"approved": False,
"scope": "once",
"feedback": "Rejected by user",
}
]
assert capsys.readouterr().out == (
"partial\n"
"[approval] Remote tool request bash_run\n"
'{\n "command": "rm -rf scratch"\n}\n'
"[y]=approve [a]=approve all (session) [n]=reject [r]=reject with reason\n"
)


Expand Down