diff --git a/coding-agent/src/coding_agent/server/http_server.py b/coding-agent/src/coding_agent/server/http_server.py index 158ded609..d12f590ef 100644 --- a/coding-agent/src/coding_agent/server/http_server.py +++ b/coding-agent/src/coding_agent/server/http_server.py @@ -2594,21 +2594,7 @@ async def update_runtime_config( if getattr(session, "turn_in_progress", False): raise HTTPException(status_code=409, detail="Turn already in progress") - # Apply thinking and approval directly on live mutable session/runtime state. - # No pipeline rebuild needed for these: provider reads thinking per-turn from - # ctx.config, and approval is evaluated by the existing live plugin. no_rebuild_update = not (provided & {"model", "provider", "base_url"}) - if body.thinking is not None: - session.thinking_config["enabled"] = body.thinking.enabled - session.thinking_config["effort"] = body.thinking.effort - if body.approval is not None: - approval_policy = ApprovalPolicy(body.approval) - plugin = _live_approval_plugin(session) - if plugin is not None: - plugin.set_policy(approval_policy) - session.approval_policy = approval_policy - await session_manager._persist_session_async(session) - try: if no_rebuild_update: updated_session = session @@ -2634,6 +2620,20 @@ async def update_runtime_config( status_code=500, detail=_http_exception_detail(exc) ) from exc + # Apply thinking and approval after the fallible rebuild. For rebuilds this + # targets the freshly attached runtime; for approval/thinking-only updates it + # keeps the existing no-rebuild fast path. + if body.thinking is not None: + updated_session.thinking_config["enabled"] = body.thinking.enabled + updated_session.thinking_config["effort"] = body.thinking.effort + if body.approval is not None: + approval_policy = ApprovalPolicy(body.approval) + plugin = _live_approval_plugin(updated_session) + if plugin is not None: + plugin.set_policy(approval_policy) + updated_session.approval_policy = approval_policy + await session_manager._persist_session_async(updated_session) + return RuntimeConfigUpdateResponse( session_id=session_id, provider_name=getattr(updated_session, "provider_name", None), diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index 79c29b1e9..e2f7cb23d 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -2444,6 +2444,64 @@ async def fail_replace_session_runtime_config(*args, **kwargs): Approve, ) + async def test_mixed_runtime_replacement_failure_leaves_approval_and_thinking_unchanged( + self, client, monkeypatch + ): + response = await client.post("/sessions", json={"approval_policy": "auto"}) + session_id = response.json()["session_id"] + await session_manager.ensure_session_runtime(session_id) + session = session_manager.get_session(session_id) + original_thinking_config = dict(session.thinking_config) + persisted_before = session_manager._store.load(session_id) + assert persisted_before is not None + assert persisted_before["approval_policy"] == "auto" + + plugin = session.runtime_pipeline._registry.get("approval") + assert isinstance(plugin, ApprovalPlugin) + assert isinstance( + plugin.approve_tool_call(tool_name="bash_run", arguments={}), + AskUser, + ) + + replace_calls: list[dict[str, object]] = [] + + async def fail_replace_session_runtime_config(*args, **kwargs): + replace_calls.append(dict(kwargs)) + raise RuntimeError("runtime replacement failed") + + monkeypatch.setattr( + session_manager, + "replace_session_runtime_config", + fail_replace_session_runtime_config, + ) + + update = await client.post( + f"/sessions/{session_id}/runtime-config", + json={ + "approval": "yolo", + "thinking": {"enabled": False, "effort": "high"}, + "model": "replacement-model", + }, + ) + + assert update.status_code == 500 + assert replace_calls == [ + { + "model_name": "replacement-model", + "provider_name": None, + "base_url": http_server.UNSET, + } + ] + assert session.approval_policy == ApprovalPolicy.AUTO + assert session.thinking_config == original_thinking_config + assert isinstance( + plugin.approve_tool_call(tool_name="bash_run", arguments={}), + AskUser, + ) + persisted_after = session_manager._store.load(session_id) + assert persisted_after is not None + assert persisted_after["approval_policy"] == "auto" + async def test_approval_null_is_rejected(self, client): response = await client.post("/sessions", json={}) session_id = response.json()["session_id"]