Skip to content

Commit 6645ee4

Browse files
committed
feat(sessions): persist the answer half of every approval gate
1 parent 447924a commit 6645ee4

19 files changed

Lines changed: 620 additions & 17 deletions

File tree

api/oss/src/apis/fastapi/sessions/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ class SessionInteractionTransitionRequest(BaseModel):
106106
session_id: str
107107
token: str
108108
status: SessionInteractionStatus
109+
resolution: Optional[Dict[str, Any]] = None
109110

110111

111112
class SessionInteractionCancelStaleRequest(BaseModel):

api/oss/src/apis/fastapi/sessions/router.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,7 @@ async def transition_interaction(
645645
session_id=body.session_id,
646646
token=body.token,
647647
status=body.status,
648+
resolution=body.resolution,
648649
),
649650
)
650651
except InteractionNotFound:

api/oss/src/core/sessions/interactions/dtos.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class SessionInteractionTransition(BaseModel):
6868
session_id: str
6969
token: str
7070
status: SessionInteractionStatus
71+
resolution: Optional[Dict[str, Any]] = None
7172

7273

7374
class SessionInteractionQuery(BaseModel):

api/oss/src/dbs/postgres/sessions/interactions/dao.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from typing import List, Optional
33
from uuid import UUID
44

5-
from sqlalchemy import delete as sa_delete, func, select, update as sa_update
5+
from sqlalchemy import cast, delete as sa_delete, func, select, update as sa_update
6+
from sqlalchemy.dialects.postgresql import JSON, JSONB, array
67
from sqlalchemy.exc import IntegrityError
78

89
from oss.src.core.sessions.interactions.dtos import (
@@ -97,6 +98,24 @@ async def transition_interaction(
9798
# Only non-terminal interactions transition: pending (responded|resolved|
9899
# cancelled) and responded (resolved, when the runner consumes an API-plane
99100
# answer). resolved/cancelled are terminal.
101+
transition_values = {
102+
"status": transition.status.value,
103+
"updated_at": datetime.now(timezone.utc),
104+
}
105+
if transition.resolution is not None:
106+
# Preserve request/references while atomically adding the answer under the guard.
107+
transition_values["data"] = cast(
108+
func.jsonb_set(
109+
func.coalesce(
110+
cast(SessionInteractionDBE.data, JSONB),
111+
cast({}, JSONB),
112+
),
113+
array(["resolution"]),
114+
cast(transition.resolution, JSONB),
115+
True,
116+
),
117+
JSON,
118+
)
100119
stmt = (
101120
sa_update(SessionInteractionDBE)
102121
.where(
@@ -105,10 +124,7 @@ async def transition_interaction(
105124
SessionInteractionDBE.token == transition.token,
106125
SessionInteractionDBE.status.in_(("pending", "responded")),
107126
)
108-
.values(
109-
status=transition.status.value,
110-
updated_at=datetime.now(timezone.utc),
111-
)
127+
.values(**transition_values)
112128
.returning(SessionInteractionDBE)
113129
)
114130
result = await session.execute(stmt)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from uuid import uuid4
2+
3+
from sqlalchemy.dialects import postgresql
4+
5+
from oss.src.core.sessions.interactions.dtos import (
6+
SessionInteractionStatus,
7+
SessionInteractionTransition,
8+
)
9+
from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO
10+
11+
12+
class _Result:
13+
def scalar_one_or_none(self):
14+
return None
15+
16+
17+
class _Session:
18+
def __init__(self):
19+
self.statement = None
20+
21+
async def execute(self, statement):
22+
self.statement = statement
23+
return _Result()
24+
25+
async def commit(self):
26+
return None
27+
28+
29+
class _SessionContext:
30+
def __init__(self, session):
31+
self.session = session
32+
33+
async def __aenter__(self):
34+
return self.session
35+
36+
async def __aexit__(self, exc_type, exc, traceback):
37+
return False
38+
39+
40+
class _Engine:
41+
def __init__(self, session):
42+
self._session = session
43+
44+
def session(self):
45+
return _SessionContext(self._session)
46+
47+
48+
async def _transition_statement(resolution=None):
49+
session = _Session()
50+
dao = SessionInteractionsDAO(engine=_Engine(session))
51+
await dao.transition_interaction(
52+
transition=SessionInteractionTransition(
53+
project_id=uuid4(),
54+
session_id="session-1",
55+
token="token-1",
56+
status=SessionInteractionStatus.resolved,
57+
resolution=resolution,
58+
)
59+
)
60+
return session.statement
61+
62+
63+
async def test_resolution_update_preserves_existing_data_with_jsonb_set():
64+
resolution = {"verdict": "approved", "tool_call_id": "tool-1"}
65+
statement = await _transition_statement(resolution)
66+
compiled = statement.compile(dialect=postgresql.dialect())
67+
set_clause = str(compiled).split(" WHERE ", maxsplit=1)[0]
68+
69+
assert "jsonb_set" in set_clause
70+
assert "CAST(session_interactions.data AS JSONB)" in set_clause
71+
assert resolution in compiled.params.values()
72+
73+
74+
async def test_transition_without_resolution_does_not_write_data():
75+
statement = await _transition_statement()
76+
set_clause = str(statement.compile(dialect=postgresql.dialect())).split(
77+
" WHERE ", maxsplit=1
78+
)[0]
79+
80+
assert "data=" not in set_clause
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from unittest.mock import AsyncMock, patch
2+
from uuid import uuid4
3+
4+
from fastapi import FastAPI, Request
5+
6+
from oss.src.apis.fastapi.sessions.models import SessionInteractionTransitionRequest
7+
from oss.src.apis.fastapi.sessions.router import InteractionsRouter
8+
from oss.src.core.sessions.interactions.dtos import (
9+
SessionInteraction,
10+
SessionInteractionData,
11+
SessionInteractionKind,
12+
SessionInteractionStatus,
13+
)
14+
15+
16+
def _make_authed_request(app: FastAPI, project_id, user_id) -> Request:
17+
request = Request(
18+
{
19+
"type": "http",
20+
"method": "POST",
21+
"path": "/sessions/interactions/transition",
22+
"headers": [],
23+
"app": app,
24+
}
25+
)
26+
request.state.project_id = project_id
27+
request.state.user_id = user_id
28+
return request
29+
30+
31+
async def test_transition_route_passes_resolution_to_the_domain_transition():
32+
project_id = uuid4()
33+
user_id = uuid4()
34+
captured = []
35+
36+
class _InteractionsService:
37+
async def transition_interaction(self, *, transition):
38+
captured.append(transition)
39+
return SessionInteraction(
40+
project_id=transition.project_id,
41+
session_id=transition.session_id,
42+
token=transition.token,
43+
kind=SessionInteractionKind.user_approval,
44+
status=transition.status,
45+
data=SessionInteractionData(resolution=transition.resolution),
46+
)
47+
48+
router = InteractionsRouter(
49+
interactions_service=_InteractionsService(),
50+
workflows_service=AsyncMock(),
51+
respond_task=AsyncMock(),
52+
)
53+
body = SessionInteractionTransitionRequest(
54+
session_id="session-1",
55+
token="approval-token",
56+
status=SessionInteractionStatus.resolved,
57+
resolution={"verdict": "denied", "tool_call_id": "tool-1"},
58+
)
59+
60+
with patch(
61+
"oss.src.apis.fastapi.sessions.router.check_action_access",
62+
new_callable=AsyncMock,
63+
return_value=True,
64+
):
65+
response = await router.transition_interaction(
66+
request=_make_authed_request(FastAPI(), project_id, user_id),
67+
body=body,
68+
)
69+
70+
assert len(captured) == 1
71+
assert captured[0].resolution == {
72+
"verdict": "denied",
73+
"tool_call_id": "tool-1",
74+
}
75+
assert response.interaction is not None
76+
assert response.interaction.data is not None
77+
assert response.interaction.data.resolution == captured[0].resolution

api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222

2323
from oss.src.core.sessions.interactions.dtos import (
2424
SessionInteractionCreate,
25+
SessionInteractionData,
2526
SessionInteractionKind,
2627
SessionInteractionQuery,
28+
SessionInteractionStatus,
29+
SessionInteractionTransition,
2730
)
2831
from oss.src.core.mounts.dtos import MountCreate
2932
import oss.src.models.db_models # noqa: F401
@@ -143,6 +146,74 @@ def mounts_dao():
143146
return MountsDAO(engine=get_transactions_engine())
144147

145148

149+
# ---------------------------------------------------------------------------
150+
# SessionInteractionsDAO transition resolution
151+
# ---------------------------------------------------------------------------
152+
153+
154+
async def test_interaction_transition_preserves_data_and_optionally_adds_resolution(
155+
interactions_dao, project
156+
):
157+
project_id = project["project_id"]
158+
session_id = f"interaction-resolution-{uuid.uuid4().hex[:8]}"
159+
request = {"tool": "bash", "args": {"command": "ls"}}
160+
161+
await interactions_dao.create_interaction(
162+
project_id=project_id,
163+
user_id=None,
164+
interaction=SessionInteractionCreate(
165+
project_id=project_id,
166+
session_id=session_id,
167+
token="approval-token",
168+
kind=SessionInteractionKind.user_approval,
169+
data=SessionInteractionData(request=request),
170+
),
171+
)
172+
transitioned = await interactions_dao.transition_interaction(
173+
transition=SessionInteractionTransition(
174+
project_id=project_id,
175+
session_id=session_id,
176+
token="approval-token",
177+
status=SessionInteractionStatus.resolved,
178+
resolution={"verdict": "approved", "tool_call_id": "tool-1"},
179+
)
180+
)
181+
182+
assert transitioned is not None
183+
assert transitioned.status == SessionInteractionStatus.resolved
184+
assert transitioned.data is not None
185+
assert transitioned.data.request == request
186+
assert transitioned.data.resolution == {
187+
"verdict": "approved",
188+
"tool_call_id": "tool-1",
189+
}
190+
191+
await interactions_dao.create_interaction(
192+
project_id=project_id,
193+
user_id=None,
194+
interaction=SessionInteractionCreate(
195+
project_id=project_id,
196+
session_id=session_id,
197+
token="client-token",
198+
kind=SessionInteractionKind.client_tool,
199+
data=SessionInteractionData(request=request),
200+
),
201+
)
202+
transitioned_without_resolution = await interactions_dao.transition_interaction(
203+
transition=SessionInteractionTransition(
204+
project_id=project_id,
205+
session_id=session_id,
206+
token="client-token",
207+
status=SessionInteractionStatus.resolved,
208+
)
209+
)
210+
211+
assert transitioned_without_resolution is not None
212+
assert transitioned_without_resolution.data is not None
213+
assert transitioned_without_resolution.data.request == request
214+
assert transitioned_without_resolution.data.resolution is None
215+
216+
146217
# ---------------------------------------------------------------------------
147218
# SessionInteractionsDAO.delete_by_session_id — new hard delete
148219
# ---------------------------------------------------------------------------

sdks/python/agenta/sdk/agents/wire_models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,8 @@ class WireEvent(_WireModel):
378378
the forward-compatible event types the runner may add, which contradicts the "drop unknown"
379379
guarantee. The known ``type`` values are documented for readers, not enforced: ``message``,
380380
``thought``, the ``message_*`` / ``reasoning_*`` lifecycle trios, ``tool_call``,
381-
``tool_result``, ``interaction_request``, ``data``, ``file``, ``usage``, ``error``, ``done``.
381+
``tool_result``, ``interaction_request``, ``interaction_response``, ``data``, ``file``,
382+
``usage``, ``error``, ``done``.
382383
"""
383384

384385
type: Optional[str] = None

services/runner/src/engines/sandbox_agent/acp-interactions.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ export interface AttachPermissionResponderInput {
6969
kind: "user_approval" | "client_tool",
7070
) => void;
7171
/** Called after a stored decision was successfully forwarded to the harness. */
72-
onResolveInteraction?: (token: string) => void;
72+
onResolveInteraction?: (
73+
token: string,
74+
verdict?: { approved: boolean; toolCallId: string },
75+
) => void;
7376
/**
7477
* Fires for EVERY parkable permission gate (a Claude ACP gate or a Pi ACP gate) that
7578
* resolves to pendingApproval. Keep-alive uses it to record every parked permission id /
@@ -223,9 +226,12 @@ export function attachPermissionResponder({
223226
};
224227

225228
// Resolve the durable row this gate created, if it created one.
226-
const resolveIfCreated = (id: string): void => {
229+
const resolveIfCreated = (
230+
id: string,
231+
verdict?: { approved: boolean; toolCallId: string },
232+
): void => {
227233
if (!createdInteractionIds.delete(id)) return;
228-
onResolveInteraction?.(id);
234+
onResolveInteraction?.(id, verdict);
229235
};
230236

231237
const replyPermission = async (
@@ -254,7 +260,10 @@ export function attachPermissionResponder({
254260
onPause?.();
255261
return;
256262
}
257-
resolveIfCreated(id);
263+
resolveIfCreated(id, {
264+
approved: decision === "allow",
265+
toolCallId: toolCallId ?? id,
266+
});
258267
};
259268

260269
const replyClientTool = async (

0 commit comments

Comments
 (0)