From 3adc7fa75c6ad130d7061fc822658338281b242a Mon Sep 17 00:00:00 2001 From: Rasaboun <40967731+Rasaboun@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:57:44 +0100 Subject: [PATCH 1/3] fix(python): add timeout parameter to generated RPC methods Every generated async RPC method now accepts an optional `timeout` keyword argument that is forwarded to `JsonRpcClient.request()`. This lets callers override the default 30s timeout for long-running RPCs like `session.fleet.start` without bypassing the typed API. Fixes #539 --- python/copilot/generated/rpc.py | 90 ++++++++++++++++++--------------- python/test_rpc_timeout.py | 48 ++++++++++++++++++ scripts/codegen/python.ts | 25 ++++++--- 3 files changed, 114 insertions(+), 49 deletions(-) create mode 100644 python/test_rpc_timeout.py diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 27a2bca2..4f20f8f6 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -3,12 +3,20 @@ Generated from: api.schema.json """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..jsonrpc import JsonRpcClient + +def _timeout_kwargs(timeout: Optional[float]) -> dict: + """Build keyword arguments for optional timeout forwarding.""" + if timeout is not None: + return {"timeout": timeout} + return {} + + from dataclasses import dataclass from typing import Any, Optional, List, Dict, TypeVar, Type, cast, Callable from enum import Enum @@ -1150,25 +1158,25 @@ class ModelsApi: def __init__(self, client: "JsonRpcClient"): self._client = client - async def list(self) -> ModelsListResult: - return ModelsListResult.from_dict(await self._client.request("models.list", {})) + async def list(self, *, timeout: Optional[float] = None) -> ModelsListResult: + return ModelsListResult.from_dict(await self._client.request("models.list", {}, **_timeout_kwargs(timeout))) class ToolsApi: def __init__(self, client: "JsonRpcClient"): self._client = client - async def list(self, params: ToolsListParams) -> ToolsListResult: + async def list(self, params: ToolsListParams, *, timeout: Optional[float] = None) -> ToolsListResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} - return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict)) + return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict, **_timeout_kwargs(timeout))) class AccountApi: def __init__(self, client: "JsonRpcClient"): self._client = client - async def get_quota(self) -> AccountGetQuotaResult: - return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {})) + async def get_quota(self, *, timeout: Optional[float] = None) -> AccountGetQuotaResult: + return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {}, **_timeout_kwargs(timeout))) class ServerRpc: @@ -1179,9 +1187,9 @@ def __init__(self, client: "JsonRpcClient"): self.tools = ToolsApi(client) self.account = AccountApi(client) - async def ping(self, params: PingParams) -> PingResult: + async def ping(self, params: PingParams, *, timeout: Optional[float] = None) -> PingResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} - return PingResult.from_dict(await self._client.request("ping", params_dict)) + return PingResult.from_dict(await self._client.request("ping", params_dict, **_timeout_kwargs(timeout))) class ModelApi: @@ -1189,13 +1197,13 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def get_current(self) -> SessionModelGetCurrentResult: - return SessionModelGetCurrentResult.from_dict(await self._client.request("session.model.getCurrent", {"sessionId": self._session_id})) + async def get_current(self, *, timeout: Optional[float] = None) -> SessionModelGetCurrentResult: + return SessionModelGetCurrentResult.from_dict(await self._client.request("session.model.getCurrent", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def switch_to(self, params: SessionModelSwitchToParams) -> SessionModelSwitchToResult: + async def switch_to(self, params: SessionModelSwitchToParams, *, timeout: Optional[float] = None) -> SessionModelSwitchToResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionModelSwitchToResult.from_dict(await self._client.request("session.model.switchTo", params_dict)) + return SessionModelSwitchToResult.from_dict(await self._client.request("session.model.switchTo", params_dict, **_timeout_kwargs(timeout))) class ModeApi: @@ -1203,13 +1211,13 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def get(self) -> SessionModeGetResult: - return SessionModeGetResult.from_dict(await self._client.request("session.mode.get", {"sessionId": self._session_id})) + async def get(self, *, timeout: Optional[float] = None) -> SessionModeGetResult: + return SessionModeGetResult.from_dict(await self._client.request("session.mode.get", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def set(self, params: SessionModeSetParams) -> SessionModeSetResult: + async def set(self, params: SessionModeSetParams, *, timeout: Optional[float] = None) -> SessionModeSetResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionModeSetResult.from_dict(await self._client.request("session.mode.set", params_dict)) + return SessionModeSetResult.from_dict(await self._client.request("session.mode.set", params_dict, **_timeout_kwargs(timeout))) class PlanApi: @@ -1217,16 +1225,16 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def read(self) -> SessionPlanReadResult: - return SessionPlanReadResult.from_dict(await self._client.request("session.plan.read", {"sessionId": self._session_id})) + async def read(self, *, timeout: Optional[float] = None) -> SessionPlanReadResult: + return SessionPlanReadResult.from_dict(await self._client.request("session.plan.read", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def update(self, params: SessionPlanUpdateParams) -> SessionPlanUpdateResult: + async def update(self, params: SessionPlanUpdateParams, *, timeout: Optional[float] = None) -> SessionPlanUpdateResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionPlanUpdateResult.from_dict(await self._client.request("session.plan.update", params_dict)) + return SessionPlanUpdateResult.from_dict(await self._client.request("session.plan.update", params_dict, **_timeout_kwargs(timeout))) - async def delete(self) -> SessionPlanDeleteResult: - return SessionPlanDeleteResult.from_dict(await self._client.request("session.plan.delete", {"sessionId": self._session_id})) + async def delete(self, *, timeout: Optional[float] = None) -> SessionPlanDeleteResult: + return SessionPlanDeleteResult.from_dict(await self._client.request("session.plan.delete", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) class WorkspaceApi: @@ -1234,18 +1242,18 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def list_files(self) -> SessionWorkspaceListFilesResult: - return SessionWorkspaceListFilesResult.from_dict(await self._client.request("session.workspace.listFiles", {"sessionId": self._session_id})) + async def list_files(self, *, timeout: Optional[float] = None) -> SessionWorkspaceListFilesResult: + return SessionWorkspaceListFilesResult.from_dict(await self._client.request("session.workspace.listFiles", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def read_file(self, params: SessionWorkspaceReadFileParams) -> SessionWorkspaceReadFileResult: + async def read_file(self, params: SessionWorkspaceReadFileParams, *, timeout: Optional[float] = None) -> SessionWorkspaceReadFileResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionWorkspaceReadFileResult.from_dict(await self._client.request("session.workspace.readFile", params_dict)) + return SessionWorkspaceReadFileResult.from_dict(await self._client.request("session.workspace.readFile", params_dict, **_timeout_kwargs(timeout))) - async def create_file(self, params: SessionWorkspaceCreateFileParams) -> SessionWorkspaceCreateFileResult: + async def create_file(self, params: SessionWorkspaceCreateFileParams, *, timeout: Optional[float] = None) -> SessionWorkspaceCreateFileResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionWorkspaceCreateFileResult.from_dict(await self._client.request("session.workspace.createFile", params_dict)) + return SessionWorkspaceCreateFileResult.from_dict(await self._client.request("session.workspace.createFile", params_dict, **_timeout_kwargs(timeout))) class FleetApi: @@ -1253,10 +1261,10 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def start(self, params: SessionFleetStartParams) -> SessionFleetStartResult: + async def start(self, params: SessionFleetStartParams, *, timeout: Optional[float] = None) -> SessionFleetStartResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict)) + return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict, **_timeout_kwargs(timeout))) class AgentApi: @@ -1264,19 +1272,19 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def list(self) -> SessionAgentListResult: - return SessionAgentListResult.from_dict(await self._client.request("session.agent.list", {"sessionId": self._session_id})) + async def list(self, *, timeout: Optional[float] = None) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(await self._client.request("session.agent.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def get_current(self) -> SessionAgentGetCurrentResult: - return SessionAgentGetCurrentResult.from_dict(await self._client.request("session.agent.getCurrent", {"sessionId": self._session_id})) + async def get_current(self, *, timeout: Optional[float] = None) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(await self._client.request("session.agent.getCurrent", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def select(self, params: SessionAgentSelectParams) -> SessionAgentSelectResult: + async def select(self, params: SessionAgentSelectParams, *, timeout: Optional[float] = None) -> SessionAgentSelectResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionAgentSelectResult.from_dict(await self._client.request("session.agent.select", params_dict)) + return SessionAgentSelectResult.from_dict(await self._client.request("session.agent.select", params_dict, **_timeout_kwargs(timeout))) - async def deselect(self) -> SessionAgentDeselectResult: - return SessionAgentDeselectResult.from_dict(await self._client.request("session.agent.deselect", {"sessionId": self._session_id})) + async def deselect(self, *, timeout: Optional[float] = None) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(await self._client.request("session.agent.deselect", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) class CompactionApi: @@ -1284,8 +1292,8 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def compact(self) -> SessionCompactionCompactResult: - return SessionCompactionCompactResult.from_dict(await self._client.request("session.compaction.compact", {"sessionId": self._session_id})) + async def compact(self, *, timeout: Optional[float] = None) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(await self._client.request("session.compaction.compact", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) class SessionRpc: diff --git a/python/test_rpc_timeout.py b/python/test_rpc_timeout.py new file mode 100644 index 00000000..d009a366 --- /dev/null +++ b/python/test_rpc_timeout.py @@ -0,0 +1,48 @@ +"""Tests for timeout parameter on generated RPC methods.""" +from unittest.mock import AsyncMock + +import pytest + +from copilot.generated.rpc import ( + FleetApi, + Mode, + ModeApi, + SessionFleetStartParams, + SessionModeSetParams, +) + + +class TestRpcTimeout: + @pytest.mark.asyncio + async def test_default_timeout_not_forwarded(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"started": True}) + api = FleetApi(client, "sess-1") + + await api.start(SessionFleetStartParams(prompt="go")) + + client.request.assert_called_once() + _, kwargs = client.request.call_args + assert "timeout" not in kwargs + + @pytest.mark.asyncio + async def test_custom_timeout_forwarded(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"started": True}) + api = FleetApi(client, "sess-1") + + await api.start(SessionFleetStartParams(prompt="go"), timeout=600.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 600.0 + + @pytest.mark.asyncio + async def test_timeout_on_other_methods(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"mode": "plan"}) + api = ModeApi(client, "sess-1") + + await api.set(SessionModeSetParams(mode=Mode.PLAN), timeout=120.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 120.0 diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index aa688782..bac58976 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -169,11 +169,20 @@ AUTO-GENERATED FILE - DO NOT EDIT Generated from: api.schema.json """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..jsonrpc import JsonRpcClient +`); + + lines.push(` +def _timeout_kwargs(timeout: Optional[float]) -> dict: + """Build keyword arguments for optional timeout forwarding.""" + if timeout is not None: + return {"timeout": timeout} + return {} + `); lines.push(typesCode); lines.push(``); @@ -255,10 +264,10 @@ function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; const paramsType = toPascalCase(method.rpcMethod) + "Params"; - // Build signature with typed params + // Build signature with typed params + optional timeout const sig = hasParams - ? ` async def ${methodName}(self, params: ${paramsType}) -> ${resultType}:` - : ` async def ${methodName}(self) -> ${resultType}:`; + ? ` async def ${methodName}(self, params: ${paramsType}, *, timeout: Optional[float] = None) -> ${resultType}:` + : ` async def ${methodName}(self, *, timeout: Optional[float] = None) -> ${resultType}:`; lines.push(sig); @@ -267,16 +276,16 @@ function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: if (hasParams) { lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); lines.push(` params_dict["sessionId"] = self._session_id`); - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict, **_timeout_kwargs(timeout)))`); } else { - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {"sessionId": self._session_id}))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {"sessionId": self._session_id}, **_timeout_kwargs(timeout)))`); } } else { if (hasParams) { lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict, **_timeout_kwargs(timeout)))`); } else { - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {}))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {}, **_timeout_kwargs(timeout)))`); } } lines.push(``); From 31ca7637ace53b3e94e5e2d5586a47762c400679 Mon Sep 17 00:00:00 2001 From: Rasaboun <40967731+Rasaboun@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:06:59 +0100 Subject: [PATCH 2/3] test: cover no-params and server-scoped RPC timeout branches Add tests for PlanApi.read (session, no params) and ModelsApi.list (server, no params) to exercise all four codegen branches. --- python/test_rpc_timeout.py | 61 +++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/python/test_rpc_timeout.py b/python/test_rpc_timeout.py index d009a366..5874952d 100644 --- a/python/test_rpc_timeout.py +++ b/python/test_rpc_timeout.py @@ -7,12 +7,23 @@ FleetApi, Mode, ModeApi, + ModelsApi, + PlanApi, SessionFleetStartParams, SessionModeSetParams, ) class TestRpcTimeout: + """Tests for timeout forwarding across all four codegen branches: + - session-scoped with params + - session-scoped without params + - server-scoped with params (not tested — no server+params method exists yet) + - server-scoped without params + """ + + # ── session-scoped, with params ────────────────────────────────── + @pytest.mark.asyncio async def test_default_timeout_not_forwarded(self): client = AsyncMock() @@ -37,7 +48,7 @@ async def test_custom_timeout_forwarded(self): assert kwargs["timeout"] == 600.0 @pytest.mark.asyncio - async def test_timeout_on_other_methods(self): + async def test_timeout_on_session_params_method(self): client = AsyncMock() client.request = AsyncMock(return_value={"mode": "plan"}) api = ModeApi(client, "sess-1") @@ -46,3 +57,51 @@ async def test_timeout_on_other_methods(self): _, kwargs = client.request.call_args assert kwargs["timeout"] == 120.0 + + # ── session-scoped, no params ──────────────────────────────────── + + @pytest.mark.asyncio + async def test_timeout_on_session_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"exists": True}) + api = PlanApi(client, "sess-1") + + await api.read(timeout=90.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 90.0 + + @pytest.mark.asyncio + async def test_default_timeout_on_session_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"exists": True}) + api = PlanApi(client, "sess-1") + + await api.read() + + _, kwargs = client.request.call_args + assert "timeout" not in kwargs + + # ── server-scoped, no params ───────────────────────────────────── + + @pytest.mark.asyncio + async def test_timeout_on_server_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"models": []}) + api = ModelsApi(client) + + await api.list(timeout=45.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 45.0 + + @pytest.mark.asyncio + async def test_default_timeout_on_server_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"models": []}) + api = ModelsApi(client) + + await api.list() + + _, kwargs = client.request.call_args + assert "timeout" not in kwargs From 36b4fa66703a1a57f0f4bc73f537512f1cc1cdfb Mon Sep 17 00:00:00 2001 From: Rasaboun <40967731+Rasaboun@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:14:03 +0100 Subject: [PATCH 3/3] fix: move _timeout_kwargs after quicktype imports, add server+params test - Move _timeout_kwargs helper after the quicktype-generated import block to avoid duplicate Optional import and keep preamble conventional - Add ToolsApi.list tests covering the server-scoped + params branch - All four codegen branches now have test coverage --- python/copilot/generated/rpc.py | 17 ++++++++--------- python/test_rpc_timeout.py | 28 +++++++++++++++++++++++++++- scripts/codegen/python.ts | 6 ++---- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 4f20f8f6..6cb630c1 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -3,20 +3,12 @@ Generated from: api.schema.json """ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..jsonrpc import JsonRpcClient - -def _timeout_kwargs(timeout: Optional[float]) -> dict: - """Build keyword arguments for optional timeout forwarding.""" - if timeout is not None: - return {"timeout": timeout} - return {} - - from dataclasses import dataclass from typing import Any, Optional, List, Dict, TypeVar, Type, cast, Callable from enum import Enum @@ -1154,6 +1146,13 @@ def session_compaction_compact_result_to_dict(x: SessionCompactionCompactResult) return to_class(SessionCompactionCompactResult, x) +def _timeout_kwargs(timeout: Optional[float]) -> dict: + """Build keyword arguments for optional timeout forwarding.""" + if timeout is not None: + return {"timeout": timeout} + return {} + + class ModelsApi: def __init__(self, client: "JsonRpcClient"): self._client = client diff --git a/python/test_rpc_timeout.py b/python/test_rpc_timeout.py index 5874952d..70a616c9 100644 --- a/python/test_rpc_timeout.py +++ b/python/test_rpc_timeout.py @@ -11,6 +11,8 @@ PlanApi, SessionFleetStartParams, SessionModeSetParams, + ToolsApi, + ToolsListParams, ) @@ -18,7 +20,7 @@ class TestRpcTimeout: """Tests for timeout forwarding across all four codegen branches: - session-scoped with params - session-scoped without params - - server-scoped with params (not tested — no server+params method exists yet) + - server-scoped with params - server-scoped without params """ @@ -82,6 +84,30 @@ async def test_default_timeout_on_session_no_params_method(self): _, kwargs = client.request.call_args assert "timeout" not in kwargs + # ── server-scoped, with params ───────────────────────────────────── + + @pytest.mark.asyncio + async def test_timeout_on_server_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"tools": []}) + api = ToolsApi(client) + + await api.list(ToolsListParams(), timeout=60.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 60.0 + + @pytest.mark.asyncio + async def test_default_timeout_on_server_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"tools": []}) + api = ToolsApi(client) + + await api.list(ToolsListParams()) + + _, kwargs = client.request.call_args + assert "timeout" not in kwargs + # ── server-scoped, no params ───────────────────────────────────── @pytest.mark.asyncio diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index bac58976..0a1df7f1 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -169,13 +169,13 @@ AUTO-GENERATED FILE - DO NOT EDIT Generated from: api.schema.json """ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..jsonrpc import JsonRpcClient `); - + lines.push(typesCode); lines.push(` def _timeout_kwargs(timeout: Optional[float]) -> dict: """Build keyword arguments for optional timeout forwarding.""" @@ -184,8 +184,6 @@ def _timeout_kwargs(timeout: Optional[float]) -> dict: return {} `); - lines.push(typesCode); - lines.push(``); // Emit RPC wrapper classes if (schema.server) {