Skip to content

Commit d1d2efe

Browse files
authored
fix(acp): make session/close and auth errors spec-compliant (#58)
session/close now cancels in-flight work and releases per-session runtime resources (MCP toolset, background refresh) before dropping the session, as the ACP session/close spec requires ("the agent must cancel any ongoing work ... and then free up any resources associated with the session"). Previously it only popped the registry entry, leaking the MCP clients and background-refresh task of every closed session. The authenticate() failure path passed its authMethods as raw Pydantic models inside the error data. The JSON-RPC connection layer encodes error data with a plain json.dumps (no Pydantic-aware encoder), so that path raised TypeError while building the response. Serialize to plain dicts via model_dump(by_alias=True, exclude_none=True), matching the _check_auth path. Adds regression tests for both; updates the close_session test to assert the cancel + cleanup contract instead of a synthetic placeholder tuple.
1 parent 7ac3b9c commit d1d2efe

5 files changed

Lines changed: 75 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **ACP session close and auth errors are spec-compliant.** `session/close` now cancels any
19+
in-flight work and frees the session's runtime resources (MCP toolset, background refresh)
20+
before dropping it, instead of leaking them, per the ACP `session/close` requirement. The
21+
`authenticate` failure path now serializes its `authMethods` to plain dicts so the JSON-RPC
22+
error response no longer raises `TypeError` on encode.
1823
- **Auto-mode destructive actions deliberate per turn and context.** Auto-deliberation now
1924
scopes destructive-command one-shots to the active execution context and LLM generation,
2025
so duplicate destructive calls in one response keep bouncing while later deliberate retries

docs/en/release-notes/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
## Unreleased
1919

20+
- **ACP session close and auth errors are spec-compliant.** `session/close` now cancels any
21+
in-flight work and frees the session's runtime resources (MCP toolset, background refresh)
22+
before dropping it, instead of leaking them, per the ACP `session/close` requirement. The
23+
`authenticate` failure path now serializes its `authMethods` to plain dicts so the JSON-RPC
24+
error response no longer raises `TypeError` on encode.
2025
- **Auto-mode destructive actions deliberate per turn and context.** Auto-deliberation now
2126
scopes destructive-command one-shots to the active execution context and LLM generation,
2227
so duplicate destructive calls in one response keep bouncing while later deliberate retries

src/pythinker_code/acp/server.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,18 @@ async def set_session_mode(
376376
async def close_session(
377377
self, session_id: str, **kwargs: Any
378378
) -> acp.schema.CloseSessionResponse | None:
379-
"""Drop a session from the in-memory registry (ACP 0.10 session/close)."""
379+
"""Close a session: cancel in-flight work, then free its resources (ACP 0.10).
380+
381+
The ``session/close`` spec requires the agent to cancel any ongoing work
382+
(as if ``session/cancel`` was called) and then release the resources
383+
associated with the session before dropping it from the registry.
384+
"""
380385
logger.info("Closing session: {id}", id=session_id)
381-
self.sessions.pop(session_id, None)
386+
entry = self.sessions.pop(session_id, None)
387+
if entry is not None:
388+
acp_session, _ = entry
389+
await acp_session.cancel()
390+
await acp_session.cli.cleanup_runtime_resources()
382391
return None
383392

384393
async def set_config_option(
@@ -469,7 +478,10 @@ async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateR
469478
raise acp.RequestError.auth_required(
470479
{
471480
"message": "Please complete login in terminal first",
472-
"authMethods": self._auth_methods,
481+
"authMethods": [
482+
m.model_dump(by_alias=True, exclude_none=True)
483+
for m in self._auth_methods
484+
],
473485
}
474486
)
475487

tests/acp/test_server_initialize.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from __future__ import annotations
44

5+
from types import SimpleNamespace
6+
from unittest.mock import AsyncMock
7+
58
import acp.schema
69
import pytest
710

@@ -34,14 +37,30 @@ async def test_initialize_advertises_terminal_auth_method():
3437
assert auth_method.env == {}
3538

3639

37-
async def test_close_session_drops_session_from_registry():
38-
"""ACP 0.10 session/close removes the session from the in-memory registry."""
40+
async def test_close_session_cancels_and_releases_resources():
41+
"""ACP 0.10 session/close must cancel in-flight work and free per-session
42+
resources before dropping the session.
43+
44+
The spec mandates the agent "must cancel any ongoing work related to the
45+
session (treat it as if ``session/cancel`` was called) and then free up any
46+
resources associated with the session."
47+
"""
3948
server = ACPServer()
40-
server.sessions["sess-1"] = ("placeholder",) # type: ignore[assignment]
49+
cli = SimpleNamespace(cleanup_runtime_resources=AsyncMock())
50+
acp_session = SimpleNamespace(cancel=AsyncMock(), cli=cli)
51+
# Registry stores ``tuple[ACPSession, _ModelIDConv]``.
52+
server.sessions["sess-1"] = (acp_session, object()) # type: ignore[assignment]
4153

4254
result = await server.close_session("sess-1")
4355

4456
assert result is None
4557
assert "sess-1" not in server.sessions
46-
# Closing an unknown session is a no-op (must not raise).
58+
acp_session.cancel.assert_awaited_once()
59+
cli.cleanup_runtime_resources.assert_awaited_once()
60+
61+
62+
async def test_close_session_unknown_is_noop():
63+
"""Closing an unknown session must be a no-op (no raise, no cleanup)."""
64+
server = ACPServer()
65+
4766
assert await server.close_session("missing") is None

tests/ui_and_conv/test_acp_server_auth.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,33 @@ async def test_authenticate_rejects_expired_token_without_refresh(server: ACPSer
169169
assert exc_info.value.code == -32000
170170

171171

172+
@pytest.mark.asyncio
173+
async def test_authenticate_auth_required_data_is_json_serializable(server: ACPServer) -> None:
174+
"""The AUTH_REQUIRED error from authenticate() must carry JSON-serializable
175+
``authMethods`` (plain dicts), not raw Pydantic models.
176+
177+
The JSON-RPC connection layer encodes the error ``data`` with a plain
178+
``json.dumps`` (no Pydantic-aware default encoder), so raw models would raise
179+
``TypeError`` while building the error response — crashing the agent process.
180+
"""
181+
import json
182+
183+
token = _make_token(expires_at=time.time() - 100, refresh_token="")
184+
185+
with (
186+
patch("pythinker_code.acp.server.load_config", return_value=Config()),
187+
patch("pythinker_code.acp.server.load_tokens", return_value=token),
188+
pytest.raises(acp.RequestError) as exc_info,
189+
):
190+
await server.authenticate(method_id="login")
191+
192+
data = exc_info.value.data
193+
assert isinstance(data, dict)
194+
# Must not raise TypeError on encode (the bug: raw Pydantic models).
195+
json.dumps(data)
196+
assert all(isinstance(m, dict) for m in data["authMethods"])
197+
198+
172199
@pytest.mark.asyncio
173200
async def test_authenticate_accepts_valid_token(server: ACPServer) -> None:
174201
"""authenticate('login') should succeed for a valid, non-expired token."""

0 commit comments

Comments
 (0)