Files
omnigent-ai--omnigent/tests/server/integration/test_sessions_permission_request_hook.py
2026-07-13 13:12:00 +08:00

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