Skip to content

Commit b5ceb20

Browse files
grace227mdw771
authored andcommitted
FEAT: auto-reject stale WebUI tool approvals
1 parent 4a00651 commit b5ceb20

8 files changed

Lines changed: 195 additions & 29 deletions

File tree

packages/eaa-core/src/eaa_core/gui/runtime.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import queue
1010
import threading
1111
from dataclasses import dataclass, field
12-
from datetime import datetime
12+
from datetime import datetime, timedelta, timezone
1313
from email.utils import formatdate
1414
from typing import Any
1515

@@ -88,9 +88,11 @@ def __init__(
8888
task_manager: Any,
8989
*,
9090
upload_dir: str = ".tmp",
91+
approval_timeout_seconds: float = 120.0,
9192
) -> None:
9293
self.task_manager = task_manager
9394
self.upload_dir = upload_dir
95+
self.approval_timeout_seconds = approval_timeout_seconds
9496
self.input_queue: queue.Queue[str] = queue.Queue()
9597
self.approval_queue: queue.Queue[bool] = queue.Queue()
9698
self.approval_queues: dict[str, queue.Queue[bool]] = {
@@ -104,6 +106,7 @@ def __init__(
104106
self.max_log_entries = 500
105107
self.message_event_counter = 0
106108
self.log_event_counter = 0
109+
self.approval_event_counter = 0
107110
self.subagent_counter = 0
108111
self.status = "idle"
109112
self.input_requested = False
@@ -402,18 +405,29 @@ def request_approval(
402405
),
403406
)
404407
previous_status = conversation.status
408+
self.approval_event_counter += 1
409+
approval_id = f"approval-{self.approval_event_counter}"
410+
requested_at = datetime.now(timezone.utc)
411+
expires_at = requested_at + timedelta(seconds=self.approval_timeout_seconds)
405412
pending_approval = {
413+
"id": approval_id,
406414
"conversation_id": conversation_id,
407415
"tool_name": tool_name,
408416
"arguments": tool_kwargs,
417+
"requested_at": requested_at.isoformat().replace("+00:00", "Z"),
418+
"expires_at": expires_at.isoformat().replace("+00:00", "Z"),
419+
"timeout_seconds": self.approval_timeout_seconds,
409420
}
410421
conversation.pending_approval = dict(pending_approval)
411422
if conversation_id == "primary":
412423
self.pending_approval = dict(pending_approval)
413424
approval_queue = self.approval_queues.setdefault(conversation_id, queue.Queue())
414425
self.publish("approval.requested", pending_approval)
415426
self.set_status("waiting_for_approval", input_requested=True, conversation_id=conversation_id)
416-
approved = approval_queue.get()
427+
try:
428+
approved = approval_queue.get(timeout=self.approval_timeout_seconds)
429+
except queue.Empty:
430+
approved = False
417431
with self.lock:
418432
conversation = self.conversations[conversation_id]
419433
conversation.pending_approval = None
@@ -422,11 +436,27 @@ def request_approval(
422436
self.set_status(previous_status, input_requested=False, conversation_id=conversation_id)
423437
return approved
424438

425-
def submit_approval(self, approved: bool, conversation_id: str = "primary") -> None:
439+
def submit_approval(
440+
self,
441+
approved: bool,
442+
conversation_id: str = "primary",
443+
approval_id: str | None = None,
444+
*,
445+
require_pending: bool = False,
446+
) -> bool:
426447
"""Queue a tool approval decision."""
427448
with self.lock:
449+
conversation = self.conversations.get(conversation_id)
450+
pending_approval = conversation.pending_approval if conversation else None
451+
if require_pending and pending_approval is None:
452+
return False
453+
if approval_id is not None and (
454+
pending_approval is None or pending_approval.get("id") != approval_id
455+
):
456+
return False
428457
approval_queue = self.approval_queues.setdefault(conversation_id, queue.Queue())
429458
approval_queue.put(approved)
459+
return True
430460

431461
def has_pending_approval(self) -> bool:
432462
"""Return whether a tool approval request is waiting for a response."""
@@ -595,10 +625,21 @@ async def interrupt() -> dict[str, bool]:
595625
return {"ok": True}
596626

597627
@app.post("/api/approval")
598-
async def approval(payload: dict[str, Any]) -> dict[str, bool]:
628+
async def approval(payload: dict[str, Any]) -> JSONResponse:
599629
conversation_id = str(payload.get("conversation_id") or "primary")
600-
self.controller.submit_approval(bool(payload.get("approved")), conversation_id)
601-
return {"ok": True}
630+
approval_id = payload.get("approval_id")
631+
accepted = self.controller.submit_approval(
632+
bool(payload.get("approved")),
633+
conversation_id,
634+
str(approval_id) if approval_id is not None else None,
635+
require_pending=True,
636+
)
637+
if not accepted:
638+
return JSONResponse(
639+
{"ok": False, "error": "No matching approval request is pending"},
640+
status_code=409,
641+
)
642+
return JSONResponse({"ok": True})
602643

603644
@app.get("/api/skill-catalog")
604645
async def skill_catalog() -> dict[str, list[dict[str, str]]]:

packages/eaa-core/src/eaa_core/gui/static/webui/assets/index-BGbkLgDw.js

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/eaa-core/src/eaa_core/gui/static/webui/assets/index-D-NyMFIS.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/eaa-core/src/eaa_core/gui/static/webui/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>EAA WebUI</title>
7-
<script type="module" crossorigin src="/static/webui/assets/index-BGbkLgDw.js"></script>
8-
<link rel="stylesheet" crossorigin href="/static/webui/assets/index-D-NyMFIS.css">
7+
<script type="module" crossorigin src="/static/webui/assets/index-BGbkLgDw.js?v=approval-timeout-120s"></script>
8+
<link rel="stylesheet" crossorigin href="/static/webui/assets/index-D-NyMFIS.css?v=approval-timeout-120s">
99
</head>
1010
<body>
1111
<div id="root"></div>

packages/eaa-core/webui/src/App.tsx

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ const currentImageTimeTitle = () => new Date().toLocaleTimeString([], { hour: "2
165165
const isApprovalMessage = (message: WebUIMessage) =>
166166
String(message.role ?? "") === "system" && /Approve\?\s*\[y\/N\]:/i.test(String(message.content ?? ""));
167167

168+
const formatRemainingTime = (milliseconds: number) => {
169+
const seconds = Math.max(0, Math.ceil(milliseconds / 1000));
170+
const minutes = Math.floor(seconds / 60);
171+
const remainder = seconds % 60;
172+
return `${minutes}:${String(remainder).padStart(2, "0")}`;
173+
};
174+
168175
const formatApproval = (content: unknown) => {
169176
const text = String(content ?? "");
170177
const argsMatch = text.match(/Arguments:\s*([\s\S]*?)\nApprove\?\s*\[y\/N\]:/i);
@@ -270,11 +277,19 @@ function MessageView({
270277
index: number;
271278
message: WebUIMessage;
272279
onImage: (src: string) => void;
273-
onApproval: (approved: boolean) => Promise<void>;
280+
onApproval: (approved: boolean, approvalId?: string) => Promise<void>;
274281
}) {
275282
const [approvalSubmitted, setApprovalSubmitted] = useState(false);
283+
const [approvalRemainingMs, setApprovalRemainingMs] = useState<number | null>(null);
276284
const role = String(message.role ?? "message");
277285
const content = String(message.content ?? "").trim();
286+
const approvalExpiresAt = useMemo(() => {
287+
if (message.approval_expires_at) return Date.parse(message.approval_expires_at);
288+
if (!message.approval_timeout_seconds) return NaN;
289+
const requestedAt = message.approval_requested_at ? Date.parse(message.approval_requested_at) : Date.now();
290+
return requestedAt + message.approval_timeout_seconds * 1000;
291+
}, [message.approval_expires_at, message.approval_requested_at, message.approval_timeout_seconds]);
292+
const approvalExpired = approvalRemainingMs !== null && approvalRemainingMs <= 0;
278293
const imageSources = useMemo(() => {
279294
const sources: string[] = [];
280295
const seen = new Set<string>();
@@ -298,9 +313,21 @@ function MessageView({
298313
return sources;
299314
}, [message, role]);
300315

316+
useEffect(() => {
317+
if (!isApprovalMessage(message) || Number.isNaN(approvalExpiresAt)) {
318+
setApprovalRemainingMs(null);
319+
return;
320+
}
321+
const updateRemaining = () => setApprovalRemainingMs(Math.max(0, approvalExpiresAt - Date.now()));
322+
updateRemaining();
323+
const interval = window.setInterval(updateRemaining, 1000);
324+
return () => window.clearInterval(interval);
325+
}, [approvalExpiresAt, message]);
326+
301327
const submitApproval = async (approved: boolean) => {
328+
if (approvalSubmitted || approvalExpired) return;
302329
setApprovalSubmitted(true);
303-
await onApproval(approved);
330+
await onApproval(approved, message.approval_id);
304331
};
305332

306333
return (
@@ -335,14 +362,31 @@ function MessageView({
335362
</div>
336363
) : null}
337364
{isApprovalMessage(message) ? (
338-
<div className="eaa-approval-actions">
339-
<button className="eaa-approval-button eaa-approval-yes" disabled={approvalSubmitted} type="button" onClick={() => submitApproval(true)}>
340-
Yes
341-
</button>
342-
<button className="eaa-approval-button eaa-approval-no" disabled={approvalSubmitted} type="button" onClick={() => submitApproval(false)}>
343-
No
344-
</button>
345-
</div>
365+
<>
366+
{approvalRemainingMs !== null ? (
367+
<div className={`eaa-approval-timer${approvalExpired ? " eaa-approval-timer-expired" : ""}`}>
368+
{approvalExpired ? "Approval timed out and was rejected." : `Auto-rejects in ${formatRemainingTime(approvalRemainingMs)}`}
369+
</div>
370+
) : null}
371+
<div className="eaa-approval-actions">
372+
<button
373+
className="eaa-approval-button eaa-approval-yes"
374+
disabled={approvalSubmitted || approvalExpired}
375+
type="button"
376+
onClick={() => submitApproval(true)}
377+
>
378+
Yes
379+
</button>
380+
<button
381+
className="eaa-approval-button eaa-approval-no"
382+
disabled={approvalSubmitted || approvalExpired}
383+
type="button"
384+
onClick={() => submitApproval(false)}
385+
>
386+
No
387+
</button>
388+
</div>
389+
</>
346390
) : null}
347391
</div>
348392
</article>
@@ -696,7 +740,17 @@ function App() {
696740
2,
697741
)}\nApprove? [y/N]: `;
698742
mergeMessages(
699-
[{ id: `approval-${conversationId}-${payload.tool_name || "tool"}-${stringHash(approvalContent)}`, role: "system", content: approvalContent }],
743+
[
744+
{
745+
id: payload.id || `approval-${conversationId}-${payload.tool_name || "tool"}-${stringHash(approvalContent)}`,
746+
role: "system",
747+
content: approvalContent,
748+
approval_id: payload.id,
749+
approval_requested_at: payload.requested_at,
750+
approval_expires_at: payload.expires_at,
751+
approval_timeout_seconds: payload.timeout_seconds,
752+
},
753+
],
700754
conversationId,
701755
);
702756
},
@@ -774,11 +828,11 @@ function App() {
774828
return () => source.close();
775829
}, [applyStatus, mergeMessages, renderApprovalRequest, upsertConversation]);
776830

777-
const submitApproval = async (approved: boolean, conversationId = activeConversationId) => {
831+
const submitApproval = async (approved: boolean, conversationId = activeConversationId, approvalId?: string) => {
778832
await fetch(config.routes.approval, {
779833
method: "POST",
780834
headers: { "Content-Type": "application/json" },
781-
body: JSON.stringify({ conversation_id: conversationId, approved }),
835+
body: JSON.stringify({ conversation_id: conversationId, approved, approval_id: approvalId }),
782836
});
783837
};
784838

@@ -1080,7 +1134,7 @@ function App() {
10801134
key={messageKey(message, index)}
10811135
message={message}
10821136
onImage={setPreviewImage}
1083-
onApproval={(approved) => submitApproval(approved, activeConversationId)}
1137+
onApproval={(approved, approvalId) => submitApproval(approved, activeConversationId, approvalId)}
10841138
/>
10851139
))}
10861140
{activeConversation?.terminated ? <div className="eaa-termination-marker">Subagent terminated</div> : null}

packages/eaa-core/webui/src/styles.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,17 @@ button {
509509
margin-top: 4px;
510510
}
511511

512+
.eaa-approval-timer {
513+
color: #475467;
514+
font-size: 12px;
515+
font-weight: 650;
516+
margin-top: 8px;
517+
}
518+
519+
.eaa-approval-timer-expired {
520+
color: #b42318;
521+
}
522+
512523
.eaa-approval-button {
513524
background: #ffffff;
514525
border: 1px solid #cbd5e1;
@@ -518,6 +529,11 @@ button {
518529
padding: 7px 12px;
519530
}
520531

532+
.eaa-approval-button:disabled {
533+
cursor: not-allowed;
534+
opacity: 0.55;
535+
}
536+
521537
.eaa-message-images {
522538
display: flex;
523539
flex-wrap: wrap;

packages/eaa-core/webui/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export type WebUIMessage = {
2626
images?: string[];
2727
tool_calls?: unknown;
2828
pending?: boolean;
29+
approval_id?: string;
30+
approval_requested_at?: string;
31+
approval_expires_at?: string;
32+
approval_timeout_seconds?: number;
2933
};
3034

3135
export type RuntimeLogEntry = {
@@ -50,9 +54,13 @@ export type RuntimeSnapshot = {
5054
};
5155

5256
export type PendingApproval = {
57+
id?: string;
5358
conversation_id?: string;
5459
tool_name?: string;
5560
arguments?: Record<string, unknown>;
61+
requested_at?: string;
62+
expires_at?: string;
63+
timeout_seconds?: number;
5664
};
5765

5866
export type RuntimeConversation = {

tests/test_webui_chat.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,15 @@ def test_runtime_fastapi_routes_handle_core_commands(tmp_path):
349349
assert interrupt_response.status_code == 200
350350
assert controller.interrupt_event.is_set()
351351

352-
approval_response = client.post("/api/approval", json={"approved": True})
353-
assert approval_response.status_code == 200
354-
assert controller.approval_queue.get_nowait() is True
352+
with ThreadPoolExecutor(max_workers=1) as executor:
353+
future = executor.submit(controller.request_approval, "tool", {}, "primary")
354+
for _ in range(50):
355+
if controller.has_pending_approval_for_conversation("primary"):
356+
break
357+
time.sleep(0.01)
358+
approval_response = client.post("/api/approval", json={"approved": True})
359+
assert approval_response.status_code == 200
360+
assert future.result(timeout=1) is True
355361

356362
catalog_response = client.get("/api/skill-catalog")
357363
assert catalog_response.status_code == 200
@@ -495,6 +501,49 @@ def test_runtime_input_and_approval_restore_previous_status():
495501
assert controller.snapshot()["status"] == "running"
496502

497503

504+
def test_runtime_approval_times_out_and_rejects():
505+
task_manager = BaseTaskManager(build=False)
506+
controller = WebUIRuntimeController(task_manager, approval_timeout_seconds=0.05)
507+
508+
controller.set_status("running", input_requested=False)
509+
started_at = time.monotonic()
510+
assert controller.request_approval("tool", {}) is False
511+
elapsed = time.monotonic() - started_at
512+
513+
assert elapsed >= 0.05
514+
assert controller.snapshot()["status"] == "running"
515+
assert controller.snapshot()["pending_approval"] is None
516+
517+
518+
def test_runtime_approval_route_rejects_stale_approval_id(tmp_path):
519+
task_manager = BaseTaskManager(
520+
build=False,
521+
transcript_db_path=str(tmp_path / "transcript.sqlite"),
522+
)
523+
controller = WebUIRuntimeController(task_manager, approval_timeout_seconds=0.05)
524+
server = WebUIRuntimeServer(controller)
525+
client = TestClient(server.build_app())
526+
527+
with ThreadPoolExecutor(max_workers=1) as executor:
528+
future = executor.submit(controller.request_approval, "tool", {}, "primary")
529+
for _ in range(50):
530+
snapshot = controller.snapshot()
531+
pending = snapshot["pending_approval"]
532+
if pending:
533+
break
534+
time.sleep(0.01)
535+
else:
536+
raise AssertionError("approval did not become pending")
537+
assert future.result(timeout=1) is False
538+
539+
response = client.post(
540+
"/api/approval",
541+
json={"approved": True, "approval_id": pending["id"]},
542+
)
543+
544+
assert response.status_code == 409
545+
546+
498547
def test_html_webui_base_uses_runtime_url():
499548
webui = HTMLWebUIBase("http://127.0.0.1:9999", title="Custom UI")
500549

0 commit comments

Comments
 (0)