mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 10:12:20 +02:00
fix(agent): allow remaining actions for an approved task (#6113)
* fix(agent): allow remaining actions for an approved task * fix(agent): make approval continuation control-only * fix(ci): preserve approval taint and cache-buster contract * fix(ui): keep tool approvals in current chat * fix(ui): route tool approvals through chat submit * test(ui): pin approval submit routing * fix(agent): complete approval denial flow * fix(ui): avoid duplicate ask-user close icon * fix(agent): retain approved tool in continuation set * revert(ui): keep PR 6113 scoped to approval continuation * fix(agent): add task and chat approval scopes * fix(ui): prevent duplicate ask-user close icon * feat(ui): add ask-user option shortcuts * fix(compare): route ask-user choices per pane * fix(agent): keep skill-test approvals to a single action The chat card now reuses the wire value `approve` to mean chat-session scope, and `consume()` returned `allow_remaining_actions=True` for it unconditionally. The skill-test approval route was never updated: it still sends `approve` meaning "once", and its button still reads "Allow once", but the grant it got back set `approval_gate_bypassed` for the rest of the resumed run. That surface wraps the skill body and every transcript byte as untrusted context, so it is the last place where one click should ungate everything that follows. Give `consume()` an explicit `allow_continuation` flag. Callers that own a resumable chat keep the scope the user picked; callers that do not — the skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms behind the sealed action, which is what their label promises. * fix(ui): cache-bust every module the approval click depends on chatStream.js, compare/index.js and compare/stream.js all changed behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were bumped. A returning browser therefore serves the new chat.js — which now deliberately leaves the composer empty and clicks the send button — next to the cached chatStream.js that has no interceptor. With an empty composer that button sits at `data-mode="newchat"`, so the click opens a new chat and the approval is dropped. Bump the three, and version compare/stream.js's chatRenderer import to match everyone else's so the ask_user keydown listener binds to one module instance instead of two. * fix(ui): keep the digit shortcuts off tool approval cards With an approval card on screen and focus anywhere outside an input, a bare `1` fired `approve_task` — the widest of the three grants — with no modifier and no confirmation. That card is the one control whose entire purpose is deliberate consent after untrusted context influenced the run, and Deny sits at 3. Label the card with its kind and skip the shortcut for approvals. Ordinary ask_user questions keep 1-3. * fix(compare): restore a pane's ask_user card instead of dropping the choice renderAskUserCard removes the card as soon as onSubmit accepts, but the resume loop gave up silently after 10s if the originating stream still owned the pane. The user saw the click land, the card vanish, and nothing happen, with no way to get it back. Re-render the card on that deadline and say why. The reroll case still returns without sending — that choice belongs to a stream that no longer exists. * refactor(chat): drop the unreachable deny branch `if decision != "deny"` is always true — the deny path returns a StreamingResponse a few lines above. It reads as if deny still falls through to the toggle restore. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
+51
-1
@@ -8,6 +8,11 @@ These are simple datacontainers. All persistence is handled by SessionManager.
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .session_manager import SessionManager
|
||||
|
||||
@@ -31,6 +36,35 @@ set_session_manager = set_session_manager_instance
|
||||
get_session_manager = get_session_manager_instance
|
||||
|
||||
|
||||
def _history_grants_chat_session_approval(
|
||||
history: List["ChatMessage"],
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Return whether this exact chat has a resolved session-scope grant."""
|
||||
|
||||
expected_session = str(session_id or "")
|
||||
if not expected_session:
|
||||
return False
|
||||
for message in reversed(history or []):
|
||||
metadata = getattr(message, "metadata", None)
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
tool_events = metadata.get("tool_events")
|
||||
if not isinstance(tool_events, list):
|
||||
continue
|
||||
for event in reversed(tool_events):
|
||||
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||
if not isinstance(ask_user, dict):
|
||||
continue
|
||||
if (
|
||||
ask_user.get("kind") == "tool_approval"
|
||||
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
||||
and str(ask_user.get("session_id") or "") == expected_session
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
"""A single chat message."""
|
||||
@@ -116,11 +150,27 @@ class Session:
|
||||
the model. Display/history-load paths use the raw ``history`` and are
|
||||
unaffected.
|
||||
"""
|
||||
return [
|
||||
messages = [
|
||||
msg.to_dict()
|
||||
for msg in self.history
|
||||
if (msg.metadata or {}).get("source") != "slash"
|
||||
]
|
||||
if not _history_grants_chat_session_approval(self.history, self.id):
|
||||
return messages
|
||||
|
||||
# Keep the grant close to the latest user request so route-neutral
|
||||
# compaction/trimming preserves it. Copy the metadata instead of
|
||||
# mutating the durable transcript object.
|
||||
for index in range(len(messages) - 1, -1, -1):
|
||||
if messages[index].get("role") != "user":
|
||||
continue
|
||||
message = dict(messages[index])
|
||||
metadata = dict(message.get("metadata") or {})
|
||||
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
|
||||
message["metadata"] = metadata
|
||||
messages[index] = message
|
||||
break
|
||||
return messages
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""Dict-like access for compatibility."""
|
||||
|
||||
+20
-5
@@ -624,6 +624,8 @@ async def build_chat_context(
|
||||
agent_mode: bool = False,
|
||||
allow_tool_preprocessing: bool = True,
|
||||
defer_context_shaping: bool = False,
|
||||
continuation_context_message: str | None = None,
|
||||
persist_user_message: bool = True,
|
||||
) -> ChatContext:
|
||||
"""Build the full context (preface + messages) for an LLM call.
|
||||
|
||||
@@ -647,14 +649,14 @@ async def build_chat_context(
|
||||
# Add user message to history. Nobody/incognito uses a request-local
|
||||
# transcript store instead of session history so stale saved chats cannot
|
||||
# bleed into context and the turn is not persisted.
|
||||
if incognito:
|
||||
if persist_user_message and incognito:
|
||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||
else:
|
||||
elif persist_user_message:
|
||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||
|
||||
# Fire events
|
||||
if not incognito:
|
||||
if persist_user_message and not incognito:
|
||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||
|
||||
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
||||
@@ -666,7 +668,12 @@ async def build_chat_context(
|
||||
getattr(chat_handler, "upload_handler", None),
|
||||
getattr(sess, "owner", None),
|
||||
)
|
||||
casual_low_signal = _is_casual_low_signal(message)
|
||||
context_message = (
|
||||
str(continuation_context_message).strip()
|
||||
if continuation_context_message
|
||||
else message
|
||||
)
|
||||
casual_low_signal = _is_casual_low_signal(context_message)
|
||||
|
||||
# Memory enabled?
|
||||
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
||||
@@ -703,7 +710,15 @@ async def build_chat_context(
|
||||
# Build context preface
|
||||
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
||||
# the sync path uses text_for_context.
|
||||
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
|
||||
_ctx_msg = (
|
||||
context_message
|
||||
if continuation_context_message
|
||||
else (
|
||||
preprocessed.enhanced_message
|
||||
if use_enhanced_message
|
||||
else preprocessed.text_for_context
|
||||
)
|
||||
)
|
||||
_preface_kwargs = dict(
|
||||
message=_ctx_msg,
|
||||
session=sess,
|
||||
|
||||
+140
-41
@@ -89,6 +89,65 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
"""Persist a consumed approval decision on its existing tool event."""
|
||||
|
||||
approval_key = str(approval_id or "")
|
||||
normalized_decision = str(decision or "").strip().lower()
|
||||
if not approval_key or normalized_decision not in {"approve", "approve_task", "deny"}:
|
||||
return False
|
||||
|
||||
message_id = None
|
||||
resolved_metadata = None
|
||||
for item in reversed(getattr(sess, "history", []) or []):
|
||||
metadata = getattr(item, "metadata", None)
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
tool_events = metadata.get("tool_events")
|
||||
if not isinstance(tool_events, list):
|
||||
continue
|
||||
for event in reversed(tool_events):
|
||||
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||
if not isinstance(ask_user, dict):
|
||||
continue
|
||||
if str(ask_user.get("approval_id") or "") != approval_key:
|
||||
continue
|
||||
ask_user["resolved"] = normalized_decision
|
||||
message_id = metadata.get("_db_id")
|
||||
resolved_metadata = {
|
||||
key: value for key, value in metadata.items() if key != "_db_id"
|
||||
}
|
||||
break
|
||||
if resolved_metadata is not None:
|
||||
break
|
||||
|
||||
if resolved_metadata is None or not message_id:
|
||||
return False
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_message = db.query(DBChatMessage).filter(
|
||||
DBChatMessage.id == message_id,
|
||||
DBChatMessage.session_id == str(getattr(sess, "id", "")),
|
||||
).first()
|
||||
if db_message is None:
|
||||
return False
|
||||
db_message.meta_data = json.dumps(resolved_metadata)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to persist tool approval resolution")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _tool_approval_resolution_stream(decision: str) -> AsyncGenerator[str, None]:
|
||||
yield f"data: {json.dumps({'type': 'tool_approval_resolved', 'decision': decision})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def _chat_candidate_request_factory(
|
||||
messages,
|
||||
fallback_context_length: int = 0,
|
||||
@@ -917,6 +976,7 @@ def setup_chat_routes(
|
||||
exact_tool_approval = None
|
||||
pending_tool_approval = None
|
||||
retired_tool_approval_taint = False
|
||||
external_untrusted_context_seen = False
|
||||
tool_approval_continuation = False
|
||||
# Workspace: confine the agent's file/shell tools to this folder.
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
@@ -1050,14 +1110,14 @@ def setup_chat_routes(
|
||||
)
|
||||
|
||||
try:
|
||||
# Attachment-only sends: skip the message-required check when the
|
||||
# user has attached one or more files (the attachment IS the action).
|
||||
# Attachment-only sends and approval controls may omit message text.
|
||||
_has_atts = (
|
||||
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
|
||||
or bool(form_data.get("attachments"))
|
||||
)
|
||||
message, session = coerce_message_and_session(
|
||||
body, message, session, session_manager, allow_empty=_has_atts,
|
||||
body, message, session, session_manager,
|
||||
allow_empty=(_has_atts or bool(tool_approval_id)),
|
||||
)
|
||||
# Verify ownership AFTER coerce (which may resolve a default session)
|
||||
# but BEFORE loading. Prevents cross-user session hijack.
|
||||
@@ -1076,8 +1136,14 @@ def setup_chat_routes(
|
||||
409,
|
||||
"This tool approval is invalid, expired, or belongs to another thread.",
|
||||
)
|
||||
pending_taint = bool(
|
||||
pending_tool_approval.external_untrusted_context_seen
|
||||
)
|
||||
external_untrusted_context_seen = (
|
||||
external_untrusted_context_seen or pending_taint
|
||||
)
|
||||
decision = str(tool_approval_decision or "").strip().lower()
|
||||
if decision not in {"approve", "deny"}:
|
||||
if decision not in {"approve", "approve_task", "deny"}:
|
||||
raise HTTPException(400, "Invalid tool approval decision.")
|
||||
if plan_mode:
|
||||
raise HTTPException(
|
||||
@@ -1091,37 +1157,46 @@ def setup_chat_routes(
|
||||
session_id=session,
|
||||
)
|
||||
tool_approval_continuation = True
|
||||
if decision == "approve" and exact_tool_approval is None:
|
||||
if (
|
||||
decision in {"approve", "approve_task"}
|
||||
and exact_tool_approval is None
|
||||
):
|
||||
raise HTTPException(
|
||||
409,
|
||||
"This tool approval could not be consumed.",
|
||||
)
|
||||
if decision == "approve":
|
||||
message = (
|
||||
f"Approved the exact {pending_tool_approval.tool_name} action "
|
||||
"shown above once."
|
||||
if not _mark_tool_approval_resolved(
|
||||
sess,
|
||||
tool_approval_id,
|
||||
decision,
|
||||
):
|
||||
logger.warning(
|
||||
"Tool approval %s was consumed but its persisted card could not be marked resolved",
|
||||
tool_approval_id,
|
||||
)
|
||||
# The sealed server record, not mutable composer state,
|
||||
# restores the original action workspace.
|
||||
workspace = pending_tool_approval.workspace or None
|
||||
workspace_rejected = None
|
||||
if pending_tool_approval.document_id:
|
||||
active_doc_id = pending_tool_approval.document_id
|
||||
# The approval click is the per-turn opt-in for this exact
|
||||
# sealed action. Restore only the coarse request toggle
|
||||
# that would otherwise disable it because the synthetic
|
||||
# "Approved…" message no longer resembles the original
|
||||
# shell/web request. Current privilege, global-disable,
|
||||
# incognito, compare, and tool-policy gates still run.
|
||||
if pending_tool_approval.tool_name == "bash":
|
||||
allow_bash = "true"
|
||||
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
|
||||
allow_web_search = "true"
|
||||
_search_enabled = True
|
||||
else:
|
||||
message = (
|
||||
f"Denied the {pending_tool_approval.tool_name} action shown above."
|
||||
if decision == "deny":
|
||||
return StreamingResponse(
|
||||
_tool_approval_resolution_stream(decision),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
# Approval is a control-plane continuation, not a new user turn.
|
||||
# Reuse the sealed interrupted request only for internal context,
|
||||
# retrieval, and policy reconstruction; never persist or display it.
|
||||
message = pending_tool_approval.continuation_query
|
||||
# The sealed server record, not mutable composer state,
|
||||
# restores the original action workspace.
|
||||
workspace = pending_tool_approval.workspace or None
|
||||
workspace_rejected = None
|
||||
if pending_tool_approval.document_id:
|
||||
active_doc_id = pending_tool_approval.document_id
|
||||
# Restore only the coarse request toggle needed by the exact
|
||||
# sealed action. Current privilege, global-disable, incognito,
|
||||
# compare, and tool-policy gates still run.
|
||||
if pending_tool_approval.tool_name == "bash":
|
||||
allow_bash = "true"
|
||||
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
|
||||
allow_web_search = "true"
|
||||
_search_enabled = True
|
||||
chat_mode = "agent"
|
||||
else:
|
||||
# A normal user message supersedes the card that was waiting
|
||||
@@ -1132,6 +1207,9 @@ def setup_chat_routes(
|
||||
owner=owner,
|
||||
session_id=session,
|
||||
)
|
||||
external_untrusted_context_seen = (
|
||||
external_untrusted_context_seen or retired_tool_approval_taint
|
||||
)
|
||||
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
@@ -1261,6 +1339,14 @@ def setup_chat_routes(
|
||||
agent_mode=(chat_mode == "agent"),
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
continuation_context_message=(
|
||||
pending_tool_approval.continuation_query
|
||||
if exact_tool_approval
|
||||
and pending_tool_approval
|
||||
and pending_tool_approval.continuation_query
|
||||
else None
|
||||
),
|
||||
persist_user_message=not tool_approval_continuation,
|
||||
)
|
||||
|
||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||
@@ -1663,7 +1749,11 @@ def setup_chat_routes(
|
||||
if foreground_policy.enabled
|
||||
else ctx.messages
|
||||
)
|
||||
messages = _ensure_current_request_is_latest_user(context_source, message)
|
||||
messages = (
|
||||
list(context_source)
|
||||
if tool_approval_continuation
|
||||
else _ensure_current_request_is_latest_user(context_source, message)
|
||||
)
|
||||
|
||||
# Auto-compact notification
|
||||
if ctx.was_compacted:
|
||||
@@ -2134,7 +2224,10 @@ def setup_chat_routes(
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
owner=_user,
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
allow_background_extraction=(
|
||||
not tool_policy.block_all_tool_calls
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
)
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
@@ -2223,17 +2316,17 @@ def setup_chat_routes(
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
relevant_tools=(
|
||||
set(pending_tool_approval.selected_tools)
|
||||
if exact_tool_approval
|
||||
and pending_tool_approval
|
||||
and pending_tool_approval.selected_tools
|
||||
else None
|
||||
),
|
||||
forced_tools=_forced_tools,
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
defer_context_shaping=_foreground_policy.enabled,
|
||||
external_untrusted_context_seen=bool(
|
||||
retired_tool_approval_taint
|
||||
or (
|
||||
tool_approval_continuation
|
||||
and pending_tool_approval
|
||||
and pending_tool_approval.external_untrusted_context_seen
|
||||
)
|
||||
),
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
exact_approval=exact_tool_approval,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
@@ -2401,8 +2494,14 @@ def setup_chat_routes(
|
||||
agent_tool_calls=_agent_tool_calls,
|
||||
skills_manager=skills_manager,
|
||||
owner=_user,
|
||||
extract_skills=user_requested_agent,
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
extract_skills=(
|
||||
user_requested_agent
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
allow_background_extraction=(
|
||||
not tool_policy.block_all_tool_calls
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
)
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
|
||||
@@ -1603,6 +1603,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
decision=decision,
|
||||
owner=user,
|
||||
session_id=None,
|
||||
# The button here says "Allow once" and there is no chat to carry a
|
||||
# scope into, so the gate must re-arm behind the sealed action.
|
||||
allow_continuation=False,
|
||||
)
|
||||
|
||||
if decision == "approve" and exact_approval is None:
|
||||
|
||||
+16
-1
@@ -3460,7 +3460,10 @@ async def stream_agent_loop(
|
||||
and exact_approval.pending.external_untrusted_context_seen
|
||||
)
|
||||
or messages_contain_external_untrusted_context(messages)
|
||||
)
|
||||
),
|
||||
approval_gate_bypassed=bool(
|
||||
exact_approval and exact_approval.allow_remaining_actions
|
||||
),
|
||||
)
|
||||
mcp_mgr = get_mcp_manager()
|
||||
prep_timings: Dict[str, float] = {}
|
||||
@@ -5698,6 +5701,16 @@ async def stream_agent_loop(
|
||||
"policy": "exact_tool_approval_target",
|
||||
}
|
||||
else:
|
||||
# The approval click becomes a synthetic user turn. Seal the
|
||||
# actual server-selected candidates now so that continuation
|
||||
# does not lose memory, skills, MCP, documents, or other
|
||||
# ToolIndex/RAG-selected tools by classifying that synthetic text.
|
||||
approval_selected_tools = set(_relevant_tools or ())
|
||||
approval_selected_tools.update(
|
||||
name for name in _tool_names_sent if name
|
||||
)
|
||||
approval_selected_tools.add(block.tool_type)
|
||||
approval_selected_tools.difference_update(disabled_tools)
|
||||
pending_approval = tool_approval_store.create(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
@@ -5725,6 +5738,8 @@ async def stream_agent_loop(
|
||||
external_untrusted_context_seen=(
|
||||
run_security.external_untrusted_context_seen
|
||||
),
|
||||
selected_tools=approval_selected_tools,
|
||||
continuation_query=_retrieval_query or _last_user,
|
||||
capabilities=capabilities_for_action(
|
||||
block.tool_type,
|
||||
block.content,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared wire values and scope markers for tool approval continuations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Keep the existing wire values so the current route and no-build frontend do
|
||||
# not need a second protocol migration. ``approve`` no longer means one action;
|
||||
# it now selects chat-session scope.
|
||||
TASK_APPROVAL_DECISION = "approve_task"
|
||||
CHAT_SESSION_APPROVAL_DECISION = "approve"
|
||||
DENY_APPROVAL_DECISION = "deny"
|
||||
|
||||
# Session.get_context_messages() adds this server-owned marker only when the
|
||||
# session history contains a matching, resolved chat-session approval.
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
||||
|
||||
|
||||
class ToolApprovalScope(str, Enum):
|
||||
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
||||
# keep the original one-use meaning: the sealed action runs and the gate
|
||||
# re-arms immediately for anything after it.
|
||||
SINGLE_ACTION = "single_action"
|
||||
TASK = "task"
|
||||
CHAT_SESSION = "chat_session"
|
||||
|
||||
|
||||
def scope_for_decision(decision: object) -> ToolApprovalScope | None:
|
||||
normalized = str(decision or "").strip().lower()
|
||||
if normalized == TASK_APPROVAL_DECISION:
|
||||
return ToolApprovalScope.TASK
|
||||
if normalized == CHAT_SESSION_APPROVAL_DECISION:
|
||||
return ToolApprovalScope.CHAT_SESSION
|
||||
return None
|
||||
+136
-15
@@ -1,8 +1,9 @@
|
||||
"""Opaque, exact, one-use approvals for tainted model-requested actions.
|
||||
"""Opaque exact-action approvals with explicit task and chat scopes.
|
||||
|
||||
The model may propose an action after untrusted context, but only the server
|
||||
stores and later executes the exact approved tool input. Browser-visible
|
||||
fields are display copies, never authority.
|
||||
The server still seals and claims the first displayed action exactly once. The
|
||||
selected scope then bypasses only the automatic post-external-context approval
|
||||
gate for the rest of the resumed task or chat session. Browser-visible fields
|
||||
are display copies, never authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +17,13 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
DENY_APPROVAL_DECISION,
|
||||
TASK_APPROVAL_DECISION,
|
||||
ToolApprovalScope,
|
||||
scope_for_decision,
|
||||
)
|
||||
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||
|
||||
|
||||
@@ -33,6 +41,51 @@ def _normalized_workspace(workspace: Any) -> str:
|
||||
return os.path.realpath(os.path.expanduser(workspace))
|
||||
|
||||
|
||||
_MAX_APPROVAL_SELECTED_TOOLS = 512
|
||||
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
|
||||
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
|
||||
|
||||
|
||||
def _normalized_selected_tools(
|
||||
selected_tools: Any,
|
||||
*,
|
||||
required_tool: Any = None,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(selected_tools, str):
|
||||
selected_tools = (selected_tools,)
|
||||
try:
|
||||
values = selected_tools or ()
|
||||
names = {
|
||||
name.strip()
|
||||
for name in values
|
||||
if (
|
||||
isinstance(name, str)
|
||||
and name.strip()
|
||||
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
|
||||
)
|
||||
}
|
||||
required_name = str(required_tool or "").strip()
|
||||
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
|
||||
names.add(required_name)
|
||||
ordered = sorted(names)
|
||||
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
|
||||
return tuple(ordered)
|
||||
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
|
||||
if required_name and required_name in names and required_name not in kept:
|
||||
kept[-1] = required_name
|
||||
kept.sort()
|
||||
return tuple(kept)
|
||||
except TypeError:
|
||||
return ()
|
||||
|
||||
|
||||
def _normalized_continuation_query(value: Any) -> str:
|
||||
# The query is server-derived from the interrupted run and already lives in
|
||||
# session history. Keep the pending copy bounded because approvals are held
|
||||
# in memory until consumed or expired.
|
||||
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
|
||||
|
||||
|
||||
def _canonical_digest(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
@@ -60,6 +113,8 @@ def _binding_payload(
|
||||
document_version: Any,
|
||||
document_digest: Any,
|
||||
external_untrusted_context_seen: bool,
|
||||
selected_tools: Any,
|
||||
continuation_query: Any,
|
||||
effects: tuple[str, ...],
|
||||
result_integrity: str,
|
||||
) -> dict[str, Any]:
|
||||
@@ -76,6 +131,10 @@ def _binding_payload(
|
||||
),
|
||||
"document_digest": str(document_digest or "").strip().lower(),
|
||||
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
||||
"selected_tools": list(
|
||||
_normalized_selected_tools(selected_tools, required_tool=tool_name)
|
||||
),
|
||||
"continuation_query": _normalized_continuation_query(continuation_query),
|
||||
"effects": list(effects),
|
||||
"result_integrity": str(result_integrity),
|
||||
}
|
||||
@@ -99,26 +158,47 @@ class PendingToolApproval:
|
||||
digest: str
|
||||
created_at: float
|
||||
expires_at: float
|
||||
# Server-only continuation state. Both fields are digest-bound and never
|
||||
# exposed in the browser payload.
|
||||
selected_tools: tuple[str, ...] = ()
|
||||
continuation_query: str = ""
|
||||
|
||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": "tool_approval",
|
||||
"approval_id": self.approval_id,
|
||||
"question": "Allow this exact action once?",
|
||||
# The browser already owns this chat id. Persisting it with the
|
||||
# resolved card lets history-derived session grants remain bound to
|
||||
# this exact chat and prevents inheritance by a forked session.
|
||||
"session_id": self.session_id,
|
||||
"question": "Allow this task to continue?",
|
||||
"description": reason or (
|
||||
"Untrusted context influenced this run, so this action needs "
|
||||
"your explicit approval."
|
||||
"Untrusted context influenced this run, so continuing with "
|
||||
"otherwise-gated actions needs your explicit approval."
|
||||
),
|
||||
"options": [
|
||||
{
|
||||
"label": "Allow once",
|
||||
"value": "approve",
|
||||
"description": "Execute only the sealed action shown here.",
|
||||
"label": "Allow for this task",
|
||||
"value": TASK_APPROVAL_DECISION,
|
||||
"description": (
|
||||
"Execute the sealed action and allow every otherwise-gated "
|
||||
"action needed to finish this request. Current tool, account, "
|
||||
"workspace, and sandbox restrictions still apply."
|
||||
),
|
||||
},
|
||||
{
|
||||
"label": "Allow for this chat session",
|
||||
"value": CHAT_SESSION_APPROVAL_DECISION,
|
||||
"description": (
|
||||
"Execute the sealed action and stop asking at this gate for "
|
||||
"later requests in this chat. Current tool, account, workspace, "
|
||||
"and sandbox restrictions still apply."
|
||||
),
|
||||
},
|
||||
{
|
||||
"label": "Deny",
|
||||
"value": "deny",
|
||||
"description": "Do not execute it.",
|
||||
"value": DENY_APPROVAL_DECISION,
|
||||
"description": "Do not execute the proposed action.",
|
||||
},
|
||||
],
|
||||
"action": {
|
||||
@@ -137,12 +217,23 @@ class PendingToolApproval:
|
||||
|
||||
@dataclass
|
||||
class ExactToolApproval:
|
||||
"""A consumed grant that the dispatcher can claim exactly once."""
|
||||
"""A consumed exact first action plus an explicit continuation scope."""
|
||||
|
||||
pending: PendingToolApproval
|
||||
scope: ToolApprovalScope = ToolApprovalScope.TASK
|
||||
# The seam consumed by agent_loop. Both chat-card allow choices cover the
|
||||
# complete resumed task, because one-action scope there immediately
|
||||
# re-entered the same gate on the next round. Callers with no resumable
|
||||
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
|
||||
# sealed action.
|
||||
allow_remaining_actions: bool = True
|
||||
_claimed: bool = field(default=False, init=False, repr=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def grants_chat_session(self) -> bool:
|
||||
return self.scope is ToolApprovalScope.CHAT_SESSION
|
||||
|
||||
def _matches_unlocked(
|
||||
self,
|
||||
*,
|
||||
@@ -175,6 +266,8 @@ class ExactToolApproval:
|
||||
external_untrusted_context_seen=(
|
||||
self.pending.external_untrusted_context_seen
|
||||
),
|
||||
selected_tools=self.pending.selected_tools,
|
||||
continuation_query=self.pending.continuation_query,
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
)
|
||||
@@ -255,6 +348,8 @@ class ToolApprovalStore:
|
||||
document_id: Any = None,
|
||||
document_version: Any = None,
|
||||
document_digest: Any = None,
|
||||
selected_tools: Any = None,
|
||||
continuation_query: Any = None,
|
||||
external_untrusted_context_seen: bool,
|
||||
capabilities: ToolCapabilities,
|
||||
) -> PendingToolApproval:
|
||||
@@ -272,6 +367,8 @@ class ToolApprovalStore:
|
||||
document_version=document_version,
|
||||
document_digest=document_digest,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
selected_tools=selected_tools,
|
||||
continuation_query=continuation_query,
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
)
|
||||
@@ -294,6 +391,8 @@ class ToolApprovalStore:
|
||||
digest=_canonical_digest(payload),
|
||||
created_at=now,
|
||||
expires_at=now + self._ttl_seconds,
|
||||
selected_tools=tuple(payload["selected_tools"]),
|
||||
continuation_query=payload["continuation_query"],
|
||||
)
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
@@ -331,7 +430,17 @@ class ToolApprovalStore:
|
||||
decision: Any,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
allow_continuation: bool = True,
|
||||
) -> ExactToolApproval | None:
|
||||
"""Consume a pending approval.
|
||||
|
||||
``allow_continuation`` is the caller's assertion that it owns a
|
||||
resumable conversation the granted scope can apply to. Callers without
|
||||
one (the skill tester, unattended audits) pass ``False`` and get the
|
||||
original one-use grant, so a button labelled "Allow once" cannot widen
|
||||
into a run-long bypass just because the chat card reuses the same wire
|
||||
value.
|
||||
"""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
@@ -348,9 +457,21 @@ class ToolApprovalStore:
|
||||
# another owner's pending action.
|
||||
return None
|
||||
self._pending.pop(approval_key, None)
|
||||
if str(decision or "").strip().lower() != "approve":
|
||||
normalized_decision = str(decision or "").strip().lower()
|
||||
scope = scope_for_decision(normalized_decision)
|
||||
if scope is None:
|
||||
return None
|
||||
return ExactToolApproval(pending)
|
||||
if not allow_continuation:
|
||||
return ExactToolApproval(
|
||||
pending,
|
||||
scope=ToolApprovalScope.SINGLE_ACTION,
|
||||
allow_remaining_actions=False,
|
||||
)
|
||||
return ExactToolApproval(
|
||||
pending,
|
||||
scope=scope,
|
||||
allow_remaining_actions=True,
|
||||
)
|
||||
|
||||
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||
now = time.time()
|
||||
|
||||
@@ -14,6 +14,7 @@ from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
|
||||
|
||||
@@ -618,13 +619,30 @@ class ToolRunSecurityContext:
|
||||
external_untrusted_context_seen: bool = False
|
||||
external_sources: list[str] = field(default_factory=list)
|
||||
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||
# Task-scope approval sets this for the resumed in-memory run. Chat-scope
|
||||
# approval is projected from the server-owned session history marker below.
|
||||
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||
approval_gate_bypassed: bool = False
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Promote any server-labelled untrusted prompt context into the gate."""
|
||||
if messages_contain_external_untrusted_context(messages):
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
message_list = list(messages or ())
|
||||
if any(
|
||||
isinstance(message, dict)
|
||||
and isinstance(message.get("metadata"), dict)
|
||||
and message["metadata"].get(
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
) is True
|
||||
for message in message_list
|
||||
):
|
||||
self.approval_gate_bypassed = True
|
||||
if messages_contain_external_untrusted_context(message_list):
|
||||
self.external_untrusted_context_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
if self.approval_gate_bypassed:
|
||||
return ToolGateDecision(True)
|
||||
if not self.external_untrusted_context_seen:
|
||||
return ToolGateDecision(True)
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
||||
import ragModule from './js/rag.js';
|
||||
import presetsModule from './js/presets.js';
|
||||
import searchModule from './js/search.js';
|
||||
import chatModule from './js/chat.js?v=20260815toolapproval4';
|
||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
|
||||
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
|
||||
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
settleSessionHydration
|
||||
} from './js/startupShell.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
|
||||
import sessionModule from './js/sessions.js';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
|
||||
+3
-3
@@ -2572,10 +2572,10 @@
|
||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
|
||||
+19
-16
@@ -8,8 +8,8 @@
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
|
||||
import chatStream from './chatStream.js?v=20260815approvalsave1';
|
||||
import chatRenderer from './chatRenderer.js?v=20260819approvalcontrol1';
|
||||
import chatStream from './chatStream.js?v=20260819approvalcontrol1';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
@@ -62,20 +62,18 @@ import { loadPanel } from './panels.js';
|
||||
let _contextHeaderBound = false;
|
||||
let _pendingToolApproval = null;
|
||||
|
||||
function _submitToolApprovalWhenIdle(approvalId, label) {
|
||||
function _submitToolApprovalWhenIdle(approvalId) {
|
||||
if (
|
||||
!_pendingToolApproval
|
||||
|| _pendingToolApproval.approval_id !== approvalId
|
||||
) return;
|
||||
if (isStreaming || _sendInFlight) {
|
||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
|
||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 120);
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('message');
|
||||
if (input) {
|
||||
_pendingToolApproval.draft = input.value || '';
|
||||
input.value = label;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
if (sendButton) sendButton.click();
|
||||
@@ -84,16 +82,13 @@ import { loadPanel } from './panels.js';
|
||||
document.addEventListener('odysseus:tool-approval', (event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const decision = String(detail.decision || '').toLowerCase();
|
||||
if (!detail.approval_id || !['approve', 'deny'].includes(decision)) return;
|
||||
if (!detail.approval_id || !['approve', 'approve_task', 'deny'].includes(decision)) return;
|
||||
_pendingToolApproval = {
|
||||
approval_id: String(detail.approval_id),
|
||||
decision,
|
||||
document_id: String(detail.document_id || ''),
|
||||
};
|
||||
_submitToolApprovalWhenIdle(
|
||||
_pendingToolApproval.approval_id,
|
||||
detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
|
||||
);
|
||||
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
||||
});
|
||||
|
||||
function _fmtContextNumber(n) {
|
||||
@@ -1309,10 +1304,10 @@ import { loadPanel } from './panels.js';
|
||||
}
|
||||
|
||||
const el = uiModule.el;
|
||||
const msg = el('message').value;
|
||||
const msg = approvalForSend ? '' : el('message').value;
|
||||
// Allow empty text when a regen carries over the original message's
|
||||
// attachment ids — a photo-only message still has something to send.
|
||||
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||
if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||
|
||||
// --- Slash commands: execute directly without AI (no session needed) ---
|
||||
if (!approvalForSend && isCommand(msg.trim())) {
|
||||
@@ -1590,7 +1585,7 @@ import { loadPanel } from './panels.js';
|
||||
|
||||
const userDisplay = _displayOverride || msg;
|
||||
_displayOverride = null;
|
||||
const skipBubble = _hideUserBubble;
|
||||
const skipBubble = _hideUserBubble || !!approvalForSend;
|
||||
_hideUserBubble = false;
|
||||
// Auto-recovery counter: carries across a turn's auto-continues, but resets
|
||||
// when the user genuinely sends a new message (so each task gets a fresh cap).
|
||||
@@ -1833,7 +1828,7 @@ import { loadPanel } from './panels.js';
|
||||
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', _finalMsgWithInject);
|
||||
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
||||
fd.append('session', streamSessionId);
|
||||
if (approvalForSend) {
|
||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||
@@ -2873,7 +2868,7 @@ import { loadPanel } from './panels.js';
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
break;
|
||||
}
|
||||
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
if (json.delta || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
clearResponseTimeout();
|
||||
clearProcessingProbe();
|
||||
clearFirstTokenWaitTimers();
|
||||
@@ -2890,6 +2885,14 @@ import { loadPanel } from './panels.js';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (json.type === 'tool_approval_resolved') {
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
if (!_isBg && roundHolder && roundHolder !== holder) roundHolder.remove();
|
||||
if (!_isBg && holder) holder.remove();
|
||||
continue;
|
||||
}
|
||||
if (json.delta) {
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
|
||||
+75
-15
@@ -2327,6 +2327,42 @@ export function removeAskUserCards(root) {
|
||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
// While a choice card is visible, let plain 1–3 activate the corresponding
|
||||
// rendered option. Reuse the option's click path so the question keeps its
|
||||
// existing submission semantics. Tool approval cards are excluded: that card
|
||||
// exists to make consent deliberate after untrusted context influenced the
|
||||
// run, and its first option is the widest grant, so a stray digit must not
|
||||
// answer it.
|
||||
function _handleAskUserShortcut(event) {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.repeat
|
||||
|| event.isComposing
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.metaKey
|
||||
|| event.shiftKey
|
||||
) return;
|
||||
if (!/^[1-3]$/.test(event.key)) return;
|
||||
|
||||
const target = event.target;
|
||||
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
|
||||
const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
|
||||
const mainCard = document.querySelector('#chat-history .ask-user-card');
|
||||
const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
|
||||
const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
|
||||
if (!card) return;
|
||||
if (card.dataset.askUserKind === 'tool_approval') return;
|
||||
const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
|
||||
if (!option || option.disabled) return;
|
||||
|
||||
event.preventDefault();
|
||||
option.click();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', _handleAskUserShortcut);
|
||||
|
||||
/**
|
||||
* Render an ask_user payload as a durable choice card.
|
||||
*
|
||||
@@ -2336,11 +2372,15 @@ export function removeAskUserCards(root) {
|
||||
*/
|
||||
export function renderAskUserCard(payload, options) {
|
||||
const aq = payload || {};
|
||||
if (aq.resolved) return null;
|
||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
const renderOptions = options || {};
|
||||
const chatBox = renderOptions.root || document.getElementById('chat-history');
|
||||
const onSubmit = typeof renderOptions.onSubmit === 'function'
|
||||
? renderOptions.onSubmit
|
||||
: null;
|
||||
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||
|
||||
const renderOptions = options || {};
|
||||
removeAskUserCards(chatBox);
|
||||
|
||||
const card = document.createElement('div');
|
||||
@@ -2349,6 +2389,7 @@ export function renderAskUserCard(payload, options) {
|
||||
card.tabIndex = -1;
|
||||
const multi = !!aq.multi;
|
||||
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
||||
card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
|
||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||
|
||||
const head = document.createElement('div');
|
||||
@@ -2357,7 +2398,6 @@ export function renderAskUserCard(payload, options) {
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
@@ -2400,6 +2440,17 @@ export function renderAskUserCard(payload, options) {
|
||||
|
||||
const send = (text) => {
|
||||
if (!text) return;
|
||||
if (onSubmit) {
|
||||
const accepted = onSubmit({
|
||||
kind: 'answer',
|
||||
text,
|
||||
label: text,
|
||||
payload: aq,
|
||||
card,
|
||||
});
|
||||
if (accepted !== false) card.remove();
|
||||
return;
|
||||
}
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.value = text;
|
||||
@@ -2433,17 +2484,26 @@ export function renderAskUserCard(payload, options) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => {
|
||||
if (isToolApproval) {
|
||||
card.remove();
|
||||
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
|
||||
detail: {
|
||||
approval_id: aq.approval_id,
|
||||
decision: String((opt && opt.value) || '').toLowerCase(),
|
||||
label,
|
||||
document_id: aq.action && aq.action.document_id
|
||||
? String(aq.action.document_id)
|
||||
: '',
|
||||
},
|
||||
}));
|
||||
const detail = {
|
||||
approval_id: aq.approval_id,
|
||||
decision: String((opt && opt.value) || '').toLowerCase(),
|
||||
label,
|
||||
document_id: aq.action && aq.action.document_id
|
||||
? String(aq.action.document_id)
|
||||
: '',
|
||||
};
|
||||
if (onSubmit) {
|
||||
const accepted = onSubmit({
|
||||
kind: 'tool_approval',
|
||||
...detail,
|
||||
payload: aq,
|
||||
card,
|
||||
});
|
||||
if (accepted !== false) card.remove();
|
||||
} else {
|
||||
card.remove();
|
||||
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
|
||||
}
|
||||
} else {
|
||||
send(label);
|
||||
}
|
||||
@@ -2628,7 +2688,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.appendChild(threadWrap);
|
||||
}
|
||||
for (const ev of roundTools) {
|
||||
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||
let outHtml = '';
|
||||
if (ev.output && ev.output.trim()) {
|
||||
|
||||
@@ -9,6 +9,35 @@ import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js?v=20260815approvalsave1';
|
||||
|
||||
// Tool approvals are control-plane submits for the current chat. chat.js
|
||||
// deliberately leaves the composer untouched, then programmatically clicks the
|
||||
// shared send button after it records the sealed approval id/decision. That
|
||||
// button is polymorphic: with an empty composer it can mean New chat or Record
|
||||
// voice instead of Send. Intercept only the programmatic approval click and
|
||||
// route it through the form submit path, which already reaches chat.js directly.
|
||||
document.addEventListener('odysseus:tool-approval', () => {
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
if (!sendButton || !chatForm) return;
|
||||
|
||||
const interceptApprovalClick = (event) => {
|
||||
// A real user click must retain the normal send/new-chat/STT behavior.
|
||||
if (event.isTrusted) return;
|
||||
sendButton.removeEventListener('click', interceptApprovalClick, true);
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (chatForm.requestSubmit) chatForm.requestSubmit();
|
||||
else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
};
|
||||
|
||||
sendButton.addEventListener('click', interceptApprovalClick, true);
|
||||
// Fail-safe cleanup if the approval continuation never reaches its deferred
|
||||
// synthetic click (for example because the surrounding view is torn down).
|
||||
setTimeout(() => {
|
||||
sendButton.removeEventListener('click', interceptApprovalClick, true);
|
||||
}, 60000);
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
|
||||
import {
|
||||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||||
@@ -1006,11 +1006,16 @@ async function _executeCompare(message) {
|
||||
console.error('Compare error:', err);
|
||||
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
||||
} finally {
|
||||
state._streaming = false;
|
||||
_setSendBtn('send');
|
||||
// Re-enable header buttons
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||||
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
||||
// A pane may have started its own ask_user/approval continuation while the
|
||||
// original all-pane Promise was settling. Keep Compare busy until every
|
||||
// pane-owned controller is gone instead of exposing a second broadcast send.
|
||||
const compareStillStreaming = state._abortControllers.some(Boolean);
|
||||
state._streaming = compareStillStreaming;
|
||||
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||
button.disabled = compareStillStreaming;
|
||||
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
|
||||
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1514,7 +1519,7 @@ async function showShufflePoolEditor() {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
registerCompareActions({ stopAll, resetCompare });
|
||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
|
||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
|
||||
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+189
-6
@@ -1,7 +1,7 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
@@ -24,11 +24,157 @@ function _safeHttpHref(raw) {
|
||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||
let _rerollPane = null;
|
||||
let _autoPreviewHtml = null;
|
||||
let _setSendBtn = null;
|
||||
|
||||
/** Register external functions that live in compare.js. */
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
|
||||
_rerollPane = rerollPane;
|
||||
_autoPreviewHtml = autoPreviewHtml;
|
||||
_setSendBtn = setSendBtn;
|
||||
}
|
||||
|
||||
function _paneSessionIsCurrent(paneIdx, sessionId) {
|
||||
return Boolean(
|
||||
state.isActive
|
||||
&& state._paneSessionIds[paneIdx] === sessionId
|
||||
&& document.getElementById('cmp-history-' + paneIdx)
|
||||
);
|
||||
}
|
||||
|
||||
function _setCompareBusy(active) {
|
||||
state._streaming = Boolean(active);
|
||||
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||
button.disabled = Boolean(active);
|
||||
button.style.opacity = active ? '0.25' : '0.7';
|
||||
button.style.pointerEvents = active ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncCompareBusyFromPanes() {
|
||||
_setCompareBusy((state._abortControllers || []).some(Boolean));
|
||||
}
|
||||
|
||||
function _appendPaneMessage(hist, role, text) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
roleEl.textContent = role === 'user' ? 'You' : 'AI';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.textContent = text || '';
|
||||
message.appendChild(roleEl);
|
||||
message.appendChild(body);
|
||||
hist.appendChild(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function _createPaneContinuationMessage(hist) {
|
||||
const message = _appendPaneMessage(hist, 'assistant', '');
|
||||
const body = message.querySelector('.body');
|
||||
if (spinnerModule) {
|
||||
const spinner = spinnerModule.create('Continuing...', 'right');
|
||||
body.appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
message._spinner = spinner;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
const restored = _renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
submission.payload || {},
|
||||
hist,
|
||||
null,
|
||||
originController,
|
||||
);
|
||||
if (uiModule) {
|
||||
uiModule.showError(
|
||||
restored
|
||||
? 'This pane is still streaming — choose again once it settles.'
|
||||
: 'Compare pane is still streaming; the choice was not sent.',
|
||||
);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
|
||||
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const resume = () => {
|
||||
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
|
||||
const activeController = state._abortControllers[paneIdx];
|
||||
if (activeController === originController) {
|
||||
if (Date.now() - startedAt < 10000) {
|
||||
setTimeout(resume, 25);
|
||||
return;
|
||||
}
|
||||
// The originating stream never released the pane. The card was already
|
||||
// removed when the choice was accepted, so put it back rather than
|
||||
// swallowing a decision the user made.
|
||||
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
|
||||
return;
|
||||
}
|
||||
// A reroll/model replacement already owns this pane. Never send the stale
|
||||
// choice into that replacement stream or session UI.
|
||||
if (activeController) return;
|
||||
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (!hist) return;
|
||||
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
|
||||
|
||||
const isApproval = submission.kind === 'tool_approval';
|
||||
const message = isApproval ? '' : String(submission.text || submission.label || '');
|
||||
if (!isApproval) _appendPaneMessage(hist, 'user', message);
|
||||
const aiMessage = _createPaneContinuationMessage(hist);
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
const resumeOptions = { skipBadge: true };
|
||||
if (isApproval) {
|
||||
resumeOptions.toolApproval = {
|
||||
approval_id: String(submission.approval_id || ''),
|
||||
decision: String(submission.decision || '').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
_setCompareBusy(true);
|
||||
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
|
||||
.catch((error) => {
|
||||
console.error('Compare pane continuation failed:', error);
|
||||
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
|
||||
})
|
||||
.finally(_syncCompareBusyFromPanes);
|
||||
};
|
||||
|
||||
setTimeout(resume, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
|
||||
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
|
||||
if (aiMsgEl && aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
const card = renderAskUserCard(payload, {
|
||||
root: hist,
|
||||
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
originController,
|
||||
submission,
|
||||
),
|
||||
});
|
||||
if (card) {
|
||||
card.dataset.comparePane = String(paneIdx);
|
||||
card.dataset.compareSession = String(sessionId);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
||||
@@ -164,6 +310,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
let metrics = null;
|
||||
let timedOut = false;
|
||||
let streamOk = false;
|
||||
let awaitingChoice = false;
|
||||
let currentToolBlock = null; // track active agent tool block
|
||||
// Idle timeout — abort only if no data is received for this many seconds.
|
||||
// Long generations (SVG, big code) are fine as long as the stream stays
|
||||
@@ -219,6 +366,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const fd = new FormData();
|
||||
fd.append('message', message);
|
||||
fd.append('session', sessionId);
|
||||
if (opts.toolApproval) {
|
||||
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
|
||||
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
|
||||
}
|
||||
|
||||
// Compare mode determines what tools/features are enabled
|
||||
const isAgent = state._compareMode === 'agent';
|
||||
@@ -322,6 +473,36 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pane-local question / approval selector ──
|
||||
} else if (json.type === 'ask_user') {
|
||||
awaitingChoice = true;
|
||||
_renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
json.data || {},
|
||||
hist,
|
||||
aiMsgEl,
|
||||
ac,
|
||||
);
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
// Deny ends as a tiny resolution-only stream, so replace the
|
||||
// continuation spinner with an explicit pane-local result.
|
||||
} else if (json.type === 'tool_approval_resolved') {
|
||||
if (aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
|
||||
let target = aiMsgEl._textEl;
|
||||
if (!target) {
|
||||
target = document.createElement('div');
|
||||
target.className = 'compare-text-content';
|
||||
aiBody.appendChild(target);
|
||||
aiMsgEl._textEl = target;
|
||||
}
|
||||
target.textContent = accumulated;
|
||||
|
||||
// ── Tool start (bash, web search agent tool) ──
|
||||
} else if (json.type === 'tool_start') {
|
||||
// Finalize any accumulated text before the tool block
|
||||
@@ -640,19 +821,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// TTFT removed from the header per user request — just show total time.
|
||||
_timerEl.textContent = _formatMs(_totalMs);
|
||||
}
|
||||
state._abortControllers[paneIdx] = null;
|
||||
if (state._abortControllers[paneIdx] === ac) {
|
||||
state._abortControllers[paneIdx] = null;
|
||||
}
|
||||
// Hide stop button, show response action buttons
|
||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneElFinal) {
|
||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||
if (accumulated.trim()) {
|
||||
if (!awaitingChoice && accumulated.trim()) {
|
||||
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
}
|
||||
state._paneMetrics[paneIdx] = metrics;
|
||||
state._paneElapsed[paneIdx] = _totalMs;
|
||||
if (!opts.skipBadge) {
|
||||
if (!opts.skipBadge && !awaitingChoice) {
|
||||
if (streamOk) {
|
||||
state._finishOrder++;
|
||||
if (state._parallel) {
|
||||
@@ -682,7 +865,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
}
|
||||
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
||||
if (streamOk && state._expectedAnswer) {
|
||||
if (streamOk && !awaitingChoice && state._expectedAnswer) {
|
||||
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
||||
}
|
||||
// Show copy/reroll buttons now that response exists
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_compare_renders_ask_user_in_the_originating_pane():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "renderAskUserCard" in stream
|
||||
assert "} else if (json.type === 'ask_user') {" in stream
|
||||
assert "root: hist" in stream
|
||||
assert "state._paneSessionIds[paneIdx] === sessionId" in stream
|
||||
assert "streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)" in stream
|
||||
assert "handleCompareSubmit" not in stream
|
||||
|
||||
|
||||
def test_compare_submits_approval_only_to_the_pane_session():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "fd.append('tool_approval_id', opts.toolApproval.approval_id || '');" in stream
|
||||
assert "fd.append('tool_approval_decision', opts.toolApproval.decision || '');" in stream
|
||||
assert "const isApproval = submission.kind === 'tool_approval';" in stream
|
||||
assert "const message = isApproval ? ''" in stream
|
||||
assert "if (!isApproval) _appendPaneMessage(hist, 'user', message);" in stream
|
||||
assert "json.type === 'tool_approval_resolved'" in stream
|
||||
assert "json.decision === 'deny' ? 'Denied.'" in stream
|
||||
|
||||
|
||||
def test_compare_continuation_does_not_lose_or_replace_pane_ownership():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||
index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "if (state._abortControllers[paneIdx] === ac)" in stream
|
||||
assert "if (activeController === originController)" in stream
|
||||
assert "if (activeController) return;" in stream
|
||||
assert "registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });" in index
|
||||
assert "const compareStillStreaming = state._abortControllers.some(Boolean);" in index
|
||||
assert "_setSendBtn(compareStillStreaming ? 'stop' : 'send');" in index
|
||||
|
||||
|
||||
def test_compare_restores_the_card_instead_of_dropping_a_timed_out_choice():
|
||||
"""The card is removed the moment a choice is accepted.
|
||||
|
||||
If the originating stream still owns the pane when the resume deadline
|
||||
passes, the decision has nowhere to go — so the card has to come back
|
||||
rather than the click vanishing silently.
|
||||
"""
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "function _restorePaneAskUserCard(" in stream
|
||||
assert "_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);" in stream
|
||||
assert "submission.payload || {}" in stream
|
||||
|
||||
start = stream.index("function _resumePaneChoiceWhenIdle(")
|
||||
end = stream.index("function _renderPaneAskUserCard(", start)
|
||||
resume = stream[start:end]
|
||||
# The deadline must not fall through to a bare return any more.
|
||||
assert "if (Date.now() - startedAt < 10000) setTimeout(resume, 25);" not in resume
|
||||
@@ -344,7 +344,7 @@ async def test_chat_stream_approval_restores_exact_shell_turn_toggle(monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
|
||||
async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
|
||||
from src.tool_capabilities import capabilities_for_action
|
||||
|
||||
captured = {}
|
||||
@@ -368,11 +368,13 @@ async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
|
||||
)
|
||||
|
||||
response = await endpoint(request)
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
chunks = [chunk async for chunk in response.body_iterator]
|
||||
|
||||
event = json.loads(chunks[0][len("data: "):])
|
||||
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
|
||||
assert chunks[-1] == "data: [DONE]\n\n"
|
||||
assert "agent" not in captured
|
||||
assert "exact_approval" not in captured
|
||||
assert captured["agent_external_untrusted_context_seen"] is True
|
||||
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_tool_approval_bypasses_polymorphic_send_button_actions():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
stream = (root / "static/js/chatStream.js").read_text(encoding="utf-8")
|
||||
|
||||
# chat.js still defers the sealed approval through a synthetic button click.
|
||||
assert "if (sendButton) sendButton.click();" in chat
|
||||
|
||||
# The capture listener must intercept only that synthetic click and route it
|
||||
# through the chat form submit path, before app.js can reinterpret an empty
|
||||
# composer as New chat or Record voice.
|
||||
assert "if (event.isTrusted) return;" in stream
|
||||
assert "event.stopImmediatePropagation();" in stream
|
||||
assert "chatForm.requestSubmit()" in stream
|
||||
assert "sendButton.dataset.mode = ''" not in stream
|
||||
|
||||
|
||||
def test_ask_user_close_button_uses_one_css_glyph():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
styles = (root / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "closeBtn.className = 'modal-close ask-user-close';" in renderer
|
||||
assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" in renderer
|
||||
assert "closeBtn.textContent = '×';" not in renderer
|
||||
assert ".modal-close::before" in styles
|
||||
|
||||
|
||||
def test_ask_user_number_shortcuts_reuse_option_click_path():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
start = renderer.index("function _handleAskUserShortcut(event)")
|
||||
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
|
||||
shortcut = renderer[start:end]
|
||||
|
||||
assert "if (!/^[1-3]$/.test(event.key)) return;" in shortcut
|
||||
assert "event.repeat" in shortcut
|
||||
assert "event.ctrlKey" in shortcut
|
||||
assert "event.altKey" in shortcut
|
||||
assert "event.metaKey" in shortcut
|
||||
assert "event.shiftKey" in shortcut
|
||||
assert "input, textarea, select, [contenteditable=\"true\"]" in shortcut
|
||||
assert "card.querySelectorAll('.ask-user-option')[Number(event.key) - 1]" in shortcut
|
||||
assert "event.preventDefault();" in shortcut
|
||||
assert "option.click();" in shortcut
|
||||
|
||||
|
||||
def test_digit_shortcuts_never_answer_a_tool_approval_card():
|
||||
"""A stray digit must not grant a scope the user did not deliberately pick."""
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
start = renderer.index("function _handleAskUserShortcut(event)")
|
||||
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
|
||||
shortcut = renderer[start:end]
|
||||
|
||||
assert "if (card.dataset.askUserKind === 'tool_approval') return;" in shortcut
|
||||
# The renderer has to label the card for that guard to ever fire.
|
||||
assert (
|
||||
"card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';"
|
||||
in renderer
|
||||
)
|
||||
|
||||
|
||||
def test_ask_user_renderer_accepts_scoped_root_and_submit_callback():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "const chatBox = renderOptions.root || document.getElementById('chat-history');" in renderer
|
||||
assert "const onSubmit = typeof renderOptions.onSubmit === 'function'" in renderer
|
||||
assert "kind: 'answer'" in renderer
|
||||
assert "kind: 'tool_approval'" in renderer
|
||||
assert "if (accepted !== false) card.remove();" in renderer
|
||||
assert "document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }))" in renderer
|
||||
|
||||
|
||||
def test_every_changed_approval_module_is_cache_busted_together():
|
||||
"""A stale module here silently reinterprets the approval click.
|
||||
|
||||
chat.js leaves the composer empty and clicks the polymorphic send button,
|
||||
so a browser that pairs the new chat.js with a cached chatStream.js has no
|
||||
interceptor and lands on the New chat branch instead. The same holds for
|
||||
the compare pane modules, which chatRenderer now shares a keydown listener
|
||||
with.
|
||||
"""
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
version = "20260819approvalcontrol1"
|
||||
index = (root / "static/index.html").read_text(encoding="utf-8")
|
||||
app = (root / "static/app.js").read_text(encoding="utf-8")
|
||||
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
compare_index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
|
||||
compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||
|
||||
assert f"chatStream.js?v={version}" in index
|
||||
assert f"chatStream.js?v={version}" in chat
|
||||
assert f"compare/index.js?v={version}" in app
|
||||
assert f"stream.js?v={version}" in compare_index
|
||||
# One chatRenderer instance, so the ask_user keydown listener binds once.
|
||||
assert f"chatRenderer.js?v={version}" in compare_stream
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Callers with no resumable chat keep the original one-use approval scope.
|
||||
|
||||
The chat card reuses the wire value ``approve`` for chat-session scope, so any
|
||||
caller that still sends ``approve`` meaning "once" has to say so explicitly or
|
||||
it silently inherits a run-long gate bypass.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from src.tool_approval_scopes import ToolApprovalScope
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
|
||||
|
||||
def _pending(store: ToolApprovalStore, *, session_id=""):
|
||||
content = "printf exact"
|
||||
return store.create(
|
||||
owner="Alice",
|
||||
session_id=session_id,
|
||||
origin_run_id="run-1",
|
||||
tool_name="bash",
|
||||
content=content,
|
||||
workspace=None,
|
||||
external_untrusted_context_seen=True,
|
||||
capabilities=capabilities_for_action("bash", content),
|
||||
)
|
||||
|
||||
|
||||
def test_single_action_grant_leaves_the_gate_armed_behind_the_sealed_action():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store)
|
||||
|
||||
grant = store.consume(
|
||||
pending.approval_id,
|
||||
decision="approve",
|
||||
owner="alice",
|
||||
session_id=None,
|
||||
allow_continuation=False,
|
||||
)
|
||||
|
||||
assert grant is not None
|
||||
assert grant.scope is ToolApprovalScope.SINGLE_ACTION
|
||||
assert grant.allow_remaining_actions is False
|
||||
assert grant.grants_chat_session is False
|
||||
|
||||
resumed = ToolRunSecurityContext(
|
||||
external_untrusted_context_seen=True,
|
||||
approval_gate_bypassed=grant.allow_remaining_actions,
|
||||
)
|
||||
assert resumed.decision_for("bash").allowed is False
|
||||
|
||||
|
||||
def test_chat_callers_still_get_the_continuation_scope_they_asked_for():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store, session_id="session-1")
|
||||
|
||||
grant = store.consume(
|
||||
pending.approval_id,
|
||||
decision="approve_task",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert grant is not None
|
||||
assert grant.scope is ToolApprovalScope.TASK
|
||||
assert grant.allow_remaining_actions is True
|
||||
|
||||
|
||||
def test_deny_is_unaffected_by_the_single_action_flag():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store)
|
||||
|
||||
assert store.consume(
|
||||
pending.approval_id,
|
||||
decision="deny",
|
||||
owner="alice",
|
||||
session_id=None,
|
||||
allow_continuation=False,
|
||||
) is None
|
||||
assert store.peek(pending.approval_id) is None
|
||||
|
||||
|
||||
def test_skill_test_approval_route_opts_out_of_continuation():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
skills = (root / "routes/skills_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
approve_call = skills.index("exact_approval = tool_approval_store.consume(")
|
||||
end = skills.index(")", skills.index("allow_continuation", approve_call))
|
||||
assert "allow_continuation=False" in skills[approve_call:end]
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Task- and chat-scoped approval continuation coverage for issue #6112."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.models import ChatMessage, Session
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
ToolApprovalScope,
|
||||
)
|
||||
from src.tool_approvals import ExactToolApproval, ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
|
||||
|
||||
def _pending(
|
||||
store: ToolApprovalStore,
|
||||
*,
|
||||
selected_tools=None,
|
||||
continuation_query="inspect the project using memory and skills",
|
||||
):
|
||||
content = "printf exact"
|
||||
return store.create(
|
||||
owner="Alice",
|
||||
session_id="session-1",
|
||||
origin_run_id="run-1",
|
||||
tool_name="bash",
|
||||
content=content,
|
||||
workspace=None,
|
||||
external_untrusted_context_seen=True,
|
||||
selected_tools=selected_tools,
|
||||
continuation_query=continuation_query,
|
||||
capabilities=capabilities_for_action("bash", content),
|
||||
)
|
||||
|
||||
|
||||
def test_card_offers_task_chat_session_and_deny_without_leaking_private_state():
|
||||
pending = _pending(
|
||||
ToolApprovalStore(),
|
||||
selected_tools=["manage_skills", "bash", "manage_skills"],
|
||||
)
|
||||
|
||||
payload = pending.public_payload()
|
||||
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert [option["value"] for option in payload["options"]] == [
|
||||
"approve_task",
|
||||
"approve",
|
||||
"deny",
|
||||
]
|
||||
assert [option["label"] for option in payload["options"]] == [
|
||||
"Allow for this task",
|
||||
"Allow for this chat session",
|
||||
"Deny",
|
||||
]
|
||||
serialized = json.dumps(payload, sort_keys=True)
|
||||
assert "Allow once" not in serialized
|
||||
assert "selected_tools" not in serialized
|
||||
assert "continuation_query" not in serialized
|
||||
assert "manage_skills" not in serialized
|
||||
assert "inspect the project" not in serialized
|
||||
|
||||
|
||||
def test_allow_for_task_bypasses_only_the_resumed_run_gate():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store, selected_tools=["bash", "manage_skills"])
|
||||
grant = store.consume(
|
||||
pending.approval_id,
|
||||
decision="approve_task",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert grant is not None
|
||||
assert grant.scope is ToolApprovalScope.TASK
|
||||
assert grant.allow_remaining_actions is True
|
||||
assert grant.grants_chat_session is False
|
||||
assert grant.pending.continuation_query == (
|
||||
"inspect the project using memory and skills"
|
||||
)
|
||||
|
||||
resumed = ToolRunSecurityContext(
|
||||
external_untrusted_context_seen=True,
|
||||
approval_gate_bypassed=grant.allow_remaining_actions,
|
||||
)
|
||||
assert resumed.decision_for("bash").allowed is True
|
||||
|
||||
# A new ordinary user turn constructs a fresh context and asks again.
|
||||
fresh = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||
assert fresh.decision_for("bash").allowed is False
|
||||
|
||||
|
||||
def test_allow_for_chat_session_applies_to_later_turns_in_only_that_chat():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store, selected_tools=["bash", "manage_skills"])
|
||||
grant = store.consume(
|
||||
pending.approval_id,
|
||||
decision="approve",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert grant is not None
|
||||
assert grant.scope is ToolApprovalScope.CHAT_SESSION
|
||||
assert grant.allow_remaining_actions is True
|
||||
assert grant.grants_chat_session is True
|
||||
assert grant.pending.selected_tools == ("bash", "manage_skills")
|
||||
assert grant.pending.continuation_query.startswith("inspect the project")
|
||||
|
||||
resolved_card = pending.public_payload()
|
||||
resolved_card["resolved"] = "approve"
|
||||
history = [
|
||||
ChatMessage(
|
||||
"assistant",
|
||||
"approval requested",
|
||||
{"tool_events": [{"ask_user": resolved_card}]},
|
||||
),
|
||||
ChatMessage("user", "continue the work"),
|
||||
]
|
||||
session = Session(
|
||||
id="session-1",
|
||||
name="Chat",
|
||||
endpoint_url="http://example.invalid",
|
||||
model="test",
|
||||
history=history,
|
||||
)
|
||||
|
||||
messages = session.get_context_messages()
|
||||
assert messages[-1]["metadata"][CHAT_SESSION_APPROVAL_CONTEXT_MARKER] is True
|
||||
assert history[-1].metadata is None
|
||||
|
||||
future_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||
future_turn.observe_messages(messages)
|
||||
assert future_turn.approval_gate_bypassed is True
|
||||
assert future_turn.decision_for("bash").allowed is True
|
||||
|
||||
# The persisted card is bound to its original chat id, so a fork/copy does
|
||||
# not inherit the grant merely by copying transcript metadata.
|
||||
other_session = Session(
|
||||
id="session-2",
|
||||
name="Fork",
|
||||
endpoint_url="http://example.invalid",
|
||||
model="test",
|
||||
history=history,
|
||||
)
|
||||
other_messages = other_session.get_context_messages()
|
||||
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
||||
other_messages[-1].get("metadata") or {}
|
||||
)
|
||||
other_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||
other_turn.observe_messages(other_messages)
|
||||
assert other_turn.decision_for("bash").allowed is False
|
||||
|
||||
|
||||
def test_deny_executes_nothing_and_grants_no_task_or_chat_scope():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store)
|
||||
|
||||
assert store.consume(
|
||||
pending.approval_id,
|
||||
decision="deny",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
) is None
|
||||
assert store.peek(pending.approval_id) is None
|
||||
|
||||
denied_card = pending.public_payload()
|
||||
denied_card["resolved"] = "deny"
|
||||
session = Session(
|
||||
id="session-1",
|
||||
name="Chat",
|
||||
endpoint_url="http://example.invalid",
|
||||
model="test",
|
||||
history=[
|
||||
ChatMessage(
|
||||
"assistant",
|
||||
"approval requested",
|
||||
{"tool_events": [{"ask_user": denied_card}]},
|
||||
),
|
||||
ChatMessage("user", "another request"),
|
||||
],
|
||||
)
|
||||
messages = session.get_context_messages()
|
||||
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
||||
messages[-1].get("metadata") or {}
|
||||
)
|
||||
|
||||
|
||||
def test_private_continuation_state_is_canonical_bounded_and_digest_bound():
|
||||
selected_tools = ["manage_skills", "bash", "manage_skills", "", 7]
|
||||
selected_tools.extend(f"tool_{index:04d}" for index in range(600))
|
||||
selected_tools.append("x" * 513)
|
||||
pending = _pending(
|
||||
ToolApprovalStore(),
|
||||
selected_tools=selected_tools,
|
||||
continuation_query=" " + ("original request " * 500),
|
||||
)
|
||||
assert pending.selected_tools[:2] == ("bash", "manage_skills")
|
||||
assert len(pending.selected_tools) == 512
|
||||
assert all(len(name) <= 512 for name in pending.selected_tools)
|
||||
assert "x" * 513 not in pending.selected_tools
|
||||
assert pending.continuation_query.startswith("original request")
|
||||
assert len(pending.continuation_query) == 4000
|
||||
|
||||
tampered = replace(
|
||||
pending,
|
||||
selected_tools=("bash", "manage_skills", "send_email"),
|
||||
continuation_query="different request",
|
||||
)
|
||||
grant = ExactToolApproval(tampered)
|
||||
assert grant.matches(
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
tool_name="bash",
|
||||
content="printf exact",
|
||||
workspace=None,
|
||||
) is False
|
||||
|
||||
|
||||
def test_consumed_card_resolution_updates_memory_and_persisted_metadata(monkeypatch):
|
||||
from routes import chat_routes
|
||||
|
||||
ask_user = {
|
||||
"kind": "tool_approval",
|
||||
"approval_id": "approval-1",
|
||||
"session_id": "session-1",
|
||||
}
|
||||
metadata = {
|
||||
"_db_id": "message-1",
|
||||
"tool_events": [{"ask_user": ask_user}],
|
||||
}
|
||||
sess = SimpleNamespace(
|
||||
id="session-1",
|
||||
history=[SimpleNamespace(metadata=metadata)],
|
||||
)
|
||||
db_message = SimpleNamespace(meta_data=None)
|
||||
|
||||
class Column:
|
||||
def __eq__(self, value):
|
||||
return value
|
||||
|
||||
class FakeDBMessage:
|
||||
id = Column()
|
||||
session_id = Column()
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *conditions):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return db_message
|
||||
|
||||
class FakeDB:
|
||||
committed = False
|
||||
rolled_back = False
|
||||
closed = False
|
||||
|
||||
def query(self, model):
|
||||
assert model is FakeDBMessage
|
||||
return FakeQuery()
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
self.rolled_back = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
db = FakeDB()
|
||||
monkeypatch.setattr(chat_routes, "DBChatMessage", FakeDBMessage)
|
||||
monkeypatch.setattr(chat_routes, "SessionLocal", lambda: db)
|
||||
|
||||
assert chat_routes._mark_tool_approval_resolved(
|
||||
sess,
|
||||
"approval-1",
|
||||
"approve",
|
||||
) is True
|
||||
assert ask_user["resolved"] == "approve"
|
||||
persisted = json.loads(db_message.meta_data)
|
||||
assert persisted["tool_events"][0]["ask_user"]["resolved"] == "approve"
|
||||
assert "_db_id" not in persisted
|
||||
assert db.committed is True
|
||||
assert db.rolled_back is False
|
||||
assert db.closed is True
|
||||
|
||||
|
||||
def test_deny_resolution_stream_is_control_only():
|
||||
from routes.chat_routes import _tool_approval_resolution_stream
|
||||
|
||||
async def collect():
|
||||
return [chunk async for chunk in _tool_approval_resolution_stream("deny")]
|
||||
|
||||
chunks = asyncio.run(collect())
|
||||
assert chunks[-1] == "data: [DONE]\n\n"
|
||||
event = json.loads(chunks[0][len("data: "):])
|
||||
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
|
||||
assert "Denied the" not in "".join(chunks)
|
||||
|
||||
|
||||
def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
route = (root / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
helpers = (root / "routes/chat_helpers.py").read_text(encoding="utf-8")
|
||||
agent = (root / "src/agent_loop.py").read_text(encoding="utf-8")
|
||||
frontend = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
app = (root / "static/app.js").read_text(encoding="utf-8")
|
||||
index = (root / "static/index.html").read_text(encoding="utf-8")
|
||||
approvals = (root / "src/tool_approvals.py").read_text(encoding="utf-8")
|
||||
capabilities = (root / "src/tool_capabilities.py").read_text(encoding="utf-8")
|
||||
models = (root / "core/models.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'decision not in {"approve", "approve_task", "deny"}' in route
|
||||
assert "set(pending_tool_approval.selected_tools)" in route
|
||||
assert "pending_tool_approval.continuation_query" in route
|
||||
assert "persist_user_message=not tool_approval_continuation" in route
|
||||
assert "_mark_tool_approval_resolved(" in route
|
||||
assert "_tool_approval_resolution_stream(decision)" in route
|
||||
assert "Approved the exact" not in route
|
||||
assert "Denied the" not in route
|
||||
assert "continuation_context_message: str | None = None" in helpers
|
||||
assert "persist_user_message: bool = True" in helpers
|
||||
assert "_without_latest_matching_user_message(" not in helpers
|
||||
assert "selected_tools=approval_selected_tools" in agent
|
||||
assert "continuation_query=_retrieval_query or _last_user" in agent
|
||||
assert "approval_gate_bypassed=bool(" in agent
|
||||
assert "['approve', 'approve_task', 'deny']" in frontend
|
||||
assert "input.value = label" not in frontend
|
||||
assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
|
||||
assert "const skipBubble = _hideUserBubble || !!approvalForSend;" in frontend
|
||||
assert "fd.append('message', approvalForSend ? '' : _finalMsgWithInject);" in frontend
|
||||
assert "json.type === 'tool_approval_resolved'" in frontend
|
||||
assert "if (aq.resolved) return null;" in renderer
|
||||
assert "ev.ask_user && !ev.ask_user.resolved" in renderer
|
||||
assert '"label": "Allow once"' not in approvals
|
||||
assert '"label": "Allow for this task"' in approvals
|
||||
assert '"label": "Allow for this chat session"' in approvals
|
||||
assert "scope_for_decision(normalized_decision)" in approvals
|
||||
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in capabilities
|
||||
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in models
|
||||
|
||||
version = "20260819approvalcontrol1"
|
||||
assert f"chat.js?v={version}" in app
|
||||
assert f"chat.js?v={version}" in index
|
||||
assert f"chatRenderer.js?v={version}" in frontend
|
||||
assert f"chatRenderer.js?v={version}" in app
|
||||
assert f"chatRenderer.js?v={version}" in index
|
||||
Reference in New Issue
Block a user