3887 lines
142 KiB
Python
3887 lines
142 KiB
Python
"""
|
|
Integration tests for ``POST /v1/sessions/{id}/hooks/permission-request``.
|
|
|
|
The endpoint receives Claude Code's ``PermissionRequest`` HTTP hook
|
|
payload (when ``omnigent claude`` wraps the native TUI), parks
|
|
the call on the same in-memory elicitation registry the claude-sdk
|
|
path uses, emits an SSE event for the web UI's :file:`ApprovalCard`,
|
|
and returns Claude's expected ``hookSpecificOutput`` decision JSON
|
|
once the UI verdict arrives.
|
|
|
|
Tests cover three round-trips:
|
|
|
|
- Allow: the UI resolves the elicitation via a session
|
|
``approval`` event → endpoint returns
|
|
``decision.behavior == "allow"``.
|
|
- Deny: same path but the UI declines → ``decision.behavior == "deny"``.
|
|
- Timeout: nobody resolves the elicitation → endpoint returns
|
|
``200`` with empty body so Claude defers to its TUI prompt
|
|
(fail-ask).
|
|
|
|
Uses the shared ``client`` fixture from ``tests/server/conftest.py``
|
|
(real stores + mock LLM) so the tests exercise the real route →
|
|
``_harness_elicitation_registry`` → SSE-publish pipeline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.routing import APIRoute
|
|
|
|
from omnigent.codex_native_elicitation import codex_elicitation_id
|
|
from omnigent.runtime import session_stream
|
|
from omnigent.server._elicitation_registry import (
|
|
_harness_pre_resolved_elicitations,
|
|
_PreResolvedHarnessElicitation,
|
|
)
|
|
from omnigent.server.routes import sessions as sessions_route
|
|
from tests.server.helpers import create_test_agent
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
async def _create_session(client: httpx.AsyncClient, agent_id: str) -> str:
|
|
"""
|
|
Create a minimal session and return its id.
|
|
|
|
:param client: Test HTTP client.
|
|
:param agent_id: Agent to bind.
|
|
:returns: New session id.
|
|
"""
|
|
resp = await client.post("/v1/sessions", json={"agent_id": agent_id})
|
|
assert resp.status_code == 201, f"create failed: {resp.status_code} {resp.text}"
|
|
return resp.json()["id"]
|
|
|
|
|
|
async def _drain_until_elicitation(
|
|
session_id: str,
|
|
*,
|
|
timeout_s: float = 5.0,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Block on the session SSE stream until a
|
|
``response.elicitation_request`` event arrives, then return the
|
|
full event dict.
|
|
|
|
The endpoint publishes the SSE event before parking on the
|
|
elicitation future, so subscribing here is the simplest way to
|
|
learn the id without monkey-patching ``uuid``. Returning the
|
|
whole event lets callers also inspect the params block for the
|
|
Claude-native extras (``cwd``, ``permission_mode``).
|
|
|
|
:param session_id: Session to subscribe to.
|
|
:param timeout_s: Maximum seconds to wait for the elicitation
|
|
event before failing the test.
|
|
:returns: The captured elicitation_request event dict.
|
|
"""
|
|
async with asyncio.timeout(timeout_s):
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") == "response.elicitation_request":
|
|
elicitation_id = event.get("elicitation_id")
|
|
assert isinstance(elicitation_id, str) and elicitation_id, (
|
|
f"elicitation event missing id: {event!r}"
|
|
)
|
|
return event
|
|
raise AssertionError("subscribe loop ended without an elicitation event")
|
|
|
|
|
|
async def _post_approval(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
elicitation_id: str,
|
|
action: str,
|
|
content: dict[str, Any] | None = None,
|
|
) -> httpx.Response:
|
|
"""
|
|
Resolve a published elicitation through the session event API.
|
|
|
|
:param client: Test HTTP client.
|
|
:param session_id: Session that emitted the elicitation.
|
|
:param elicitation_id: Elicitation id from the stream event,
|
|
e.g. ``"elicit_abc123"``.
|
|
:param action: MCP ``ElicitResult.action`` literal,
|
|
e.g. ``"accept"`` or ``"decline"``.
|
|
:param content: Optional MCP ``ElicitResult.content`` payload.
|
|
:returns: The HTTP response from the session event route.
|
|
"""
|
|
data: dict[str, Any] = {"elicitation_id": elicitation_id, "action": action}
|
|
if content is not None:
|
|
data["content"] = content
|
|
return await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "approval",
|
|
"data": data,
|
|
},
|
|
)
|
|
|
|
|
|
async def _claude_permission_payload(tool_name: str = "Bash") -> dict[str, Any]:
|
|
"""
|
|
Build a realistic Claude PermissionRequest hook body.
|
|
|
|
:param tool_name: Tool Claude wants to call.
|
|
:returns: JSON-serializable payload mirroring Claude Code's
|
|
published wire shape for the ``PermissionRequest`` event.
|
|
Deliberately carries no ``tool_use_id``: the real
|
|
PermissionRequest payload has no per-call id (it is minted only
|
|
when the tool call is emitted, after this permission check).
|
|
"""
|
|
return {
|
|
"session_id": "claude_sess_abc",
|
|
"transcript_path": "/tmp/transcript.jsonl",
|
|
"cwd": "/tmp/cwd",
|
|
"permission_mode": "default",
|
|
"hook_event_name": "PermissionRequest",
|
|
"tool_name": tool_name,
|
|
"tool_input": {"command": "ls -la"},
|
|
}
|
|
|
|
|
|
async def test_permission_request_hook_allow_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
UI approves Claude's permission request → endpoint returns
|
|
``decision.behavior == "allow"`` in the Claude hookSpecificOutput
|
|
shape.
|
|
|
|
Failure modes this catches: the SSE event is never emitted (UI
|
|
never sees the request, can't approve); the elicitation registry
|
|
doesn't park the future (verdict comes in but the endpoint never
|
|
wakes); the verdict→decision mapping returns the wrong literal.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-allow")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
# Fire the hook POST and the verdict event concurrently — the hook
|
|
# parks on the registry, the verdict resolves it. Subscribing to
|
|
# the session stream is how we learn the elicitation id.
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
# Give the subscriber a moment to register before the publisher
|
|
# fires (publish is broadcast-to-current-subscribers — pre-subscribe
|
|
# events are lost).
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body == {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PermissionRequest",
|
|
"decision": {"behavior": "allow"},
|
|
}
|
|
}
|
|
|
|
|
|
async def test_permission_request_hook_accepts_kimi_namespaced_elicitation_id(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A kimi-native hook supplies ``_omnigent_elicitation_id = elicit_kimi_…`` on
|
|
every POST (stable re-attach id). The shared endpoint must accept any
|
|
``elicit_<harness>_`` namespace — not just ``elicit_claude_`` — or it 400s
|
|
and the approval card is NEVER published.
|
|
|
|
Regression: the id regex was hard-coded to ``^elicit_claude_…$``, so every
|
|
kimi approval POST was rejected and no card surfaced in the web UI.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-kimi-id")
|
|
session_id = await _create_session(client, agent["id"])
|
|
elicitation_id = "elicit_kimi_" + "0" * 32
|
|
payload = await _claude_permission_payload()
|
|
payload["_omnigent_elicitation_id"] = elicitation_id
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# The published card uses the client-supplied id verbatim (not 400, not a
|
|
# server-minted replacement).
|
|
assert event["elicitation_id"] == elicitation_id
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["hookSpecificOutput"]["decision"]["behavior"] == "allow"
|
|
|
|
|
|
async def test_cursor_permission_request_hook_allow_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
cursor-native TUI prompt → web ApprovalCard → accept → verdict.
|
|
|
|
The runner-side mirror (``omnigent.cursor_native_permissions``) POSTs a
|
|
detected cursor TUI approval prompt to
|
|
``/hooks/cursor-permission-request``; the route publishes a
|
|
``response.elicitation_request`` (phase ``pre_tool_use``, policy
|
|
``cursor_native_permission``, carrying the runner-minted elicitation id and
|
|
the rendered command preview) and parks on the same harness-elicitation
|
|
registry the Claude hook uses, then returns the MCP ``ElicitationResult``
|
|
once the UI answers. This is the cursor-native analog of
|
|
``test_permission_request_hook_allow_round_trip``.
|
|
|
|
Catches: the SSE event never emitted (the card never renders); the
|
|
runner-minted id not preserved (the runner can't correlate its verdict); the
|
|
park/resolve plumbing not wired (the POST never wakes on the UI verdict).
|
|
"""
|
|
agent = await create_test_agent(client, "test-cursor-permission-allow")
|
|
session_id = await _create_session(client, agent["id"])
|
|
elicitation_id = f"elicit_cursor_{session_id}_deadbeef"
|
|
payload = {
|
|
"elicitation_id": elicitation_id,
|
|
"operation_type": "shell",
|
|
"message": "Run this command?",
|
|
"content_preview": "echo hi > out.txt",
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/cursor-permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# The runner-minted id is preserved end-to-end, and the card carries the
|
|
# cursor-native rendering extras.
|
|
assert event["elicitation_id"] == elicitation_id
|
|
params = event["params"]
|
|
assert params["message"] == "Run this command?"
|
|
assert params["phase"] == "pre_tool_use"
|
|
assert params["policy_name"] == "cursor_native_permission"
|
|
assert params["content_preview"] == "echo hi > out.txt"
|
|
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"action": "accept"}
|
|
|
|
|
|
async def test_qwen_permission_request_hook_allow_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
qwen-native TUI ``can_use_tool`` → web ApprovalCard → accept → verdict.
|
|
|
|
The runner-side mirror (``omnigent.qwen_native_permissions``) reads a
|
|
``can_use_tool`` control request off qwen's ``--json-file`` and POSTs it to
|
|
the generic ``/hooks/native-permission-request`` (shared with hermes-/goose-
|
|
native) with ``agent="qwen"`` + ``policy_name="qwen_native_permission"``; the
|
|
route publishes a ``response.elicitation_request`` (phase ``pre_tool_use``,
|
|
the qwen policy name, carrying the runner-minted elicitation id and the
|
|
rendered tool preview) and parks on the same harness-elicitation registry the
|
|
Claude/Cursor hooks use, then returns the MCP ``ElicitationResult`` once the
|
|
UI answers. The qwen-native analog of
|
|
``test_cursor_permission_request_hook_allow_round_trip``.
|
|
"""
|
|
agent = await create_test_agent(client, "test-qwen-permission-allow")
|
|
session_id = await _create_session(client, agent["id"])
|
|
elicitation_id = f"elicit_qwen_{session_id}_r1"
|
|
payload = {
|
|
"elicitation_id": elicitation_id,
|
|
"agent": "qwen",
|
|
"policy_name": "qwen_native_permission",
|
|
"operation_type": "run_shell_command",
|
|
"message": "qwen wants to run run_shell_command",
|
|
"content_preview": "echo hi > out.txt",
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/native-permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
assert event["elicitation_id"] == elicitation_id
|
|
params = event["params"]
|
|
assert params["message"] == "qwen wants to run run_shell_command"
|
|
assert params["phase"] == "pre_tool_use"
|
|
assert params["policy_name"] == "qwen_native_permission"
|
|
assert params["content_preview"] == "echo hi > out.txt"
|
|
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"action": "accept"}
|
|
|
|
|
|
async def test_cursor_permission_request_hook_stamps_ask_user_question_extra(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
cursor ``AskQuestion`` → the structured ``ask_user_question`` extra is
|
|
published on the elicitation params (uncapped), so the web UI renders the
|
|
interactive form from it rather than parsing the ≤1024-char content_preview.
|
|
|
|
Catches: a multi-question payload silently dropped (web falls back to the
|
|
raw approve/reject card); the extra not surviving the params round-trip.
|
|
"""
|
|
agent = await create_test_agent(client, "test-cursor-askquestion")
|
|
session_id = await _create_session(client, agent["id"])
|
|
elicitation_id = f"elicit_cursor_{session_id}_q1"
|
|
ask_user_question = {
|
|
"questions": [
|
|
{
|
|
"id": "demo_topic",
|
|
"question": "What kind of example would you like to see?",
|
|
"options": [
|
|
{"label": "A coding-related question"},
|
|
{"label": "A workflow question"},
|
|
],
|
|
"multiSelect": False,
|
|
}
|
|
]
|
|
}
|
|
payload = {
|
|
"elicitation_id": elicitation_id,
|
|
"operation_type": "question",
|
|
"message": "AskQuestion Demo",
|
|
"content_preview": "AskUserQuestion({...})",
|
|
"ask_user_question": ask_user_question,
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/cursor-permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["policy_name"] == "cursor_native_permission"
|
|
# The structured payload rode through verbatim as the params extra.
|
|
assert params["ask_user_question"] == ask_user_question
|
|
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
async def test_top_level_elicitations_route_is_not_mounted(
|
|
app: FastAPI,
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Omnigent no longer exposes ``POST /v1/elicitations/{id}``.
|
|
|
|
Approval verdicts must travel through the session event route so
|
|
they share normal session scoping and authorization. If the AP
|
|
router gets mounted again, this test turns red immediately.
|
|
|
|
Checked two ways so the guard holds regardless of the environment:
|
|
|
|
- Route table (``app`` fixture): no ``APIRoute`` declares the
|
|
legacy ``POST /v1/elicitations/{elicitation_id}`` path. This
|
|
catches an exact re-mount even if its handler would 404 at
|
|
runtime, and is independent of which static assets are present.
|
|
- HTTP (``client`` fixture, same app): the live response is
|
|
``404`` *or* ``405``. The status varies by environment because
|
|
``create_app`` mounts a catch-all SPA at ``/`` only when a local
|
|
web-ui build exists at ``omnigent/server/static/web-ui/index.html``
|
|
(a gitignored dev artifact, absent on main/CI/fresh clones).
|
|
With no build the unmatched POST is a plain ``404``; with the
|
|
build present Starlette's ``StaticFiles`` matches the path but
|
|
rejects the non-GET method with ``405``. Either way no handler
|
|
ran. A re-mounted legacy route would instead run its handler and
|
|
return ``400`` (missing ``response_id`` for this body), ``501``,
|
|
or ``2xx`` — never ``404``/``405`` — so both assertions still
|
|
bite.
|
|
"""
|
|
assert not any(
|
|
isinstance(route, APIRoute)
|
|
and route.path == "/v1/elicitations/{elicitation_id}"
|
|
and "POST" in route.methods
|
|
for route in app.routes
|
|
), "legacy POST /v1/elicitations/{elicitation_id} route is mounted again"
|
|
|
|
resp = await client.post(
|
|
"/v1/elicitations/elicit_removed",
|
|
json={"action": "accept"},
|
|
)
|
|
assert resp.status_code in (404, 405), resp.text
|
|
|
|
|
|
async def test_permission_request_hook_deny_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
UI declines Claude's permission request → endpoint returns
|
|
``decision.behavior == "deny"``.
|
|
|
|
Mirrors the allow test but the verdict carries
|
|
``action: "decline"``. If this regresses, Claude proceeds with
|
|
tools the user explicitly rejected.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-deny")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="Edit")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "decline")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["hookSpecificOutput"]["decision"]["behavior"] == "deny"
|
|
|
|
|
|
async def test_permission_request_hook_allow_all_edits_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Edit-tool prompt: the endpoint stamps ``allow_all_edits`` on the
|
|
published params, and resolving with ``content.allow_all_edits``
|
|
makes the decision carry a ``setMode`` permission update that
|
|
switches the session into ``acceptEdits`` mode.
|
|
|
|
This is the web equivalent of Claude Code's native shift+tab
|
|
"auto-accept edits" toggle. Failure modes this catches: the gate
|
|
doesn't recognize ``Edit`` as an edit tool (button never offered);
|
|
the verdict→decision mapping drops ``updatedPermissions`` (the
|
|
session never switches mode, so the button silently degrades to a
|
|
plain Approve); the ``setMode`` entry has the wrong field
|
|
names/values (Claude Code ignores it).
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-allow-all-edits")
|
|
session_id = await _create_session(client, agent["id"])
|
|
# ``permission_mode: "default"`` (set by _claude_permission_payload)
|
|
# is the mode that still prompts for edits, so the hint applies.
|
|
payload = await _claude_permission_payload(tool_name="Edit")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# The UI hint is stamped only for edit tools — it drives the
|
|
# "Accept & allow all edits" button.
|
|
assert event["params"]["allow_all_edits"] is True
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"allow_all_edits": True},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# The setMode permission update is what actually switches the
|
|
# session to acceptEdits. Shape matches the Agent SDK's
|
|
# PermissionUpdate ``setMode`` variant; "session" scopes it to this
|
|
# session so it resets on the next one.
|
|
assert decision["updatedPermissions"] == [
|
|
{"type": "setMode", "mode": "acceptEdits", "destination": "session"}
|
|
]
|
|
|
|
|
|
async def test_permission_request_hook_no_allow_all_edits_for_non_edit_tool(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Non-edit tool (Bash): the endpoint does NOT stamp
|
|
``allow_all_edits``, and a plain accept produces a decision with
|
|
no ``updatedPermissions``.
|
|
|
|
Guards the user's core concern — the option must never surface
|
|
where switching to ``acceptEdits`` would be a no-op (acceptEdits
|
|
only auto-approves Edit/Write/MultiEdit/NotebookEdit, not Bash).
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-no-edits-bash")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="Bash")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# No hint for Bash — the button is gated off client-side.
|
|
assert "allow_all_edits" not in event["params"]
|
|
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# A plain Approve must not mint a mode switch.
|
|
assert "updatedPermissions" not in decision
|
|
|
|
|
|
async def test_permission_request_hook_spoofed_allow_all_edits_on_non_edit_tool_ignored(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A client that sends ``content.allow_all_edits: true`` on a NON-edit
|
|
tool (Bash) must NOT receive a ``setMode`` decision.
|
|
|
|
The server re-derives eligibility from the gated tool/mode at the
|
|
verdict site instead of trusting the client's content flag. Without
|
|
that re-check, a crafted approval payload could flip the session
|
|
into ``acceptEdits`` on a prompt the affordance was never offered
|
|
for — the exact gating bypass this guards. (The UI never sends this;
|
|
the test forges the payload directly.)
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-spoofed-edits")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="Bash")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# Forge the flag the UI would never attach to a Bash prompt.
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"allow_all_edits": True},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# Server ignores the spoofed flag — no mode switch for a non-edit tool.
|
|
assert "updatedPermissions" not in decision
|
|
|
|
|
|
async def test_permission_request_hook_edit_plain_accept_has_no_mode_switch(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Edit-tool prompt accepted via plain Approve (no
|
|
``allow_all_edits`` flag) → decision is a plain ``allow`` with no
|
|
``updatedPermissions``.
|
|
|
|
Regression guard: the ``setMode`` update must be opt-in per click.
|
|
If the endpoint emitted it for every edit-tool accept, clicking
|
|
plain Approve would silently switch the whole session into
|
|
auto-accept-edits — exactly the surprise this gating avoids.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-edit-plain")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="Write")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# The hint is present (Write is an edit tool)…
|
|
assert event["params"]["allow_all_edits"] is True
|
|
# …but a plain accept (no content flag) must not carry it through.
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
assert "updatedPermissions" not in decision
|
|
|
|
|
|
async def _claude_webfetch_payload(url: str = "https://github.com/cli/cli") -> dict[str, Any]:
|
|
"""
|
|
Build a Claude PermissionRequest hook body for a WebFetch call.
|
|
|
|
:param url: The URL WebFetch wants to fetch — its host scopes the
|
|
persistent "don't ask again" rule.
|
|
:returns: JSON-serializable PermissionRequest payload for WebFetch.
|
|
"""
|
|
return {
|
|
"session_id": "claude_sess_abc",
|
|
"transcript_path": "/tmp/transcript.jsonl",
|
|
"cwd": "/tmp/cwd",
|
|
"permission_mode": "default",
|
|
"hook_event_name": "PermissionRequest",
|
|
"tool_name": "WebFetch",
|
|
"tool_input": {"url": url, "prompt": "summarize"},
|
|
}
|
|
|
|
|
|
async def test_permission_request_hook_remember_webfetch_domain_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
WebFetch prompt: the endpoint stamps a ``remember_scope`` carrying
|
|
the request host, and resolving with ``content.remember`` makes the
|
|
decision carry an ``addRules`` permission update that installs a
|
|
session-scoped ``WebFetch(domain:<host>)`` allow rule.
|
|
|
|
This restores native Claude Code parity: approving one github.com
|
|
URL stops the per-call re-prompting for the whole domain. Failure
|
|
modes this catches: the scope hint never gets stamped (no button);
|
|
the verdict drops ``updatedPermissions`` (rule never installed, so
|
|
same-domain calls keep prompting); the rule shape is wrong (Claude
|
|
Code ignores it).
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-remember-webfetch")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_webfetch_payload("https://github.com/cli/cli/issues/42")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# The scope hint drives the "don't ask again for github.com" button.
|
|
assert event["params"]["remember_scope"] == {"tool": "WebFetch", "host": "github.com"}
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"remember": True},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# Shape matches the Agent SDK's PermissionUpdate ``addRules`` variant;
|
|
# ``domain:`` scopes the rule to the host, "session" to this session.
|
|
assert decision["updatedPermissions"] == [
|
|
{
|
|
"type": "addRules",
|
|
"rules": [{"toolName": "WebFetch", "ruleContent": "domain:github.com"}],
|
|
"behavior": "allow",
|
|
"destination": "session",
|
|
}
|
|
]
|
|
|
|
|
|
async def test_permission_request_hook_remember_tool_wide_fallback(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Non-WebFetch tool (Bash): the ``remember_scope`` carries only the
|
|
tool (no host), and accepting with ``content.remember`` installs a
|
|
TOOL-WIDE allow rule — an ``addRules`` entry with ``toolName`` and
|
|
no ``ruleContent``.
|
|
|
|
Same fallback path a WebFetch with a missing/unparseable URL takes:
|
|
when there's no domain to scope to, the rule covers the whole tool.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-remember-bash")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="Bash")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# Tool-wide scope: tool only, no host.
|
|
assert event["params"]["remember_scope"] == {"tool": "Bash"}
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"remember": True},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# No ``ruleContent`` → the rule matches the whole tool.
|
|
assert decision["updatedPermissions"] == [
|
|
{
|
|
"type": "addRules",
|
|
"rules": [{"toolName": "Bash"}],
|
|
"behavior": "allow",
|
|
"destination": "session",
|
|
}
|
|
]
|
|
|
|
|
|
async def test_permission_request_hook_remember_not_offered_for_edit_tool(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Edit tools take the ``acceptEdits``/``setMode`` path, NOT a
|
|
persistent allow rule, so the endpoint must not stamp
|
|
``remember_scope`` for them — and a forged ``content.remember`` on
|
|
an edit-tool prompt must NOT produce an ``addRules`` decision.
|
|
|
|
Server-side eligibility guard (mirrors the ``allow_all_edits``
|
|
re-check): without it, a crafted approval payload could smuggle an
|
|
allow rule onto a tool the affordance was never offered for.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-remember-edit-guard")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="Edit")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# No remember hint for edit tools (they get allow_all_edits instead).
|
|
assert "remember_scope" not in event["params"]
|
|
|
|
# Forge the flag the UI would never attach to an edit-tool prompt.
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"remember": True},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# Server ignores the spoofed flag — no allow rule for an edit tool.
|
|
# (No allow_all_edits flag was sent either, so no setMode update,
|
|
# leaving the decision a plain allow with no permission updates.)
|
|
assert "updatedPermissions" not in decision
|
|
|
|
|
|
async def test_permission_request_hook_webfetch_plain_accept_has_no_rule(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
WebFetch accepted via plain Approve (no ``remember`` flag) → a plain
|
|
``allow`` with no ``updatedPermissions``.
|
|
|
|
Regression guard: the allow rule must be opt-in per click. A plain
|
|
Approve must approve only THIS call, leaving future same-domain
|
|
calls still prompting.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-webfetch-plain")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_webfetch_payload()
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# The hint is present (WebFetch is remember-eligible)…
|
|
assert event["params"]["remember_scope"]["tool"] == "WebFetch"
|
|
# …but a plain accept (no content flag) must not carry it through.
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
assert "updatedPermissions" not in decision
|
|
|
|
|
|
async def test_permission_request_hook_remember_webfetch_no_host_tool_wide(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
WebFetch whose URL has NO derivable host (a non-HTTP scheme) falls
|
|
back to a TOOL-WIDE rule: the ``remember_scope`` carries the tool
|
|
only (no ``host`` key), and accepting with ``content.remember``
|
|
installs an ``addRules`` entry with ``toolName`` and no
|
|
``ruleContent``.
|
|
|
|
Guards the WebFetch branch of the tool-wide fallback specifically:
|
|
domain rules are HTTP(S)-oriented, so an ``ftp://`` URL can't be
|
|
scoped to a domain — but the user can still stop the per-call
|
|
prompting for WebFetch as a whole.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-remember-webfetch-toolwide")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_webfetch_payload("ftp://files.example.com/x")
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
# No host could be derived → tool-wide scope (tool only, no host).
|
|
assert event["params"]["remember_scope"] == {"tool": "WebFetch"}
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"remember": True},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# No ``ruleContent`` → the rule matches every WebFetch call.
|
|
assert decision["updatedPermissions"] == [
|
|
{
|
|
"type": "addRules",
|
|
"rules": [{"toolName": "WebFetch"}],
|
|
"behavior": "allow",
|
|
"destination": "session",
|
|
}
|
|
]
|
|
|
|
|
|
async def test_permission_request_hook_forwards_cwd_and_permission_mode(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Claude's ``cwd`` and ``permission_mode`` make it onto the
|
|
published elicitation request params as extras under MCP's
|
|
``extra="allow"`` policy.
|
|
|
|
``cwd`` rides along for UI cues like "Bash in /etc";
|
|
``permission_mode`` lets the UI badge the card with the mode
|
|
Claude is in (``"default"`` / ``"acceptEdits"`` / ``"plan"``).
|
|
|
|
Note: ``tool_use_id`` is intentionally absent — the fixtures omit
|
|
it because Claude Code's PermissionRequest payload doesn't carry one
|
|
(the id is only minted when the tool call is emitted, AFTER
|
|
the permission check). With no id, the terminal-resolved fast path
|
|
correlates a mirrored tool result to a parked prompt by exact
|
|
``(tool_name, tool_input)`` (see
|
|
:func:`omnigent.server.routes.sessions._signal_terminal_resolved_harness_elicitation`),
|
|
and the web UI clears a card strictly by ``elicitation_id`` on the
|
|
server's ``response.elicitation_resolved`` event — never by a
|
|
"first pending" heuristic.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-extras")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
payload["cwd"] = "/home/user/project"
|
|
payload["permission_mode"] = "default"
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["cwd"] == "/home/user/project"
|
|
assert params["permission_mode"] == "default"
|
|
|
|
# Resolve so the hook task doesn't time out and stall pytest.
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
async def test_permission_request_hook_surfaces_ask_user_question_structured(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
When the gated tool is AskUserQuestion, the endpoint stamps
|
|
a structured ``ask_user_question`` payload onto the
|
|
elicitation params (under MCP ``extra="allow"``) so the web
|
|
UI can render an interactive form WITHOUT having to parse the
|
|
truncated ``content_preview`` JSON string.
|
|
|
|
Critical: ``content_preview`` is hard-capped at 1024 chars,
|
|
which is enough for Bash/Edit but the AskUserQuestion payload
|
|
blows past it (the live sample the user pasted truncated
|
|
mid-string in the third question). Surfacing the full
|
|
questions + options structurally is the only way to render
|
|
the form reliably.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-aqu-structured")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="AskUserQuestion")
|
|
payload["tool_input"] = {
|
|
"questions": [
|
|
{
|
|
"question": "Which programming language?",
|
|
"header": "Language",
|
|
"options": [
|
|
{"label": "Python", "description": "Dynamic, ML-heavy"},
|
|
{"label": "Go", "description": "Static, fast"},
|
|
{"label": "Rust", "description": "Memory-safe"},
|
|
],
|
|
"multiSelect": False,
|
|
},
|
|
{
|
|
"question": "Which dev tools? (multi)",
|
|
"header": "Tools",
|
|
"options": [
|
|
{"label": "Claude Code"},
|
|
{"label": "VS Code"},
|
|
],
|
|
"multiSelect": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert "ask_user_question" in params, params
|
|
aqu = params["ask_user_question"]
|
|
assert len(aqu["questions"]) == 2
|
|
first = aqu["questions"][0]
|
|
assert first["question"] == "Which programming language?"
|
|
assert first["header"] == "Language"
|
|
assert first["multiSelect"] is False
|
|
assert first["options"][0] == {"label": "Python", "description": "Dynamic, ML-heavy"}
|
|
second = aqu["questions"][1]
|
|
assert second["multiSelect"] is True
|
|
# Options without descriptions ride through with just the label.
|
|
assert second["options"][0] == {"label": "Claude Code"}
|
|
|
|
# Resolve through the supported session-scoped event route so the
|
|
# hook task doesn't time out and stall pytest.
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
async def test_permission_request_hook_substitutes_ask_user_question_answers(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
When the gated tool is AskUserQuestion and the user accepts
|
|
with selections in ``content``, the hook's response decision
|
|
carries ``updatedInput`` mirroring the original ``tool_input``
|
|
plus an ``answers`` field populated from the verdict content.
|
|
|
|
Without this, the form selection is recorded server-side but
|
|
Claude never sees it — the LLM keeps blocking on the TUI
|
|
picker even though the user already answered via the web UI.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-aqu-substitute")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="AskUserQuestion")
|
|
payload["tool_input"] = {
|
|
"questions": [
|
|
{
|
|
"question": "Which framework should we use?",
|
|
"header": "Framework",
|
|
"options": [
|
|
{"label": "React", "description": "JS UI library"},
|
|
{"label": "Vue", "description": "Progressive framework"},
|
|
],
|
|
"multiSelect": False,
|
|
},
|
|
{
|
|
"question": "Pick snacks",
|
|
"header": "Snacks",
|
|
"options": [
|
|
{"label": "Popcorn", "description": "Classic"},
|
|
{"label": "Pretzels", "description": "Salty"},
|
|
],
|
|
"multiSelect": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "approval",
|
|
"data": {
|
|
"elicitation_id": event["elicitation_id"],
|
|
"action": "accept",
|
|
# Flat MCP ``ElicitResult.content`` shape — each question
|
|
# text is one top-level field; single-select is a string,
|
|
# multi-select is a ``list[str]``.
|
|
"content": {
|
|
"Which framework should we use?": "Vue",
|
|
"Pick snacks": ["Popcorn", "Pretzels"],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
decision = body["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
assert decision["updatedInput"]["questions"] == payload["tool_input"]["questions"]
|
|
assert decision["updatedInput"]["answers"] == {
|
|
"Which framework should we use?": "Vue",
|
|
"Pick snacks": ["Popcorn", "Pretzels"],
|
|
}
|
|
|
|
|
|
async def test_permission_request_hook_no_updated_input_without_answers(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Approving an AskUserQuestion elicitation WITHOUT content (e.g.
|
|
a bare ``{"action": "accept"}`` verdict) must NOT add an empty
|
|
``updatedInput`` to the decision — Claude would interpret that
|
|
as "the user picked nothing" and the LLM would see an empty
|
|
answer set. Bare-accept should fall through to the TUI picker.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-aqu-bare-accept")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="AskUserQuestion")
|
|
payload["tool_input"] = {
|
|
"questions": [
|
|
{
|
|
"question": "Q",
|
|
"header": "H",
|
|
"options": [{"label": "A", "description": "a"}],
|
|
"multiSelect": False,
|
|
},
|
|
],
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202
|
|
resp = await hook_task
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision == {"behavior": "allow"}
|
|
|
|
|
|
async def test_permission_request_hook_surfaces_option_preview(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
When an AskUserQuestion option carries a ``preview`` field,
|
|
the server's structured extraction surfaces it verbatim on
|
|
the published elicitation params. Without this passthrough
|
|
the UI can't render the <pre> preview block — the
|
|
structured-extra helper was dropping ``preview`` even though
|
|
Claude included it on the wire.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-aqu-preview")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="AskUserQuestion")
|
|
payload["tool_input"] = {
|
|
"questions": [
|
|
{
|
|
"question": "Which layout?",
|
|
"header": "Layout",
|
|
"options": [
|
|
{
|
|
"label": "Two-pane",
|
|
"description": "Side-by-side",
|
|
"preview": "[editor] | [output]",
|
|
},
|
|
{
|
|
"label": "Single",
|
|
"description": "One pane",
|
|
# No preview on this option — must NOT appear
|
|
# in the structured payload.
|
|
},
|
|
],
|
|
"multiSelect": False,
|
|
},
|
|
],
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
aqu = event["params"]["ask_user_question"]
|
|
options = aqu["questions"][0]["options"]
|
|
assert options[0]["preview"] == "[editor] | [output]"
|
|
assert "preview" not in options[1]
|
|
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202
|
|
await hook_task
|
|
|
|
|
|
async def test_permission_request_hook_surfaces_exit_plan_mode_input(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
When the gated tool is ExitPlanMode, the endpoint stamps the
|
|
FULL ``tool_input`` verbatim onto the elicitation params as an
|
|
``exit_plan_mode`` extra (under MCP ``extra="allow"``) so the
|
|
web UI can render a dedicated plan-review card.
|
|
|
|
Critical: ``content_preview`` is hard-capped at 1024 chars and
|
|
real plans blow well past it — the structured extra is the only
|
|
untruncated source. The input shape also varies across Claude
|
|
Code builds (``plan`` markdown, ``allowedPrompts``, future
|
|
fields), so the endpoint must pass every field through without
|
|
filtering.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-epm-structured")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="ExitPlanMode")
|
|
# ExitPlanMode fires while Claude is in plan mode.
|
|
payload["permission_mode"] = "plan"
|
|
# Plan markdown longer than the 1024-char content_preview cap —
|
|
# proves the structured extra is NOT subject to the truncation.
|
|
long_plan = "# Migration plan\n\n" + ("- step: do the thing\n" * 100)
|
|
assert len(long_plan) > 1024
|
|
payload["tool_input"] = {
|
|
"plan": long_plan,
|
|
# ``allowedPrompts`` is the newer-build field requesting
|
|
# prompt-based permissions alongside the plan. An unknown
|
|
# extra field rides along to prove no-filtering.
|
|
"allowedPrompts": [
|
|
{"tool": "Bash", "prompt": "run tests"},
|
|
{"tool": "Bash", "prompt": "install dependencies"},
|
|
],
|
|
"futureField": {"nested": True},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
# The whole native tool_input — plan, allowedPrompts, and the
|
|
# unknown field — must ride through verbatim. A missing key or a
|
|
# truncated plan means the endpoint filtered/capped the extra.
|
|
assert params["exit_plan_mode"] == payload["tool_input"], params
|
|
# Plan cards are eligible for the "auto-accept edits" affordance
|
|
# (Claude's native "Yes, and auto-accept edits" dialog option), so
|
|
# the UI-button hint must be stamped alongside the plan.
|
|
assert params["allow_all_edits"] is True
|
|
|
|
# Resolve so the hook task doesn't time out and stall pytest.
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
# A plain accept ("Yes, manually approve edits") pins the session
|
|
# to the prompting ``default`` mode rather than trusting whatever
|
|
# mode Claude's plan-exit restores — every subsequent edit must
|
|
# prompt. ``auto`` here would mean the auto-mode branch fired
|
|
# without the allow_all_edits flag.
|
|
assert resp.json()["hookSpecificOutput"]["decision"] == {
|
|
"behavior": "allow",
|
|
"updatedPermissions": [{"type": "setMode", "mode": "default", "destination": "session"}],
|
|
}
|
|
|
|
|
|
async def test_permission_request_hook_exit_plan_mode_auto_accept_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Accepting an ExitPlanMode prompt with ``allow_all_edits: true``
|
|
in the verdict content returns ``behavior: allow`` PLUS a
|
|
``setMode → auto`` permission update — the plan card's "Yes, and
|
|
use auto mode" option: exit plan mode and continue in Claude's
|
|
``auto`` mode (NOT the narrower ``acceptEdits`` the edit-tool
|
|
affordance uses).
|
|
|
|
Without the eligibility extension this verdict would come back
|
|
as a plain allow and the user would be re-prompted for every
|
|
edit the plan goes on to make.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-epm-auto-accept")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="ExitPlanMode")
|
|
payload["permission_mode"] = "plan"
|
|
payload["tool_input"] = {"plan": "# Plan\n\n- do the thing"}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "approval",
|
|
"data": {
|
|
"elicitation_id": event["elicitation_id"],
|
|
"action": "accept",
|
|
# The UI's "auto-accept edits" button sends the same
|
|
# flag the edit-tool affordance uses.
|
|
"content": {"allow_all_edits": True},
|
|
},
|
|
},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision["behavior"] == "allow"
|
|
# The setMode echo is what flips Claude into auto mode after it
|
|
# exits plan mode; ``destination: session`` scopes it so the mode
|
|
# resets on the next session rather than persisting to settings.
|
|
# ``acceptEdits`` here would mean the edit-tool branch leaked onto
|
|
# the plan path.
|
|
assert decision["updatedPermissions"] == [
|
|
{"type": "setMode", "mode": "auto", "destination": "session"}
|
|
]
|
|
|
|
|
|
async def test_permission_request_hook_decline_forwards_feedback_message(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Declining with ``content.feedback`` (the plan card's "Reject with
|
|
feedback" flow) returns ``behavior: deny`` plus the feedback as
|
|
``decision.message`` — Claude Code surfaces it as the denial
|
|
reason, so the model stays in plan mode and revises toward the
|
|
feedback instead of guessing why the plan was refused.
|
|
|
|
(The bare-decline shape — no ``message`` key — is covered by
|
|
``test_permission_request_hook_deny_round_trip``.)
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-epm-feedback")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload(tool_name="ExitPlanMode")
|
|
payload["permission_mode"] = "plan"
|
|
payload["tool_input"] = {"plan": "# Plan\n\n- rewrite everything"}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "approval",
|
|
"data": {
|
|
"elicitation_id": event["elicitation_id"],
|
|
"action": "decline",
|
|
"content": {"feedback": "Too risky — split it into two phases."},
|
|
},
|
|
},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
# Feedback must ride through verbatim; a missing ``message`` means
|
|
# the user's revision guidance never reached Claude.
|
|
assert decision == {
|
|
"behavior": "deny",
|
|
"message": "Too risky — split it into two phases.",
|
|
}
|
|
|
|
|
|
async def test_permission_request_hook_omits_structured_extras_for_other_tools(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Tools other than AskUserQuestion / ExitPlanMode must NOT carry
|
|
the ``ask_user_question`` / ``exit_plan_mode`` extras on their
|
|
elicitation params. Without this guard the UI would attempt to
|
|
render an interactive form (or a plan-review card) for a Bash
|
|
permission prompt — wrong shape, wrong UX.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-aqu-absent")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=await _claude_permission_payload(), # default tool_name="Bash"
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert "ask_user_question" not in params, params
|
|
assert "exit_plan_mode" not in params, params
|
|
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202
|
|
await hook_task
|
|
|
|
|
|
async def test_permission_request_hook_timeout_returns_empty_body(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
When no verdict arrives within the wait budget, the endpoint
|
|
returns ``200`` with an empty body — Claude Code's HTTP hook
|
|
contract treats that as "defer to the TUI prompt" (fail-ask).
|
|
|
|
Without this contract: an unattended UI would block Claude's
|
|
tool call for the full hook timeout (Claude's own default is
|
|
~10 minutes), then default to allow on its end. Returning empty
|
|
early lets Claude fall back to its terminal prompt promptly.
|
|
|
|
Asserts the response body is literally empty, not just empty
|
|
JSON ``{}`` (the docs explicitly distinguish the two; only the
|
|
empty-body form triggers TUI fallback cleanly).
|
|
"""
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
|
|
0.1,
|
|
)
|
|
agent = await create_test_agent(client, "test-permission-timeout")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.content == b"", f"expected empty body on timeout, got {resp.content!r}"
|
|
|
|
|
|
async def test_permission_request_hook_timeout_clears_pending_index(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The pending-elicitations index is decremented when the hook
|
|
times out — Claude's fail-ask fallback (user answers in the
|
|
TUI instead of the web UI) is the ONLY signal the Omnigent server
|
|
gets that the prompt is done.
|
|
|
|
Without this, the sidebar badge stays stuck forever: the
|
|
increment ran at SSE-publish time, but no
|
|
``approval`` POST ever arrives to clear it because the user
|
|
answered out-of-band through Claude's TUI prompt.
|
|
|
|
Asserts on the index state directly because the path the user
|
|
reported is exactly "I answered in Claude, the badge never
|
|
cleared" — i.e. the visible artifact is the index value
|
|
flowing through ``GET /v1/sessions``.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
|
|
0.1,
|
|
)
|
|
# Timeout defers the index clear by the re-park grace; shrink it
|
|
# so the test observes the clear quickly.
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
0.05,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-permission-timeout-index")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
async def _drain_until_resolved() -> None:
|
|
"""
|
|
Wait for the deferred ``response.elicitation_resolved`` publish.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with asyncio.timeout(3.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
drain_task = asyncio.create_task(_drain_until_resolved())
|
|
# Give the subscriber a moment to register before the publisher
|
|
# fires (publish is broadcast-to-current-subscribers).
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
# Sanity: the hook still returns the fail-ask shape (200 empty
|
|
# body) so Claude proceeds to its TUI prompt. If this changes,
|
|
# something else broke and the index assertion below is
|
|
# secondary.
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.content == b"", f"expected empty body on timeout, got {resp.content!r}"
|
|
# The deferred clear fires only after the re-park grace; waiting
|
|
# for the resolved event (not sleeping) keeps this event-driven.
|
|
await drain_task
|
|
# 0 = the deferred clear decremented the index even though no UI
|
|
# verdict arrived. If > 0, the sidebar would show a stuck badge
|
|
# for every claude-native session whose owner answered in the
|
|
# TUI instead of the web UI.
|
|
assert pending_elicitations.count_for(session_id) == 0, (
|
|
f"index should clear after the hook timeout grace, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}. The bug "
|
|
f"is in the hook wait's deferred-clear path: it must publish "
|
|
f"response.elicitation_resolved when no re-park arrives."
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_pre_resolved_elicitation_tombstone_expires_before_hook_registration(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Expired pre-resolved tombstones are not consumed by later hooks.
|
|
|
|
``external_elicitation_resolved`` can race ahead of the Codex hook
|
|
registration, but if the hook never arrives the tombstone must not
|
|
live forever or resolve a much later prompt that reuses the same
|
|
deterministic id.
|
|
"""
|
|
now = sessions_route.time.time()
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_PRE_RESOLVED_ELICITATION_TTL_S",
|
|
10.0,
|
|
)
|
|
_harness_pre_resolved_elicitations.clear()
|
|
try:
|
|
_harness_pre_resolved_elicitations["elicit_expired"] = _PreResolvedHarnessElicitation(
|
|
session_id="conv_123",
|
|
created_at=now - 11.0,
|
|
)
|
|
|
|
assert not sessions_route._consume_pre_resolved_harness_elicitation(
|
|
"conv_123",
|
|
"elicit_expired",
|
|
)
|
|
assert _harness_pre_resolved_elicitations == {}
|
|
finally:
|
|
_harness_pre_resolved_elicitations.clear()
|
|
|
|
|
|
async def test_pre_resolved_elicitation_tombstones_prune_expired_and_excess(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Pre-resolved tombstones are bounded even when no hook arrives.
|
|
|
|
The old map accepted arbitrary ``external_elicitation_resolved``
|
|
ids for valid sessions and never reaped them. Inserting a new
|
|
tombstone now prunes expired entries and drops the oldest live
|
|
entries beyond the configured cap.
|
|
"""
|
|
now = sessions_route.time.time()
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_PRE_RESOLVED_ELICITATION_TTL_S",
|
|
10.0,
|
|
)
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_PRE_RESOLVED_ELICITATION_MAX_ENTRIES",
|
|
2,
|
|
)
|
|
_harness_pre_resolved_elicitations.clear()
|
|
try:
|
|
_harness_pre_resolved_elicitations.update(
|
|
{
|
|
"elicit_expired": _PreResolvedHarnessElicitation(
|
|
session_id="conv_123",
|
|
created_at=now - 11.0,
|
|
),
|
|
"elicit_oldest_live": _PreResolvedHarnessElicitation(
|
|
session_id="conv_123",
|
|
created_at=now - 3.0,
|
|
),
|
|
"elicit_newer_live": _PreResolvedHarnessElicitation(
|
|
session_id="conv_123",
|
|
created_at=now - 2.0,
|
|
),
|
|
}
|
|
)
|
|
|
|
sessions_route._signal_harness_elicitation_resolved_by_id(
|
|
"conv_123",
|
|
"elicit_newest_live",
|
|
)
|
|
|
|
assert set(_harness_pre_resolved_elicitations) == {
|
|
"elicit_newer_live",
|
|
"elicit_newest_live",
|
|
}
|
|
finally:
|
|
_harness_pre_resolved_elicitations.clear()
|
|
|
|
|
|
async def test_permission_request_hook_clears_index_on_client_disconnect(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
When the upstream client (Claude) closes its HTTP connection
|
|
mid-park, the hook returns the fail-ask shape promptly — without
|
|
waiting the full timeout — and the elicitation drops from the
|
|
pending index after the re-park grace elapses with no retry.
|
|
|
|
A severed connection is ambiguous (TUI answered vs proxy cut an
|
|
idle long-poll), so the clear waits out the grace instead of
|
|
wiping a card the hook's retry is about to re-park.
|
|
|
|
The disconnect is simulated by patching the polling helper rather
|
|
than wiring a real TCP teardown through the in-process ASGI
|
|
transport — deterministic, no transport-layer timing dependence.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
async def _disconnect_immediately(_request: Any) -> None:
|
|
# One short yield so the future-watcher in the asyncio.wait
|
|
# set sees the disconnect task complete after the hook has
|
|
# published the SSE event — same ordering the real socket
|
|
# close would produce.
|
|
await asyncio.sleep(0.01)
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_poll_request_disconnect",
|
|
_disconnect_immediately,
|
|
)
|
|
# Pin the timeout high so the test fails fast if the disconnect
|
|
# race regresses — without the patch the hook would park here
|
|
# for 300s rather than returning on the disconnect signal.
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
|
|
30.0,
|
|
)
|
|
# Wide enough that the count==1 assertion can't race the deferred
|
|
# clear (only a sub-ms in-process round-trip happens in between).
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
1.0,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-permission-disconnect")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
async def _drain_until_resolved() -> None:
|
|
"""
|
|
Wait for the deferred ``response.elicitation_resolved`` publish.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with asyncio.timeout(3.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
drain_task = asyncio.create_task(_drain_until_resolved())
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
# Fail-ask shape: 200 with empty body so Claude falls through to
|
|
# its TUI prompt. Asserting both fields because returning 200 +
|
|
# decision JSON would tell Claude to honor a verdict that never
|
|
# arrived.
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.content == b"", f"expected empty body on disconnect, got {resp.content!r}"
|
|
# 1 = the disconnect did NOT clear the index synchronously — the
|
|
# prompt stays visible through the grace so a hook retry can
|
|
# re-park it. 0 here would mean the deferred clear regressed to
|
|
# the old immediate wipe (blocked sub-agents go invisible again).
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"index should survive the disconnect until the grace elapses, "
|
|
f"got {pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
# After the grace passes with no re-park, the deferred clear must
|
|
# fire so badges don't stick when the hook died for real.
|
|
await drain_task
|
|
assert pending_elicitations.count_for(session_id) == 0, (
|
|
f"index should clear after the un-re-parked grace, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
_REATTACH_ELICITATION_ID = f"elicit_claude_{'ab' * 16}"
|
|
|
|
|
|
async def test_permission_hook_repark_within_grace_keeps_card_pending(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A hook retry that re-parks the same ``_omnigent_elicitation_id``
|
|
within the grace keeps the approval card pending and receives the
|
|
eventual web verdict.
|
|
|
|
This is the proxy-cut path that left headless sub-agents invisibly
|
|
blocked: the old code cleared the card the moment the long-poll was
|
|
severed, while the prompt lived on in an unattended tmux pane.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
disconnect_calls = 0
|
|
|
|
async def _disconnect_first_call_only(_request: Any) -> None:
|
|
"""
|
|
Sever the first hook long-poll; park every later one.
|
|
|
|
:param _request: Ignored FastAPI request.
|
|
:returns: None when simulating the first call's disconnect.
|
|
"""
|
|
nonlocal disconnect_calls
|
|
disconnect_calls += 1
|
|
if disconnect_calls == 1:
|
|
# Yield once so the disconnect lands after the hook
|
|
# published its SSE event, like a real socket close.
|
|
await asyncio.sleep(0.01)
|
|
return
|
|
# Later calls (the re-park) stay connected; the asyncio.wait
|
|
# race cancels this when a verdict arrives.
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_poll_request_disconnect",
|
|
_disconnect_first_call_only,
|
|
)
|
|
# Wide enough that the re-park always lands before the grace expires.
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
1.0,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-permission-repark")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
**(await _claude_permission_payload()),
|
|
"_omnigent_elicitation_id": _REATTACH_ELICITATION_ID,
|
|
}
|
|
|
|
resolved_events: list[dict[str, Any]] = []
|
|
|
|
async def _capture_resolved() -> None:
|
|
"""
|
|
Record every resolved event for this session until cancelled.
|
|
|
|
:returns: None.
|
|
"""
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
resolved_events.append(event)
|
|
|
|
capture_task = asyncio.create_task(_capture_resolved())
|
|
await asyncio.sleep(0.05)
|
|
|
|
# First long-poll: severed by the disconnect fake → fail-ask.
|
|
first = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
assert first.content == b"", f"severed poll should fail-ask, got {first.content!r}"
|
|
# Grab the severed wait's deferred clear so we can await it, not sleep.
|
|
deferred_tasks = set(sessions_route._deferred_elicitation_clear_tasks)
|
|
assert len(deferred_tasks) == 1, (
|
|
f"expected one deferred clear after the severed poll, got "
|
|
f"{len(deferred_tasks)} — disconnect regressed to immediate wipe, or leaked tasks."
|
|
)
|
|
|
|
# Retry re-parks the SAME id while the grace is still running.
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
# The re-park republishes the request; wait for it so the verdict
|
|
# below cannot race the registration.
|
|
event = await _drain_until_elicitation(session_id)
|
|
assert event["elicitation_id"] == _REATTACH_ELICITATION_ID
|
|
|
|
# Run the deferred clear to completion: it must see the re-park and no-op.
|
|
for task in deferred_tasks:
|
|
await asyncio.wait_for(task, timeout=5.0)
|
|
# 1 = the card survived the severed long-poll because the retry
|
|
# re-parked inside the grace. 0 = the deferred clear ignored the
|
|
# re-park and wiped the live prompt (the production bug).
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"re-parked elicitation should stay pending, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
assert resolved_events == [], (
|
|
f"no resolved event may be published while the prompt is "
|
|
f"re-parked, got {resolved_events!r}"
|
|
)
|
|
|
|
verdict = await _post_approval(client, session_id, _REATTACH_ELICITATION_ID, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
# The retried poll (not the severed one) carries the verdict.
|
|
assert resp.json()["hookSpecificOutput"]["decision"]["behavior"] == "allow"
|
|
capture_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await capture_task
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_permission_hook_verdict_during_gap_honored_on_repark(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A web verdict that lands between a severed long-poll and its retry
|
|
is handed to the retry via the pre-resolved tombstone.
|
|
|
|
Without it the retry would re-publish and fail-ask later — the
|
|
user's click silently dropped, the sub-agent still blocked.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
async def _disconnect_immediately(_request: Any) -> None:
|
|
"""
|
|
Sever every hook long-poll straight away.
|
|
|
|
:param _request: Ignored FastAPI request.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.sleep(0.01)
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_poll_request_disconnect",
|
|
_disconnect_immediately,
|
|
)
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
0.25,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-permission-gap-verdict")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
**(await _claude_permission_payload()),
|
|
"_omnigent_elicitation_id": _REATTACH_ELICITATION_ID,
|
|
}
|
|
|
|
first = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
assert first.content == b""
|
|
|
|
# Verdict arrives while NO wait is parked (the gap).
|
|
verdict = await _post_approval(client, session_id, _REATTACH_ELICITATION_ID, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
# The approval cleared the index even though nothing was parked.
|
|
assert pending_elicitations.count_for(session_id) == 0
|
|
|
|
# The retry consumes the tombstone and returns the verdict without
|
|
# re-publishing the prompt (it would otherwise park again and the
|
|
# disconnect fake would fail-ask it).
|
|
second = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert second.status_code == 200, second.text
|
|
body = second.json()
|
|
# "allow" proves the gap verdict was honored; an empty body here
|
|
# would mean the tombstone was dropped and the click lost.
|
|
assert body["hookSpecificOutput"]["decision"]["behavior"] == "allow"
|
|
# Drain the severed poll's deferred clear so it doesn't outlive the
|
|
# test's event loop (it no-ops the index either way).
|
|
for task in set(sessions_route._deferred_elicitation_clear_tasks):
|
|
await asyncio.wait_for(task, timeout=5.0)
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_permission_hook_rejects_malformed_reattach_id(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A client-supplied ``_omnigent_elicitation_id`` outside the
|
|
claude-hook namespace is rejected at the route boundary.
|
|
|
|
The id is client-controlled; without the format gate a client
|
|
could squat on Codex deterministic ids or server-minted ids and
|
|
interfere with another prompt's lifecycle.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-bad-id")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
**(await _claude_permission_payload()),
|
|
"_omnigent_elicitation_id": "elicit_codex_squat",
|
|
}
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
assert "_omnigent_elicitation_id" in resp.text
|
|
|
|
|
|
async def test_permission_hook_rejects_reattach_id_owned_by_other_session(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A re-attach id currently parked by another session is rejected.
|
|
|
|
Cross-session guard: session B must not be able to adopt session
|
|
A's live elicitation id — doing so would overwrite A's owner
|
|
registration and let B's approval flow resolve A's prompt.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-id-collision")
|
|
session_a = await _create_session(client, agent["id"])
|
|
session_b = await _create_session(client, agent["id"])
|
|
payload = {
|
|
**(await _claude_permission_payload()),
|
|
"_omnigent_elicitation_id": _REATTACH_ELICITATION_ID,
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_a))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_a}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
event = await drain_task
|
|
assert event["elicitation_id"] == _REATTACH_ELICITATION_ID
|
|
|
|
# Session B tries to park the id session A currently owns.
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_b}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
assert "different session" in resp.text
|
|
|
|
# Unwind session A's parked hook so no task leaks past the test.
|
|
verdict = await _post_approval(client, session_a, _REATTACH_ELICITATION_ID, "decline")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp_a = await hook_task
|
|
assert resp_a.json()["hookSpecificOutput"]["decision"]["behavior"] == "deny"
|
|
|
|
|
|
async def test_codex_mcp_elicitation_hook_accept_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex ``mcpServer/elicitation/request`` frames are published to the
|
|
web UI and the accepted form content is returned in Codex's
|
|
app-server result shape.
|
|
|
|
This is the native-Codex equivalent of the Claude permission hook
|
|
allow path: a forwarded app-server request parks on the shared
|
|
elicitation registry, the web ``approval`` event resolves it, and
|
|
the hook response is exactly what the forwarder sends back over
|
|
JSON-RPC.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-mcp-elicit")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": 7,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {
|
|
"type": "object",
|
|
"properties": {"date": {"type": "string"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["message"] == "Pick a date"
|
|
assert params["requestedSchema"] == payload["params"]["requestedSchema"]
|
|
assert params["server_name"] == "booking"
|
|
assert params["codex_method"] == "mcpServer/elicitation/request"
|
|
assert params["codex_request_id"] == 7
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"date": "tomorrow"},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {
|
|
"action": "accept",
|
|
"content": {"date": "tomorrow"},
|
|
"_meta": None,
|
|
}
|
|
|
|
|
|
async def test_codex_pending_elicitation_survives_session_snapshot_refresh(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A parked Codex app-server elicitation is replayed by session snapshot.
|
|
|
|
This exercises the real hook path instead of seeding
|
|
``pending_elicitations`` directly: the Codex request parks in the
|
|
shared harness elicitation registry, publishes
|
|
``response.elicitation_request``, and ``GET /v1/sessions/{id}``
|
|
must include that exact event in ``pending_elicitations`` so a
|
|
browser refresh can reconstruct the approval card.
|
|
|
|
:param client: Test HTTP client.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-codex-pending-refresh")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": 17,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_cmd",
|
|
"startedAtMs": 1,
|
|
"approvalId": None,
|
|
"reason": "test command approval",
|
|
"command": "date",
|
|
"cwd": "/tmp/workspace",
|
|
"commandActions": [],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
elicitation_id = event["elicitation_id"]
|
|
assert pending_elicitations.count_for(session_id) == 1
|
|
|
|
snapshot_resp = await client.get(f"/v1/sessions/{session_id}")
|
|
assert snapshot_resp.status_code == 200, snapshot_resp.text
|
|
pending_payloads = snapshot_resp.json()["pending_elicitations"]
|
|
assert len(pending_payloads) == 1
|
|
replay = pending_payloads[0]
|
|
assert replay["type"] == "response.elicitation_request"
|
|
assert replay["elicitation_id"] == elicitation_id
|
|
assert replay["params"]["command"] == "date"
|
|
assert replay["params"]["cwd"] == "/tmp/workspace"
|
|
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"decision": "accept"}
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_codex_elicitation_resolved_event_clears_hook_wait(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex ``serverRequest/resolved`` propagation clears the web prompt.
|
|
|
|
The Codex-native forwarder posts ``external_elicitation_resolved``
|
|
when another Codex client answers the JSON-RPC request first. The
|
|
server must resolve the parked hook wait and publish the standard
|
|
``response.elicitation_resolved`` SSE event for the deterministic
|
|
Omnigent elicitation id.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-resolved-event")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": 7,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {
|
|
"type": "object",
|
|
"properties": {"date": {"type": "string"}},
|
|
},
|
|
},
|
|
}
|
|
expected_elicitation_id = codex_elicitation_id(
|
|
session_id,
|
|
"mcpServer/elicitation/request",
|
|
7,
|
|
)
|
|
request_seen = asyncio.Event()
|
|
captured: list[dict[str, Any]] = []
|
|
|
|
async def _capture() -> None:
|
|
"""
|
|
Capture the request and resolved stream events for this hook.
|
|
|
|
:returns: None after the resolved event is observed.
|
|
"""
|
|
async with asyncio.timeout(5.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") in {
|
|
"response.elicitation_request",
|
|
"response.elicitation_resolved",
|
|
}:
|
|
captured.append(event)
|
|
if event.get("type") == "response.elicitation_request":
|
|
request_seen.set()
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
capture_task = asyncio.create_task(_capture())
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
await asyncio.wait_for(request_seen.wait(), timeout=5.0)
|
|
|
|
request_event = next(e for e in captured if e.get("type") == "response.elicitation_request")
|
|
assert request_event["elicitation_id"] == expected_elicitation_id
|
|
|
|
resolved_resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "external_elicitation_resolved",
|
|
"data": {"elicitation_id": expected_elicitation_id},
|
|
},
|
|
)
|
|
assert resolved_resp.status_code == 202, resolved_resp.text
|
|
|
|
hook_resp = await hook_task
|
|
assert hook_resp.status_code == 200, hook_resp.text
|
|
assert hook_resp.text == ""
|
|
await capture_task
|
|
|
|
resolved = [e for e in captured if e.get("type") == "response.elicitation_resolved"]
|
|
assert len(resolved) == 1
|
|
assert resolved[0]["elicitation_id"] == expected_elicitation_id
|
|
|
|
|
|
async def test_codex_request_user_input_hook_returns_id_keyed_answers(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex ``item/tool/requestUserInput`` frames use the shared
|
|
AskUserQuestion form payload, but return answers keyed by Codex's
|
|
stable question id rather than by display text.
|
|
|
|
Without this id preservation the web UI can render the question,
|
|
but the app-server receives an empty ``answers`` object and Codex
|
|
keeps waiting for input.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-request-user-input")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": "req_9",
|
|
"method": "item/tool/requestUserInput",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_123",
|
|
"questions": [
|
|
{
|
|
"id": "framework",
|
|
"header": "Framework",
|
|
"question": "Which framework?",
|
|
"options": [{"label": "React", "description": "JS UI"}],
|
|
"isOther": False,
|
|
"isSecret": False,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
ask_payload = event["params"]["ask_user_question"]
|
|
assert ask_payload["questions"][0]["id"] == "framework"
|
|
assert ask_payload["questions"][0]["question"] == "Which framework?"
|
|
assert ask_payload["questions"][0]["options"] == [{"label": "React", "description": "JS UI"}]
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"framework": "React"},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"answers": {"framework": {"answers": ["React"]}}}
|
|
|
|
|
|
async def test_codex_plan_mode_final_prompt_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex plan mode's final "Implement this plan?" prompt is surfaced
|
|
as a structured web approval form and returns the selected option
|
|
under the stable question id.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-plan-final-prompt")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": "plan_prompt",
|
|
"method": "item/tool/requestUserInput",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_plan_prompt",
|
|
"questions": [
|
|
{
|
|
"id": "plan_decision",
|
|
"header": "Plan",
|
|
"question": "Implement this plan?",
|
|
"options": [
|
|
{
|
|
"label": "Yes, implement this plan",
|
|
"description": "Switch to Default and start coding.",
|
|
},
|
|
{
|
|
"label": "Yes, clear context and implement",
|
|
"description": "Fresh thread. Context: 8% used.",
|
|
},
|
|
{
|
|
"label": "No, stay in Plan mode",
|
|
"description": "Continue planning with the model.",
|
|
},
|
|
],
|
|
"isOther": False,
|
|
"isSecret": False,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["message"] == "Codex needs input"
|
|
question = params["ask_user_question"]["questions"][0]
|
|
assert question["id"] == "plan_decision"
|
|
assert question["question"] == "Implement this plan?"
|
|
assert [option["label"] for option in question["options"]] == [
|
|
"Yes, implement this plan",
|
|
"Yes, clear context and implement",
|
|
"No, stay in Plan mode",
|
|
]
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"plan_decision": "Yes, implement this plan"},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"answers": {"plan_decision": {"answers": ["Yes, implement this plan"]}}}
|
|
|
|
|
|
async def test_codex_command_approval_hook_accept_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex command approval requests are published as binary web
|
|
approvals and accepted web verdicts return Codex's command
|
|
approval response shape.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-command-approval")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": 13,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_cmd",
|
|
"startedAtMs": 1,
|
|
"approvalId": None,
|
|
"reason": "test command approval",
|
|
"command": "date",
|
|
"cwd": "/tmp/workspace",
|
|
"commandActions": [],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["message"] == "Codex wants to run **date**"
|
|
assert params["command"] == "date"
|
|
assert params["cwd"] == "/tmp/workspace"
|
|
assert params["reason"] == "test command approval"
|
|
assert params["codex_method"] == "item/commandExecution/requestApproval"
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"decision": "accept"}
|
|
|
|
|
|
async def test_codex_command_approval_hook_accepts_execpolicy_amendment(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex command approval requests that offer an execpolicy
|
|
amendment can be accepted with that exact amendment, returning
|
|
Codex's structured ``acceptWithExecpolicyAmendment`` decision.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-execpolicy-approval")
|
|
session_id = await _create_session(client, agent["id"])
|
|
amendment = [".venv/bin/python", "-m", "pytest"]
|
|
payload = {
|
|
"id": 131,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_cmd",
|
|
"startedAtMs": 1,
|
|
"approvalId": None,
|
|
"reason": "test command approval",
|
|
"command": ".venv/bin/python -m pytest tests/test_codex_native.py -q",
|
|
"cwd": "/tmp/workspace",
|
|
"commandActions": [],
|
|
"availableDecisions": [
|
|
"accept",
|
|
{
|
|
"acceptWithExecpolicyAmendment": {
|
|
"execpolicy_amendment": amendment,
|
|
},
|
|
},
|
|
"cancel",
|
|
],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["execpolicy_amendment"] == amendment
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"execpolicy_amendment": amendment},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {
|
|
"decision": {
|
|
"acceptWithExecpolicyAmendment": {
|
|
"execpolicy_amendment": amendment,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
async def test_codex_command_approval_hook_rejects_malformed_execpolicy_amendment(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Malformed remember approvals fail instead of degrading to a plain
|
|
command accept.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-execpolicy-invalid")
|
|
session_id = await _create_session(client, agent["id"])
|
|
amendment = [".venv/bin/python", "-m", "pytest"]
|
|
payload = {
|
|
"id": 132,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_cmd",
|
|
"startedAtMs": 1,
|
|
"approvalId": None,
|
|
"reason": "test command approval",
|
|
"command": ".venv/bin/python -m pytest tests/test_codex_native.py -q",
|
|
"cwd": "/tmp/workspace",
|
|
"commandActions": [],
|
|
"availableDecisions": [
|
|
"accept",
|
|
{
|
|
"acceptWithExecpolicyAmendment": {
|
|
"execpolicy_amendment": amendment,
|
|
},
|
|
},
|
|
"cancel",
|
|
],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"execpolicy_amendment": []},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 400, resp.text
|
|
assert resp.json()["error"]["code"] == "invalid_input"
|
|
assert "execpolicy amendment" in resp.json()["error"]["message"]
|
|
|
|
|
|
async def test_codex_permissions_approval_hook_grants_requested_profile(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Codex ``item/permissions/requestApproval`` frames are surfaced as
|
|
web approvals and accepted verdicts return the requested permission
|
|
profile in Codex's response shape.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-permissions-approval")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": 14,
|
|
"method": "item/permissions/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_permissions",
|
|
"startedAtMs": 1,
|
|
"cwd": "/tmp/workspace",
|
|
"reason": "need network",
|
|
"permissions": {
|
|
"network": {"enabled": True},
|
|
"fileSystem": None,
|
|
},
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["message"] == "Codex requests additional permissions"
|
|
assert params["cwd"] == "/tmp/workspace"
|
|
assert params["reason"] == "need network"
|
|
assert params["permissions"] == payload["params"]["permissions"]
|
|
assert params["codex_method"] == "item/permissions/requestApproval"
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {
|
|
"permissions": {"network": {"enabled": True}},
|
|
"scope": "turn",
|
|
}
|
|
|
|
|
|
async def test_codex_legacy_exec_command_approval_uses_legacy_decision_literals(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Older Codex ``execCommandApproval`` requests expect
|
|
``approved``/``denied`` review decisions rather than v2
|
|
``accept``/``decline`` literals.
|
|
"""
|
|
agent = await create_test_agent(client, "test-codex-legacy-command-approval")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = {
|
|
"id": "legacy_1",
|
|
"method": "execCommandApproval",
|
|
"params": {
|
|
"conversationId": "thread_123",
|
|
"callId": "call_123",
|
|
"approvalId": None,
|
|
"command": ["date"],
|
|
"cwd": "/tmp/workspace",
|
|
"reason": "test legacy approval",
|
|
"parsedCmd": [],
|
|
},
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
params = event["params"]
|
|
assert params["command"] == "date"
|
|
assert params["call_id"] == "call_123"
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"decline",
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"decision": "denied"}
|
|
|
|
|
|
async def test_codex_elicitation_hook_timeout_clears_pending_index(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Codex-native elicitation timeout uses the same cleanup path as the
|
|
Claude hook: return an empty body and decrement the pending
|
|
elicitation index once the re-park grace elapses with no retry.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_CODEX_NATIVE_ELICITATION_HOOK_TIMEOUT_S",
|
|
0.1,
|
|
)
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
0.05,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-codex-elicit-timeout")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
async def _drain_until_resolved() -> None:
|
|
"""
|
|
Wait for the deferred ``response.elicitation_resolved`` publish.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with asyncio.timeout(3.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
drain_task = asyncio.create_task(_drain_until_resolved())
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json={
|
|
"id": 12,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"mode": "form",
|
|
"message": "Pick a value",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.content == b""
|
|
# Deferred clear — the index drops only after the grace passes
|
|
# with no re-park, so wait for the resolved event first.
|
|
await drain_task
|
|
assert pending_elicitations.count_for(session_id) == 0
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_permission_request_hook_validates_tool_name(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A request without ``tool_name`` is rejected at the route boundary
|
|
with 400, not parked silently on the elicitation registry.
|
|
|
|
Without this guard, a malformed payload from Claude Code would
|
|
hang the hook for the full timeout and the UI would render a
|
|
``response.elicitation_request`` with an empty preview — neither
|
|
useful nor diagnosable.
|
|
"""
|
|
agent = await create_test_agent(client, "test-permission-validate")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json={
|
|
"hook_event_name": "PermissionRequest",
|
|
"tool_input": {"command": "ls"},
|
|
},
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
assert "tool_name" in resp.text
|
|
|
|
|
|
async def test_permission_hook_finally_emits_elicitation_resolved_on_timeout(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
On hook timeout, the ``finally`` block publishes a
|
|
``response.elicitation_resolved`` SSE event (in addition to
|
|
clearing the cross-session index). Multi-tab web UI clients
|
|
rely on the SSE event to flip their copy of the
|
|
``ApprovalCard`` — without it, every tab the user hasn't
|
|
actively interacted with would hold the prompt as pending
|
|
until the user refreshes.
|
|
|
|
Asserts on the SSE event, not just the index, because the
|
|
chat-store consumer is the visible artifact for the
|
|
multi-tab path.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
|
|
0.1,
|
|
)
|
|
# The resolved event is deferred by the re-park grace; shrink it
|
|
# so the 3s capture window sees the publish.
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
0.05,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-permission-timeout-resolved-event")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
captured: list[dict[str, Any]] = []
|
|
|
|
async def _capture() -> None:
|
|
async with asyncio.timeout(3.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
captured.append(event)
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
drain_task = asyncio.create_task(_capture())
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
await drain_task
|
|
|
|
resolved = [e for e in captured if e.get("type") == "response.elicitation_resolved"]
|
|
assert len(resolved) == 1, (
|
|
f"expected one elicitation_resolved on timeout, got {resolved!r}. "
|
|
f"The PermissionRequest hook's finally block must publish "
|
|
f"response.elicitation_resolved so other tabs / TUI clients "
|
|
f"learn the prompt is dead."
|
|
)
|
|
# And the original request matches the resolved one (single
|
|
# round trip — the id from the request must be the same id
|
|
# cleared at the end).
|
|
requested = [e for e in captured if e.get("type") == "response.elicitation_request"]
|
|
assert len(requested) == 1
|
|
assert resolved[0]["elicitation_id"] == requested[0]["elicitation_id"]
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_approval_dispatch_publishes_elicitation_resolved(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
The happy-path approval dispatch on ``POST /events`` publishes
|
|
a ``response.elicitation_resolved`` SSE event after resolving
|
|
the harness future. Tabs other than the one that submitted
|
|
the verdict rely on this signal to flip their card from
|
|
pending to resolved — without it, the second tab would stay
|
|
frozen on the original prompt until refresh.
|
|
|
|
Triggers the harness-future branch by registering an
|
|
elicitation through the real ``PermissionRequest`` hook, then
|
|
delivering an ``approval`` verdict from the test side.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-approval-dispatch-emits-resolved")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
# Capture both the request AND the resolved event in one
|
|
# subscriber so we can verify ordering as well as presence.
|
|
captured: list[dict[str, Any]] = []
|
|
|
|
async def _capture() -> None:
|
|
async with asyncio.timeout(5.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
captured.append(event)
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
drain_task = asyncio.create_task(_capture())
|
|
await asyncio.sleep(0.05)
|
|
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
|
|
# Wait for the request event to surface so we know the id.
|
|
elicit_event = await _drain_until_elicitation(session_id)
|
|
elicitation_id = elicit_event["elicitation_id"]
|
|
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
await drain_task
|
|
|
|
resolved = [e for e in captured if e.get("type") == "response.elicitation_resolved"]
|
|
# The dispatch path AND the hook's finally each publish — both
|
|
# are idempotent on consumers. At least one must fire.
|
|
assert len(resolved) >= 1, (
|
|
f"expected approval dispatch to publish elicitation_resolved, "
|
|
f"got events {[e.get('type') for e in captured]!r}"
|
|
)
|
|
assert resolved[0]["elicitation_id"] == elicitation_id
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def _subscribe_into(
|
|
session_id: str,
|
|
sink: list[dict[str, Any]],
|
|
*,
|
|
timeout_s: float = 10.0,
|
|
) -> None:
|
|
"""
|
|
Append every SSE event for ``session_id`` into ``sink`` until the
|
|
enclosing task is cancelled or ``timeout_s`` elapses.
|
|
|
|
A single long-lived subscriber (vs. :func:`_drain_until_elicitation`,
|
|
which returns on the first elicitation) lets a test observe the
|
|
*absence* of a later event — here, that an unrelated tool result did
|
|
NOT publish a ``response.elicitation_resolved`` for a pending prompt.
|
|
|
|
:param session_id: Session to subscribe to.
|
|
:param sink: List the subscriber appends each event dict into.
|
|
:param timeout_s: Hard cap so a leaked subscriber can't hang pytest.
|
|
"""
|
|
try:
|
|
async with asyncio.timeout(timeout_s):
|
|
async for event in session_stream.subscribe(session_id):
|
|
sink.append(event)
|
|
except asyncio.TimeoutError:
|
|
return
|
|
|
|
|
|
async def _wait_for_event(
|
|
sink: list[dict[str, Any]],
|
|
predicate: Any,
|
|
*,
|
|
timeout_s: float = 5.0,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Poll ``sink`` until an event matching ``predicate`` appears.
|
|
|
|
Used with :func:`_subscribe_into` so the test reads ids out of the
|
|
same subscriber that also watches for the absence of later events
|
|
(a second `subscribe` could miss a publish that landed before it
|
|
registered — publish is broadcast-to-current-subscribers).
|
|
|
|
:param sink: Event list a subscriber task is appending into.
|
|
:param predicate: ``Callable[[dict], bool]`` selecting the event.
|
|
:param timeout_s: Fail the test if no match arrives in this window.
|
|
:returns: The first matching event dict.
|
|
"""
|
|
async with asyncio.timeout(timeout_s):
|
|
while True:
|
|
for event in sink:
|
|
if predicate(event):
|
|
return event
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
async def _post_external_conversation_item(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
item_type: str,
|
|
item_data: dict[str, Any],
|
|
*,
|
|
source_id: str,
|
|
response_id: str,
|
|
) -> httpx.Response:
|
|
"""
|
|
Forward one claude-native transcript item through the public route.
|
|
|
|
Mirrors what ``claude_native_forwarder`` POSTs for an observed
|
|
``function_call`` / ``function_call_output`` — the path that lands
|
|
in :func:`_publish_external_conversation_item`.
|
|
|
|
:param client: Test HTTP client.
|
|
:param session_id: Target session.
|
|
:param item_type: ``"function_call"`` or ``"function_call_output"``.
|
|
:param item_data: The item body (sans ``type``), e.g.
|
|
``{"call_id": "...", "output": "..."}``.
|
|
:param source_id: Producer-stable dedup key.
|
|
:param response_id: Turn-scoped response id the item belongs to.
|
|
:returns: The HTTP response from the session event route.
|
|
"""
|
|
return await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": item_type,
|
|
"item_data": item_data,
|
|
"source_id": source_id,
|
|
"response_id": response_id,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
async def test_non_gated_tool_output_does_not_resolve_pending_elicitation(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
An unrelated tool's ``function_call_output`` must NOT resolve a
|
|
pending approval prompt for a different gated tool the user hasn't
|
|
answered yet.
|
|
|
|
Scenario (claude-native, the reported config):
|
|
- Claude's ``PermissionRequest`` hook parks an approval for ``Bash``.
|
|
The user has answered neither the TUI nor the web UI; the
|
|
``ApprovalCard`` is live and ``elicit_bash`` sits in the pending
|
|
index.
|
|
- Before that resolves, Claude runs an auto-allowed ``Read`` (no
|
|
permission prompt). Its ``function_call`` + ``function_call_output``
|
|
are forwarded as ``external_conversation_item`` POSTs.
|
|
|
|
The old auto-resolve heuristic popped pending elicitations from
|
|
forwarded tool observations. An unrelated ``Read`` output could
|
|
silently resolve the ``Bash`` prompt: the web UI received
|
|
``response.elicitation_resolved`` and the card vanished, leaving
|
|
"Claude is working" while the terminal stayed blocked.
|
|
|
|
Asserts the ``Bash`` prompt survives the ``Read`` output: no
|
|
``elicitation_resolved`` for its id is published, and the pending
|
|
index still counts it. Fails if forwarded tool observations start
|
|
resolving pending permissions again.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-1594-non-gated")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
# One long-lived subscriber: it both yields the Bash elicitation id
|
|
# and lets us assert the *absence* of a resolved event afterwards.
|
|
captured: list[dict[str, Any]] = []
|
|
capture_task = asyncio.create_task(_subscribe_into(session_id, captured))
|
|
# Let the subscriber register before any publish (broadcast-only).
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Park a gated Bash approval — nobody answers it.
|
|
bash_payload = await _claude_permission_payload(tool_name="Bash")
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=bash_payload,
|
|
)
|
|
)
|
|
bash_event = await _wait_for_event(
|
|
captured,
|
|
lambda e: e.get("type") == "response.elicitation_request",
|
|
)
|
|
bash_elicit_id = bash_event["elicitation_id"]
|
|
# Precondition: exactly the Bash prompt is outstanding.
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"expected the Bash prompt to be the only pending elicitation, "
|
|
f"got count {pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
# Claude runs an auto-allowed Read (no PermissionRequest), forwarded
|
|
# as its tool_use then tool_result. Different tool, different call id.
|
|
read_call_id = "toolu_read_1594"
|
|
fc = await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call",
|
|
{
|
|
"agent": "Claude",
|
|
"name": "Read",
|
|
"arguments": json.dumps({"file_path": "/etc/hosts"}),
|
|
"call_id": read_call_id,
|
|
},
|
|
source_id="src_read_fc_1594",
|
|
response_id="resp_1594",
|
|
)
|
|
assert fc.status_code < 300, fc.text
|
|
fco = await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call_output",
|
|
{"call_id": read_call_id, "output": "127.0.0.1 localhost\n"},
|
|
source_id="src_read_fco_1594",
|
|
response_id="resp_1594",
|
|
)
|
|
assert fco.status_code < 300, fco.text
|
|
|
|
# Give the publish path time to wrongly resolve the prompt.
|
|
await asyncio.sleep(0.2)
|
|
|
|
# The bug: the unrelated Read output resolved the Bash prompt. If any
|
|
# elicitation_resolved for the Bash id was published, the web UI's
|
|
# ApprovalCard was cleared while the terminal is still blocked.
|
|
resolved_for_bash = [
|
|
e
|
|
for e in captured
|
|
if e.get("type") == "response.elicitation_resolved"
|
|
and e.get("elicitation_id") == bash_elicit_id
|
|
]
|
|
assert resolved_for_bash == [], (
|
|
f"the auto-allowed Read tool's function_call_output "
|
|
f"resolved the still-pending Bash approval (events: "
|
|
f"{[e.get('type') for e in captured]!r}). Forwarded tool "
|
|
f"observations must not pop pending permission prompts."
|
|
)
|
|
# Index unchanged: the sidebar/snapshot still show the Bash prompt.
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"expected the Bash prompt to survive the Read output, but the "
|
|
f"index now counts {pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
# Cleanup: answer the Bash prompt so the parked hook returns, then
|
|
# stop the subscriber so pytest doesn't wait out the timeout.
|
|
verdict = await _post_approval(client, session_id, bash_elicit_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
await hook_task
|
|
capture_task.cancel()
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_gated_tool_output_resolves_pending_elicitation(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
The gated tool's OWN ``function_call_output`` resolves its pending
|
|
permission prompt promptly — the terminal-resolved fast path that
|
|
fixes the reported "stuck for minutes" bug.
|
|
|
|
Scenario (claude-native, the reported config): the
|
|
``PermissionRequest`` hook parks a ``Bash`` approval; the user
|
|
answers in Claude's TUI, NOT the web UI, so the parked hook never
|
|
gets a web verdict and ``request.is_disconnected()`` does not fire
|
|
behind the Databricks Apps proxy. Claude runs the tool and the
|
|
forwarder mirrors its ``function_call`` then ``function_call_output``.
|
|
The output carries the SAME tool name + input as the parked prompt,
|
|
so it must resolve the prompt now instead of leaving the badge up
|
|
until the 300s hook timeout.
|
|
|
|
Asserts: ``response.elicitation_resolved`` for the Bash prompt is
|
|
published, the pending index drops to 0, and the parked hook POST
|
|
returns ``200`` with an empty body (fail-ask — Claude already has
|
|
its TUI answer). Positive counterpart to
|
|
``test_non_gated_tool_output_does_not_resolve_pending_elicitation``.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-terminal-resolved")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
captured: list[dict[str, Any]] = []
|
|
capture_task = asyncio.create_task(_subscribe_into(session_id, captured))
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Park a gated Bash approval — nobody answers it via the web UI.
|
|
bash_payload = await _claude_permission_payload(tool_name="Bash")
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=bash_payload,
|
|
)
|
|
)
|
|
bash_event = await _wait_for_event(
|
|
captured,
|
|
lambda e: e.get("type") == "response.elicitation_request",
|
|
)
|
|
bash_elicit_id = bash_event["elicitation_id"]
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"expected the Bash prompt to be parked, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
# The user answered in the TUI; Claude runs the gated Bash and the
|
|
# forwarder mirrors its tool_use then tool_result. Same tool name AND
|
|
# input as the parked prompt → correlated to this prompt.
|
|
bash_call_id = "toolu_bash_resolved"
|
|
fc = await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call",
|
|
{
|
|
"agent": "Claude",
|
|
"name": "Bash",
|
|
"arguments": json.dumps(bash_payload["tool_input"]),
|
|
"call_id": bash_call_id,
|
|
},
|
|
source_id="src_bash_fc",
|
|
response_id="resp_bash",
|
|
)
|
|
assert fc.status_code < 300, fc.text
|
|
fco = await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call_output",
|
|
{"call_id": bash_call_id, "output": "total 0\n"},
|
|
source_id="src_bash_fco",
|
|
response_id="resp_bash",
|
|
)
|
|
assert fco.status_code < 300, fco.text
|
|
|
|
# The mirrored result resolves the prompt: elicitation_resolved fires,
|
|
# the parked hook POST returns 200 empty body (fail-ask), index → 0.
|
|
resolved = await _wait_for_event(
|
|
captured,
|
|
lambda e: (
|
|
e.get("type") == "response.elicitation_resolved"
|
|
and e.get("elicitation_id") == bash_elicit_id
|
|
),
|
|
)
|
|
assert resolved["elicitation_id"] == bash_elicit_id
|
|
hook_resp = await hook_task
|
|
assert hook_resp.status_code == 200, hook_resp.text
|
|
assert not hook_resp.content, (
|
|
f"terminal-resolved prompt must return empty body (fail-ask), got {hook_resp.content!r}"
|
|
)
|
|
assert pending_elicitations.count_for(session_id) == 0, (
|
|
f"expected the Bash prompt to clear off the mirrored result, but "
|
|
f"the index still counts {pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
capture_task.cancel()
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_tool_output_resolves_only_the_matching_same_name_prompt(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
With two same-named prompts parked, a tool result clears only the
|
|
one whose input matches — not the other, and not both.
|
|
|
|
Guards the correlation's input disambiguation. Claude blocks
|
|
per-prompt so two outstanding ``Bash`` approvals is rare, but a
|
|
parallel gated batch can park two at once; a mirrored result for one
|
|
specific command must resolve exactly that prompt. Without input
|
|
matching the fast path would fall back to resolving an arbitrary
|
|
same-named prompt — the kind of mis-resolution this guards against.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-terminal-resolved-match")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
captured: list[dict[str, Any]] = []
|
|
capture_task = asyncio.create_task(_subscribe_into(session_id, captured))
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Park two Bash prompts with distinct inputs.
|
|
payload_a = {**await _claude_permission_payload("Bash"), "tool_input": {"command": "ls"}}
|
|
payload_b = {**await _claude_permission_payload("Bash"), "tool_input": {"command": "rm x"}}
|
|
hook_a = asyncio.create_task(
|
|
client.post(f"/v1/sessions/{session_id}/hooks/permission-request", json=payload_a)
|
|
)
|
|
elicit_a = (
|
|
await _wait_for_event(
|
|
captured,
|
|
lambda e: e.get("type") == "response.elicitation_request",
|
|
)
|
|
)["elicitation_id"]
|
|
hook_b = asyncio.create_task(
|
|
client.post(f"/v1/sessions/{session_id}/hooks/permission-request", json=payload_b)
|
|
)
|
|
elicit_b = (
|
|
await _wait_for_event(
|
|
captured,
|
|
lambda e: (
|
|
e.get("type") == "response.elicitation_request"
|
|
and e.get("elicitation_id") != elicit_a
|
|
),
|
|
)
|
|
)["elicitation_id"]
|
|
assert pending_elicitations.count_for(session_id) == 2
|
|
|
|
# Mirror the result for command "rm x" (prompt B) only.
|
|
call_id = "toolu_bash_b"
|
|
await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call",
|
|
{
|
|
"agent": "Claude",
|
|
"name": "Bash",
|
|
"arguments": json.dumps({"command": "rm x"}),
|
|
"call_id": call_id,
|
|
},
|
|
source_id="src_fc_b",
|
|
response_id="resp_b",
|
|
)
|
|
await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call_output",
|
|
{"call_id": call_id, "output": "done\n"},
|
|
source_id="src_fco_b",
|
|
response_id="resp_b",
|
|
)
|
|
|
|
# Prompt B resolves; prompt A stays live.
|
|
await _wait_for_event(
|
|
captured,
|
|
lambda e: (
|
|
e.get("type") == "response.elicitation_resolved"
|
|
and e.get("elicitation_id") == elicit_b
|
|
),
|
|
)
|
|
# Give any wrong resolution of A a chance to surface before asserting.
|
|
await asyncio.sleep(0.1)
|
|
resolved_a = [
|
|
e
|
|
for e in captured
|
|
if e.get("type") == "response.elicitation_resolved" and e.get("elicitation_id") == elicit_a
|
|
]
|
|
assert resolved_a == [], "prompt A (command 'ls') must stay live"
|
|
assert pending_elicitations.count_for(session_id) == 1
|
|
assert (await hook_b).status_code == 200
|
|
|
|
# Cleanup: resolve A via web verdict so its parked hook returns.
|
|
verdict = await _post_approval(client, session_id, elicit_a, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
await hook_a
|
|
capture_task.cancel()
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_pre_permission_tool_call_does_not_deny_permission_hook(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
A same-tool ``function_call`` observed before the user verdict must
|
|
not wake the parked Claude ``PermissionRequest`` hook as a deny.
|
|
|
|
Claude Code 2.1.157 writes the ``Edit`` tool-use record to its
|
|
transcript before the PermissionRequest command hook has returned.
|
|
The old auto-resolve path treated that forwarded ``function_call``
|
|
as proof that the user approved in the terminal, resolved the hook
|
|
future with ``cancel``, and the hook mapped every non-``accept``
|
|
result to ``decision.behavior == "deny"``. This reproduces that
|
|
order: hook publishes approval, the transcript forwards ``Edit``,
|
|
then the web UI accepts. The final hook response must still allow.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-permission-edit-pre-call")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
captured: list[dict[str, Any]] = []
|
|
capture_task = asyncio.create_task(_subscribe_into(session_id, captured))
|
|
await asyncio.sleep(0.05)
|
|
|
|
edit_payload = await _claude_permission_payload(tool_name="Edit")
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=edit_payload,
|
|
)
|
|
)
|
|
edit_event = await _wait_for_event(
|
|
captured,
|
|
lambda e: e.get("type") == "response.elicitation_request",
|
|
)
|
|
edit_elicit_id = edit_event["elicitation_id"]
|
|
|
|
# Newer Claude writes the tool_use before the PermissionRequest
|
|
# hook has returned. Forwarding that item must not resolve the
|
|
# parked hook as a cancel/deny.
|
|
fc = await _post_external_conversation_item(
|
|
client,
|
|
session_id,
|
|
"function_call",
|
|
{
|
|
"agent": "Claude",
|
|
"name": "Edit",
|
|
"arguments": json.dumps(
|
|
{
|
|
"file_path": "/tmp/TODO.md",
|
|
"old_string": "x",
|
|
"new_string": "x\n",
|
|
}
|
|
),
|
|
"call_id": "toolu_edit_pre_permission",
|
|
},
|
|
source_id="src_edit_fc_pre_permission",
|
|
response_id="resp_edit_pre_permission",
|
|
)
|
|
assert fc.status_code < 300, fc.text
|
|
|
|
await asyncio.sleep(0.1)
|
|
assert not hook_task.done(), (
|
|
"Forwarding a same-tool function_call before the approval verdict "
|
|
"must not finish the PermissionRequest hook."
|
|
)
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"Edit prompt should remain pending until approval, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
verdict = await _post_approval(client, session_id, edit_elicit_id, "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
decision = resp.json()["hookSpecificOutput"]["decision"]
|
|
assert decision == {"behavior": "allow"}
|
|
|
|
resolved_for_edit = await _wait_for_event(
|
|
captured,
|
|
lambda e: (
|
|
e.get("type") == "response.elicitation_resolved"
|
|
and e.get("elicitation_id") == edit_elicit_id
|
|
),
|
|
)
|
|
assert resolved_for_edit["elicitation_id"] == edit_elicit_id
|
|
assert pending_elicitations.count_for(session_id) == 0, (
|
|
f"Edit prompt should clear after approval, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
capture_task.cancel()
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_hook_returns_verdict_when_disconnect_poll_swallows_its_cancel(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A verdict releases the hook even if the disconnect watcher ignores
|
|
its cancellation.
|
|
|
|
Regression test for the gate's cleanup-cancellation race: the gate's cleanup cancels the
|
|
disconnect-watcher task and awaits it. Starlette's
|
|
``is_disconnected()`` runs inside a pre-cancelled anyio cancel
|
|
scope, and a cancel landing in that window is coalesced with the
|
|
scope's own cancellation and swallowed — the watcher survives and
|
|
the old unbounded ``await race_task`` wedged the request for the
|
|
gate's full timeout (24h on this path), hanging CI workers. The
|
|
stub watcher below swallows its cancel deterministically; the hook
|
|
must still return the verdict within the bounded reap window.
|
|
"""
|
|
swallowed_cancel = asyncio.Event()
|
|
stop_watcher = asyncio.Event()
|
|
|
|
async def _cancellation_immune_poll(_request: Any) -> None:
|
|
# Deterministic stand-in for the swallowed-cancel race: keep
|
|
# running after Task.cancel(), exactly like a poller whose
|
|
# CancelledError was absorbed by an anyio scope unwind.
|
|
while not stop_watcher.is_set():
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
except asyncio.CancelledError:
|
|
swallowed_cancel.set()
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_poll_request_disconnect",
|
|
_cancellation_immune_poll,
|
|
)
|
|
# Short reap cap so the test proves the bound, not just outlasts it.
|
|
# raising=False: on a tree without the bounded reap the constant
|
|
# doesn't exist; the test must then fail on the wedge itself (the
|
|
# 5s timeout below), not on the patch.
|
|
monkeypatch.setattr(sessions_route, "_RACE_TASK_REAP_TIMEOUT_S", 0.2, raising=False)
|
|
|
|
agent = await create_test_agent(client, "test-permission-swallowed-cancel")
|
|
session_id = await _create_session(client, agent["id"])
|
|
payload = await _claude_permission_payload()
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/permission-request",
|
|
json=payload,
|
|
)
|
|
)
|
|
try:
|
|
event = await drain_task
|
|
verdict = await _post_approval(client, session_id, event["elicitation_id"], "accept")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
# The whole point: the verdict must release the hook promptly.
|
|
# Pre-fix, the gate's cleanup awaited the cancellation-immune
|
|
# watcher forever and this timed out.
|
|
async with asyncio.timeout(5.0):
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["hookSpecificOutput"]["decision"] == {"behavior": "allow"}
|
|
# Prove the scenario actually exercised the swallow path — if the
|
|
# watcher was never cancelled (or its cancel propagated), this
|
|
# test silently degrades into a plain allow round-trip.
|
|
assert swallowed_cancel.is_set(), (
|
|
"watcher never saw (and swallowed) a cancellation — the gate "
|
|
"cleanup did not cancel the disconnect watcher"
|
|
)
|
|
finally:
|
|
stop_watcher.set()
|
|
if not hook_task.done():
|
|
hook_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await hook_task
|
|
|
|
|
|
_CODEX_REPARK_PAYLOAD: dict[str, Any] = {
|
|
"id": 12,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_rp",
|
|
"turnId": "turn_rp",
|
|
"serverName": "files",
|
|
"mode": "form",
|
|
"message": "Overwrite the file?",
|
|
"requestedSchema": {"type": "object", "properties": {"ok": {"type": "string"}}},
|
|
},
|
|
}
|
|
|
|
|
|
async def test_codex_hook_repark_after_disconnect_keeps_card_pending(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A re-POSTed codex envelope re-parks the SAME elicitation after a cut.
|
|
|
|
Codex ids are deterministic per (session, method, rpc id), so the
|
|
forwarder's retry needs no payload changes — this pins the server
|
|
half of that contract: the deferred clear no-ops on the re-park,
|
|
the card stays pending, and the verdict resolves the retried poll
|
|
in Codex's JSON-RPC result shape.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
disconnect_calls = 0
|
|
|
|
async def _disconnect_first_call_only(_request: Any) -> None:
|
|
"""
|
|
Sever the first hook long-poll; park every later one.
|
|
|
|
:param _request: Ignored FastAPI request.
|
|
:returns: None when simulating the first call's disconnect.
|
|
"""
|
|
nonlocal disconnect_calls
|
|
disconnect_calls += 1
|
|
if disconnect_calls == 1:
|
|
await asyncio.sleep(0.01)
|
|
return
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_poll_request_disconnect",
|
|
_disconnect_first_call_only,
|
|
)
|
|
# Wide enough that the re-park always lands before the grace expires.
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
1.0,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-codex-repark")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
first = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=_CODEX_REPARK_PAYLOAD,
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
assert first.content == b"", f"severed poll should fail-ask, got {first.content!r}"
|
|
deferred_tasks = set(sessions_route._deferred_elicitation_clear_tasks)
|
|
assert len(deferred_tasks) == 1, (
|
|
f"expected one deferred clear after the severed poll, got "
|
|
f"{len(deferred_tasks)} — disconnect regressed to immediate wipe, or leaked tasks."
|
|
)
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=_CODEX_REPARK_PAYLOAD,
|
|
)
|
|
)
|
|
event = await drain_task
|
|
# Same envelope → same deterministic id → the SAME elicitation
|
|
# re-parks instead of minting a second card.
|
|
assert event["params"]["codex_request_id"] == 12
|
|
|
|
# Run the deferred clear to completion: it must see the re-park and no-op.
|
|
for task in deferred_tasks:
|
|
await asyncio.wait_for(task, timeout=5.0)
|
|
# 1 = the card survived the severed long-poll. 0 = the deferred
|
|
# clear ignored the re-park and wiped the live prompt.
|
|
assert pending_elicitations.count_for(session_id) == 1, (
|
|
f"re-parked codex elicitation should stay pending, got "
|
|
f"{pending_elicitations.count_for(session_id)!r}"
|
|
)
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"ok": "yes"},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
# The retried poll carries the verdict in Codex's result shape —
|
|
# exactly what the forwarder relays back over JSON-RPC.
|
|
assert resp.json() == {"action": "accept", "content": {"ok": "yes"}, "_meta": None}
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_codex_hook_gap_verdict_returned_on_repost(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A verdict landing between a severed codex poll and its retry is
|
|
handed to the retry via the pre-resolved tombstone, mapped into
|
|
Codex's JSON-RPC result shape.
|
|
|
|
Without it the retry would re-publish the prompt and fail-ask
|
|
later — the click dropped, the codex sub-agent still blocked.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations
|
|
|
|
async def _disconnect_immediately(_request: Any) -> None:
|
|
"""
|
|
Sever every hook long-poll straight away.
|
|
|
|
:param _request: Ignored FastAPI request.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.sleep(0.01)
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_poll_request_disconnect",
|
|
_disconnect_immediately,
|
|
)
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
0.25,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-codex-gap-verdict")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
first = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=_CODEX_REPARK_PAYLOAD,
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
assert first.content == b""
|
|
event = await drain_task
|
|
|
|
# Verdict arrives while NO poll is parked (the gap).
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
event["elicitation_id"],
|
|
"accept",
|
|
content={"ok": "go"},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
assert pending_elicitations.count_for(session_id) == 0
|
|
|
|
# The retry consumes the tombstone and returns the verdict in the
|
|
# codex result shape without re-publishing the prompt; an empty
|
|
# body here means the tombstone was dropped and the click lost.
|
|
second = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/codex-elicitation-request",
|
|
json=_CODEX_REPARK_PAYLOAD,
|
|
)
|
|
assert second.status_code == 200, second.text
|
|
assert second.json() == {"action": "accept", "content": {"ok": "go"}, "_meta": None}
|
|
# Drain the severed poll's deferred clear so it doesn't outlive the
|
|
# test's event loop (it no-ops the index either way).
|
|
for task in set(sessions_route._deferred_elicitation_clear_tasks):
|
|
await asyncio.wait_for(task, timeout=5.0)
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
# ── Antigravity elicitation hook tests ──────────────────────────────────────
|
|
|
|
|
|
async def test_antigravity_elicitation_hook_accept_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Antigravity ``antigravity-elicitation-request`` hook: accept path.
|
|
|
|
The bridge POSTs ``{"elicitation_id": ..., "params": {...}}``; the
|
|
hook parks on the shared elicitation registry, the web ``approval``
|
|
event resolves it, and the hook returns the raw
|
|
``ElicitationResult`` (action + content + _meta) so the bridge can
|
|
deliver the answer to agy. This is simpler than the codex hook:
|
|
the endpoint does NOT build a JSON-RPC envelope — the bridge does
|
|
that with ``to_interaction_payload``.
|
|
|
|
:param client: Test HTTP client.
|
|
:returns: None.
|
|
"""
|
|
agent = await create_test_agent(client, "test-agy-elicit-accept")
|
|
session_id = await _create_session(client, agent["id"])
|
|
elicitation_id = "elicit_agy_00000000000000000000000000000001"
|
|
params_body = {
|
|
"mode": "form",
|
|
"message": "Antigravity needs your input",
|
|
"phase": "agy_ask_question",
|
|
"policy_name": "agy_native_ask_question",
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/antigravity-elicitation-request",
|
|
json={"elicitation_id": elicitation_id, "params": params_body},
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
assert event["elicitation_id"] == elicitation_id
|
|
assert event["params"]["message"] == "Antigravity needs your input"
|
|
assert event["params"]["phase"] == "agy_ask_question"
|
|
|
|
verdict = await _post_approval(
|
|
client,
|
|
session_id,
|
|
elicitation_id,
|
|
"accept",
|
|
content={"selectedOptionIds": ["1"]},
|
|
)
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {
|
|
"action": "accept",
|
|
"content": {"selectedOptionIds": ["1"]},
|
|
}
|
|
|
|
|
|
async def test_antigravity_elicitation_hook_decline_round_trip(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Antigravity elicitation hook: decline path returns the verdict.
|
|
|
|
:param client: Test HTTP client.
|
|
:returns: None.
|
|
"""
|
|
agent = await create_test_agent(client, "test-agy-elicit-decline")
|
|
session_id = await _create_session(client, agent["id"])
|
|
elicitation_id = "elicit_agy_00000000000000000000000000000002"
|
|
params_body = {
|
|
"mode": "form",
|
|
"message": "Antigravity wants to run a command",
|
|
"phase": "agy_permission",
|
|
"policy_name": "agy_native_permission",
|
|
}
|
|
|
|
drain_task = asyncio.create_task(_drain_until_elicitation(session_id))
|
|
await asyncio.sleep(0.05)
|
|
hook_task = asyncio.create_task(
|
|
client.post(
|
|
f"/v1/sessions/{session_id}/hooks/antigravity-elicitation-request",
|
|
json={"elicitation_id": elicitation_id, "params": params_body},
|
|
)
|
|
)
|
|
|
|
event = await drain_task
|
|
assert event["elicitation_id"] == elicitation_id
|
|
|
|
verdict = await _post_approval(client, session_id, elicitation_id, "decline")
|
|
assert verdict.status_code == 202, verdict.text
|
|
|
|
resp = await hook_task
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {"action": "decline", "content": None}
|
|
|
|
|
|
async def test_antigravity_elicitation_hook_timeout_returns_empty_200(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Antigravity elicitation hook: timeout path returns empty 200.
|
|
|
|
When the user does not resolve the elicitation within the hook
|
|
timeout, the endpoint returns ``200`` with an empty body so the
|
|
bridge's ``request_elicitation`` callable gets ``None`` and can
|
|
leave agy's WAITING step to time out on its own.
|
|
|
|
:param client: Test HTTP client.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.runtime import pending_elicitations, session_stream
|
|
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S",
|
|
0.1,
|
|
)
|
|
monkeypatch.setattr(
|
|
sessions_route,
|
|
"_HARNESS_ELICITATION_REPARK_GRACE_S",
|
|
0.05,
|
|
)
|
|
pending_elicitations.reset_for_tests()
|
|
agent = await create_test_agent(client, "test-agy-elicit-timeout")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
async def _drain_until_resolved() -> None:
|
|
"""
|
|
Wait for the deferred ``response.elicitation_resolved`` publish.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with asyncio.timeout(3.0):
|
|
async for event in session_stream.subscribe(session_id):
|
|
if event.get("type") == "response.elicitation_resolved":
|
|
return
|
|
|
|
drain_task = asyncio.create_task(_drain_until_resolved())
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/antigravity-elicitation-request",
|
|
json={
|
|
"elicitation_id": "elicit_agy_00000000000000000000000000000099",
|
|
"params": {
|
|
"mode": "form",
|
|
"message": "Antigravity needs your input",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.content == b""
|
|
await drain_task
|
|
assert pending_elicitations.count_for(session_id) == 0
|
|
pending_elicitations.reset_for_tests()
|
|
|
|
|
|
async def test_antigravity_elicitation_hook_rejects_invalid_json(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Antigravity elicitation hook: malformed JSON body returns 400.
|
|
|
|
:param client: Test HTTP client.
|
|
:returns: None.
|
|
"""
|
|
agent = await create_test_agent(client, "test-agy-elicit-badjson")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/antigravity-elicitation-request",
|
|
content=b"not-json",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
async def test_antigravity_elicitation_hook_rejects_non_dict_body(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Antigravity elicitation hook: non-object JSON body returns 400.
|
|
|
|
:param client: Test HTTP client.
|
|
:returns: None.
|
|
"""
|
|
agent = await create_test_agent(client, "test-agy-elicit-nondict")
|
|
session_id = await _create_session(client, agent["id"])
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/hooks/antigravity-elicitation-request",
|
|
json=["not", "a", "dict"],
|
|
)
|
|
assert resp.status_code == 400, resp.text
|