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
96 changes: 96 additions & 0 deletions coding-agent/src/coding_agent/ui/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,17 @@
HealthResponse,
ReadinessResponse,
SessionListResponse,
SessionResultResponse,
SessionSummaryResponse,
PublishSessionRequest,
PublishSessionResponse,
WorkspaceArchiveResponse,
WorkspaceArchiveManifestResponse,
WorkspaceCleanupResponse,
WorkspaceDiffResponse,
WorkspaceGcResponse,
WorkspaceListResponse,
WorkspacePatchResponse,
WorkspaceSummarySchema,
)
from coding_agent.ui.auth import AuthContext, auth_context_from_headers, verify_api_key
Expand Down Expand Up @@ -1682,6 +1687,97 @@ async def get_session(
return SessionSummaryResponse(**session.as_dict())


async def _get_visible_session(
session_id: str,
auth_context: AuthContext | None,
) -> Session:
try:
session = await session_manager.get_session_async(session_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="Session not found") from exc

if not _auth_context_can_access_session(auth_context, session):
raise HTTPException(status_code=404, detail="Session not found")
return session


@app.get("/sessions/{session_id}/result", response_model=SessionResultResponse)
@limiter.limit(RateLimits.GET_SESSION)
async def get_session_result(
request: Request,
session_id: str,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> SessionResultResponse:
del request
session = await _get_visible_session(session_id, auth_context)
summary = session.as_dict()
return SessionResultResponse(
session_id=session.id,
status=summary["status"],
turn_status=summary["turn_status"],
turn_id=session.current_turn_id,
workspace_id=summary["workspace_id"],
origin=session.origin,
provider_name=session.provider_name,
model_name=session.model_name,
final_answer=None,
verification_summary=None,
failure_details=None,
)


@app.get(
"/sessions/{session_id}/workspace/diff",
response_model=WorkspaceDiffResponse,
)
@limiter.limit(RateLimits.GET_SESSION)
async def get_session_workspace_diff(
request: Request,
session_id: str,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> WorkspaceDiffResponse:
del request
_ = await _get_visible_session(session_id, auth_context)
raise HTTPException(
status_code=501,
detail="workspace diff is not implemented yet",
)


@app.get(
"/sessions/{session_id}/workspace/patch",
response_model=WorkspacePatchResponse,
)
@limiter.limit(RateLimits.GET_SESSION)
async def get_session_workspace_patch(
request: Request,
session_id: str,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> WorkspacePatchResponse:
del request
_ = await _get_visible_session(session_id, auth_context)
raise HTTPException(
status_code=501,
detail="workspace patch export is not implemented yet",
)


@app.post("/sessions/{session_id}/publish", response_model=PublishSessionResponse)
@limiter.limit(RateLimits.CLOSE_SESSION)
async def publish_session_result(
request: Request,
session_id: str,
body: PublishSessionRequest,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> PublishSessionResponse:
del request, body
_ = await _get_visible_session(session_id, auth_context)
raise HTTPException(
status_code=501,
detail="remote result publication is not implemented yet",
)


async def _export_session_workspace_archive(
session_id: str,
) -> WorkspaceArchiveResponse:
Expand Down
56 changes: 56 additions & 0 deletions coding-agent/src/coding_agent/ui/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,62 @@ class CancelSessionResponse(BaseModel):
status: Literal["idle", "cancelling", "cancelled", "failed"]


class SessionResultResponse(BaseModel):
session_id: str
status: Literal[
"created", "running", "waiting_approval", "completed", "failed", "closed"
]
turn_status: Literal["idle", "running", "cancelling", "cancelled", "failed"]
turn_id: str | None = None
workspace_id: str | None = None
origin: dict[str, str] | None = None
provider_name: str | None = None
model_name: str | None = None
final_answer: str | None = None
verification_summary: str | None = None
failure_details: str | None = None


class WorkspaceDiffFileSchema(BaseModel):
path: str
status: Literal["added", "modified", "deleted", "renamed", "binary", "unknown"]
old_path: str | None = None
additions: int | None = None
deletions: int | None = None
binary: bool = False


class WorkspaceDiffResponse(BaseModel):
session_id: str
workspace_id: str | None = None
files: list[WorkspaceDiffFileSchema]
additions: int
deletions: int


class WorkspacePatchResponse(BaseModel):
session_id: str
workspace_id: str | None = None
format: Literal["unified_diff"]
patch: str


class PublishSessionRequest(BaseModel):
mode: Literal["branch", "pr"]
branch_name: str | None = Field(None, min_length=1, max_length=200)


class PublishSessionResponse(BaseModel):
session_id: str
mode: Literal["branch", "pr"]
status: Literal["published", "unsupported", "failed"]
branch_name: str | None = None
commit_sha: str | None = None
remote_url: str | None = None
pr_url: str | None = None
error: str | None = None


class CheckpointMetadataResponse(BaseModel):
checkpoint_id: str
tape_id: str
Expand Down
115 changes: 115 additions & 0 deletions coding-agent/tests/ui/test_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3351,6 +3351,121 @@ async def test_get_session_success(self, client):
assert "pending_approval" in data


class TestRemoteResultPublicationContract:
async def test_session_result_returns_stable_contract_for_existing_session(
self, client: AsyncClient
) -> None:
create_resp = await client.post(
"/sessions",
json={
"execution_binding": {
"kind": "cloud",
"workspace_url": "docker://agent-ws-result/workspace",
"workspace_id": "ws-result",
},
"provider": "openai",
"model": "result-model",
"max_steps": 5,
},
)
session_id = create_resp.json()["session_id"]

response = await client.get(f"/sessions/{session_id}/result")

assert response.status_code == 200
data = response.json()
assert data == {
"session_id": session_id,
"status": "created",
"turn_status": "idle",
"turn_id": None,
"workspace_id": "ws-result",
"origin": {"channel": "http", "binding_kind": "cloud"},
"provider_name": "openai",
"model_name": "result-model",
"final_answer": None,
"verification_summary": None,
"failure_details": None,
}

async def test_session_result_hides_other_user_session(
self, client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(
"CODING_AGENT_SERVER_CONFIG", str(_write_auth_config(tmp_path))
)
monkeypatch.setattr(settings, "http_api_key", None)

created = await client.post(
"/sessions",
headers={"Authorization": "Bearer admin-token"},
json={},
)

response = await client.get(
f"/sessions/{created.json()['session_id']}/result",
headers={"Authorization": "Bearer user-token-a"},
)

assert response.status_code == 404

@pytest.mark.parametrize(
("method", "path_suffix", "json_body", "detail"),
[
(
"GET",
"/workspace/diff",
None,
"workspace diff is not implemented yet",
),
(
"GET",
"/workspace/patch",
None,
"workspace patch export is not implemented yet",
),
(
"POST",
"/publish",
{"mode": "branch"},
"remote result publication is not implemented yet",
),
],
)
async def test_remote_result_publication_operations_fail_closed(
self,
client: AsyncClient,
method: str,
path_suffix: str,
json_body: dict[str, object] | None,
detail: str,
) -> None:
create_resp = await client.post("/sessions", json={})
session_id = create_resp.json()["session_id"]

response = await client.request(
method,
f"/sessions/{session_id}{path_suffix}",
json=json_body,
)

assert response.status_code == 501
assert response.json()["detail"] == detail

async def test_remote_result_publication_operations_return_404_for_missing_session(
self, client: AsyncClient
) -> None:
result = await client.get("/sessions/missing/result")
diff = await client.get("/sessions/missing/workspace/diff")
patch = await client.get("/sessions/missing/workspace/patch")
publish = await client.post("/sessions/missing/publish", json={"mode": "pr"})

assert result.status_code == 404
assert diff.status_code == 404
assert patch.status_code == 404
assert publish.status_code == 404


class TestCancelSession:
async def test_cancel_session_turn_returns_cancelling_for_active_turn(self, client):
create_resp = await client.post("/sessions", json={})
Expand Down