mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-12 03:02:21 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
166a6e9cf6 |
+10
-28
@@ -30,20 +30,11 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
|||||||
"""
|
"""
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
try:
|
json.dump(data, f, indent=indent)
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
f.flush()
|
||||||
json.dump(data, f, indent=indent)
|
os.fsync(f.fileno())
|
||||||
f.flush()
|
os.replace(tmp, path)
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp, path)
|
|
||||||
finally:
|
|
||||||
# Directly unlink to avoid a check-then-act race condition.
|
|
||||||
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
|
||||||
try:
|
|
||||||
os.unlink(tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_text(path: str, text: str) -> None:
|
def atomic_write_text(path: str, text: str) -> None:
|
||||||
@@ -51,17 +42,8 @@ def atomic_write_text(path: str, text: str) -> None:
|
|||||||
raise TypeError("atomic_write_text expects a string")
|
raise TypeError("atomic_write_text expects a string")
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
try:
|
f.write(text)
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
f.flush()
|
||||||
f.write(text)
|
os.fsync(f.fileno())
|
||||||
f.flush()
|
os.replace(tmp, path)
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp, path)
|
|
||||||
finally:
|
|
||||||
# Directly unlink to avoid a check-then-act race condition.
|
|
||||||
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
|
||||||
try:
|
|
||||||
os.unlink(tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|||||||
+1
-51
@@ -8,11 +8,6 @@ These are simple datacontainers. All persistence is handled by SessionManager.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
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:
|
if TYPE_CHECKING:
|
||||||
from .session_manager import SessionManager
|
from .session_manager import SessionManager
|
||||||
|
|
||||||
@@ -36,35 +31,6 @@ set_session_manager = set_session_manager_instance
|
|||||||
get_session_manager = get_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
|
@dataclass
|
||||||
class ChatMessage:
|
class ChatMessage:
|
||||||
"""A single chat message."""
|
"""A single chat message."""
|
||||||
@@ -150,27 +116,11 @@ class Session:
|
|||||||
the model. Display/history-load paths use the raw ``history`` and are
|
the model. Display/history-load paths use the raw ``history`` and are
|
||||||
unaffected.
|
unaffected.
|
||||||
"""
|
"""
|
||||||
messages = [
|
return [
|
||||||
msg.to_dict()
|
msg.to_dict()
|
||||||
for msg in self.history
|
for msg in self.history
|
||||||
if (msg.metadata or {}).get("source") != "slash"
|
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):
|
def get(self, key: str, default=None):
|
||||||
"""Dict-like access for compatibility."""
|
"""Dict-like access for compatibility."""
|
||||||
|
|||||||
+5
-20
@@ -624,8 +624,6 @@ async def build_chat_context(
|
|||||||
agent_mode: bool = False,
|
agent_mode: bool = False,
|
||||||
allow_tool_preprocessing: bool = True,
|
allow_tool_preprocessing: bool = True,
|
||||||
defer_context_shaping: bool = False,
|
defer_context_shaping: bool = False,
|
||||||
continuation_context_message: str | None = None,
|
|
||||||
persist_user_message: bool = True,
|
|
||||||
) -> ChatContext:
|
) -> ChatContext:
|
||||||
"""Build the full context (preface + messages) for an LLM call.
|
"""Build the full context (preface + messages) for an LLM call.
|
||||||
|
|
||||||
@@ -649,14 +647,14 @@ async def build_chat_context(
|
|||||||
# Add user message to history. Nobody/incognito uses a request-local
|
# Add user message to history. Nobody/incognito uses a request-local
|
||||||
# transcript store instead of session history so stale saved chats cannot
|
# transcript store instead of session history so stale saved chats cannot
|
||||||
# bleed into context and the turn is not persisted.
|
# bleed into context and the turn is not persisted.
|
||||||
if persist_user_message and incognito:
|
if incognito:
|
||||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||||
elif persist_user_message:
|
else:
|
||||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||||
|
|
||||||
# Fire events
|
# Fire events
|
||||||
if persist_user_message and not incognito:
|
if not incognito:
|
||||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||||
|
|
||||||
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
||||||
@@ -668,12 +666,7 @@ async def build_chat_context(
|
|||||||
getattr(chat_handler, "upload_handler", None),
|
getattr(chat_handler, "upload_handler", None),
|
||||||
getattr(sess, "owner", None),
|
getattr(sess, "owner", None),
|
||||||
)
|
)
|
||||||
context_message = (
|
casual_low_signal = _is_casual_low_signal(message)
|
||||||
str(continuation_context_message).strip()
|
|
||||||
if continuation_context_message
|
|
||||||
else message
|
|
||||||
)
|
|
||||||
casual_low_signal = _is_casual_low_signal(context_message)
|
|
||||||
|
|
||||||
# Memory enabled?
|
# Memory enabled?
|
||||||
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
||||||
@@ -710,15 +703,7 @@ async def build_chat_context(
|
|||||||
# Build context preface
|
# Build context preface
|
||||||
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
||||||
# the sync path uses text_for_context.
|
# the sync path uses text_for_context.
|
||||||
_ctx_msg = (
|
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
|
||||||
context_message
|
|
||||||
if continuation_context_message
|
|
||||||
else (
|
|
||||||
preprocessed.enhanced_message
|
|
||||||
if use_enhanced_message
|
|
||||||
else preprocessed.text_for_context
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_preface_kwargs = dict(
|
_preface_kwargs = dict(
|
||||||
message=_ctx_msg,
|
message=_ctx_msg,
|
||||||
session=sess,
|
session=sess,
|
||||||
|
|||||||
+41
-140
@@ -89,65 +89,6 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
|||||||
return None
|
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(
|
def _chat_candidate_request_factory(
|
||||||
messages,
|
messages,
|
||||||
fallback_context_length: int = 0,
|
fallback_context_length: int = 0,
|
||||||
@@ -976,7 +917,6 @@ def setup_chat_routes(
|
|||||||
exact_tool_approval = None
|
exact_tool_approval = None
|
||||||
pending_tool_approval = None
|
pending_tool_approval = None
|
||||||
retired_tool_approval_taint = False
|
retired_tool_approval_taint = False
|
||||||
external_untrusted_context_seen = False
|
|
||||||
tool_approval_continuation = False
|
tool_approval_continuation = False
|
||||||
# Workspace: confine the agent's file/shell tools to this folder.
|
# Workspace: confine the agent's file/shell tools to this folder.
|
||||||
workspace, workspace_rejected = _resolve_request_workspace(
|
workspace, workspace_rejected = _resolve_request_workspace(
|
||||||
@@ -1110,14 +1050,14 @@ def setup_chat_routes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Attachment-only sends and approval controls may omit message text.
|
# Attachment-only sends: skip the message-required check when the
|
||||||
|
# user has attached one or more files (the attachment IS the action).
|
||||||
_has_atts = (
|
_has_atts = (
|
||||||
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
|
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
|
||||||
or bool(form_data.get("attachments"))
|
or bool(form_data.get("attachments"))
|
||||||
)
|
)
|
||||||
message, session = coerce_message_and_session(
|
message, session = coerce_message_and_session(
|
||||||
body, message, session, session_manager,
|
body, message, session, session_manager, allow_empty=_has_atts,
|
||||||
allow_empty=(_has_atts or bool(tool_approval_id)),
|
|
||||||
)
|
)
|
||||||
# Verify ownership AFTER coerce (which may resolve a default session)
|
# Verify ownership AFTER coerce (which may resolve a default session)
|
||||||
# but BEFORE loading. Prevents cross-user session hijack.
|
# but BEFORE loading. Prevents cross-user session hijack.
|
||||||
@@ -1136,14 +1076,8 @@ def setup_chat_routes(
|
|||||||
409,
|
409,
|
||||||
"This tool approval is invalid, expired, or belongs to another thread.",
|
"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()
|
decision = str(tool_approval_decision or "").strip().lower()
|
||||||
if decision not in {"approve", "approve_task", "deny"}:
|
if decision not in {"approve", "deny"}:
|
||||||
raise HTTPException(400, "Invalid tool approval decision.")
|
raise HTTPException(400, "Invalid tool approval decision.")
|
||||||
if plan_mode:
|
if plan_mode:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1157,46 +1091,37 @@ def setup_chat_routes(
|
|||||||
session_id=session,
|
session_id=session,
|
||||||
)
|
)
|
||||||
tool_approval_continuation = True
|
tool_approval_continuation = True
|
||||||
if (
|
if decision == "approve" and exact_tool_approval is None:
|
||||||
decision in {"approve", "approve_task"}
|
|
||||||
and exact_tool_approval is None
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
409,
|
409,
|
||||||
"This tool approval could not be consumed.",
|
"This tool approval could not be consumed.",
|
||||||
)
|
)
|
||||||
if not _mark_tool_approval_resolved(
|
if decision == "approve":
|
||||||
sess,
|
message = (
|
||||||
tool_approval_id,
|
f"Approved the exact {pending_tool_approval.tool_name} action "
|
||||||
decision,
|
"shown above once."
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"Tool approval %s was consumed but its persisted card could not be marked resolved",
|
|
||||||
tool_approval_id,
|
|
||||||
)
|
)
|
||||||
if decision == "deny":
|
# The sealed server record, not mutable composer state,
|
||||||
return StreamingResponse(
|
# restores the original action workspace.
|
||||||
_tool_approval_resolution_stream(decision),
|
workspace = pending_tool_approval.workspace or None
|
||||||
media_type="text/event-stream",
|
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."
|
||||||
)
|
)
|
||||||
# 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"
|
chat_mode = "agent"
|
||||||
else:
|
else:
|
||||||
# A normal user message supersedes the card that was waiting
|
# A normal user message supersedes the card that was waiting
|
||||||
@@ -1207,9 +1132,6 @@ def setup_chat_routes(
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
session_id=session,
|
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)
|
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||||
@@ -1339,14 +1261,6 @@ def setup_chat_routes(
|
|||||||
agent_mode=(chat_mode == "agent"),
|
agent_mode=(chat_mode == "agent"),
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
defer_context_shaping=foreground_policy.enabled,
|
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
|
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||||
@@ -1749,11 +1663,7 @@ def setup_chat_routes(
|
|||||||
if foreground_policy.enabled
|
if foreground_policy.enabled
|
||||||
else ctx.messages
|
else ctx.messages
|
||||||
)
|
)
|
||||||
messages = (
|
messages = _ensure_current_request_is_latest_user(context_source, message)
|
||||||
list(context_source)
|
|
||||||
if tool_approval_continuation
|
|
||||||
else _ensure_current_request_is_latest_user(context_source, message)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Auto-compact notification
|
# Auto-compact notification
|
||||||
if ctx.was_compacted:
|
if ctx.was_compacted:
|
||||||
@@ -2224,10 +2134,7 @@ def setup_chat_routes(
|
|||||||
incognito=incognito, compare_mode=compare_mode,
|
incognito=incognito, compare_mode=compare_mode,
|
||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
allow_background_extraction=(
|
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||||
not tool_policy.block_all_tool_calls
|
|
||||||
and not tool_approval_continuation
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
_stream_set(session, status="done")
|
_stream_set(session, status="done")
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -2316,17 +2223,17 @@ def setup_chat_routes(
|
|||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
approved_plan=approved_plan or None,
|
approved_plan=approved_plan or None,
|
||||||
workspace=workspace 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,
|
forced_tools=_forced_tools,
|
||||||
uploaded_files=ctx.uploaded_files,
|
uploaded_files=ctx.uploaded_files,
|
||||||
defer_context_shaping=_foreground_policy.enabled,
|
defer_context_shaping=_foreground_policy.enabled,
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
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
|
||||||
|
)
|
||||||
|
),
|
||||||
exact_approval=exact_tool_approval,
|
exact_approval=exact_tool_approval,
|
||||||
):
|
):
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
@@ -2494,14 +2401,8 @@ def setup_chat_routes(
|
|||||||
agent_tool_calls=_agent_tool_calls,
|
agent_tool_calls=_agent_tool_calls,
|
||||||
skills_manager=skills_manager,
|
skills_manager=skills_manager,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
extract_skills=(
|
extract_skills=user_requested_agent,
|
||||||
user_requested_agent
|
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||||
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")
|
_stream_set(session, status="done")
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
@@ -1603,9 +1603,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
decision=decision,
|
decision=decision,
|
||||||
owner=user,
|
owner=user,
|
||||||
session_id=None,
|
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:
|
if decision == "approve" and exact_approval is None:
|
||||||
|
|||||||
@@ -327,12 +327,7 @@ def list_models():
|
|||||||
|
|
||||||
@app.post("/v1/images/generations")
|
@app.post("/v1/images/generations")
|
||||||
def generate(req: ImageRequest):
|
def generate(req: ImageRequest):
|
||||||
# The served model is the one this process was launched with. `req.model`
|
model = req.model or _args.model
|
||||||
# is accepted for OpenAI wire compatibility and ignored, matching
|
|
||||||
# scripts/diffusion_server.py: honouring it would let a caller point the
|
|
||||||
# generator at any local directory or Hugging Face repo, and the HiDream
|
|
||||||
# branch runs a python script from inside that directory.
|
|
||||||
model = _args.model
|
|
||||||
width, height = _size(req.size)
|
width, height = _size(req.size)
|
||||||
out_images = []
|
out_images = []
|
||||||
count = max(1, min(int(req.n or 1), 4))
|
count = max(1, min(int(req.n or 1), 4))
|
||||||
@@ -398,7 +393,7 @@ async def edit_image(
|
|||||||
size: str = Form("1024x1024"),
|
size: str = Form("1024x1024"),
|
||||||
response_format: str = Form("b64_json"),
|
response_format: str = Form("b64_json"),
|
||||||
):
|
):
|
||||||
active_model = _args.model # pinned; see generate()
|
active_model = model or _args.model
|
||||||
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||||
image_raw = await image.read()
|
image_raw = await image.read()
|
||||||
mask_raw = await mask.read() if mask is not None else None
|
mask_raw = await mask.read() if mask is not None else None
|
||||||
|
|||||||
+1
-16
@@ -3467,10 +3467,7 @@ async def stream_agent_loop(
|
|||||||
and exact_approval.pending.external_untrusted_context_seen
|
and exact_approval.pending.external_untrusted_context_seen
|
||||||
)
|
)
|
||||||
or messages_contain_external_untrusted_context(messages)
|
or messages_contain_external_untrusted_context(messages)
|
||||||
),
|
)
|
||||||
approval_gate_bypassed=bool(
|
|
||||||
exact_approval and exact_approval.allow_remaining_actions
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
mcp_mgr = get_mcp_manager()
|
mcp_mgr = get_mcp_manager()
|
||||||
prep_timings: Dict[str, float] = {}
|
prep_timings: Dict[str, float] = {}
|
||||||
@@ -5708,16 +5705,6 @@ async def stream_agent_loop(
|
|||||||
"policy": "exact_tool_approval_target",
|
"policy": "exact_tool_approval_target",
|
||||||
}
|
}
|
||||||
else:
|
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(
|
pending_approval = tool_approval_store.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -5745,8 +5732,6 @@ async def stream_agent_loop(
|
|||||||
external_untrusted_context_seen=(
|
external_untrusted_context_seen=(
|
||||||
run_security.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(
|
capabilities=capabilities_for_action(
|
||||||
block.tool_type,
|
block.tool_type,
|
||||||
block.content,
|
block.content,
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import os
|
|||||||
|
|
||||||
from src.runtime_paths import get_app_root, get_default_data_dir
|
from src.runtime_paths import get_app_root, get_default_data_dir
|
||||||
|
|
||||||
APP_VERSION = "1.0.3"
|
APP_VERSION = "1.0.2"
|
||||||
|
|
||||||
# Base paths
|
# Base paths
|
||||||
BASE_DIR = os.path.join(get_app_root(), "")
|
BASE_DIR = os.path.join(get_app_root(), "")
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"""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
|
|
||||||
+15
-136
@@ -1,9 +1,8 @@
|
|||||||
"""Opaque exact-action approvals with explicit task and chat scopes.
|
"""Opaque, exact, one-use approvals for tainted model-requested actions.
|
||||||
|
|
||||||
The server still seals and claims the first displayed action exactly once. The
|
The model may propose an action after untrusted context, but only the server
|
||||||
selected scope then bypasses only the automatic post-external-context approval
|
stores and later executes the exact approved tool input. Browser-visible
|
||||||
gate for the rest of the resumed task or chat session. Browser-visible fields
|
fields are display copies, never authority.
|
||||||
are display copies, never authority.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,13 +16,6 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
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
|
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||||
|
|
||||||
|
|
||||||
@@ -41,51 +33,6 @@ def _normalized_workspace(workspace: Any) -> str:
|
|||||||
return os.path.realpath(os.path.expanduser(workspace))
|
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:
|
def _canonical_digest(payload: dict[str, Any]) -> str:
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
payload,
|
payload,
|
||||||
@@ -113,8 +60,6 @@ def _binding_payload(
|
|||||||
document_version: Any,
|
document_version: Any,
|
||||||
document_digest: Any,
|
document_digest: Any,
|
||||||
external_untrusted_context_seen: bool,
|
external_untrusted_context_seen: bool,
|
||||||
selected_tools: Any,
|
|
||||||
continuation_query: Any,
|
|
||||||
effects: tuple[str, ...],
|
effects: tuple[str, ...],
|
||||||
result_integrity: str,
|
result_integrity: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -131,10 +76,6 @@ def _binding_payload(
|
|||||||
),
|
),
|
||||||
"document_digest": str(document_digest or "").strip().lower(),
|
"document_digest": str(document_digest or "").strip().lower(),
|
||||||
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
"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),
|
"effects": list(effects),
|
||||||
"result_integrity": str(result_integrity),
|
"result_integrity": str(result_integrity),
|
||||||
}
|
}
|
||||||
@@ -158,47 +99,26 @@ class PendingToolApproval:
|
|||||||
digest: str
|
digest: str
|
||||||
created_at: float
|
created_at: float
|
||||||
expires_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]:
|
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"kind": "tool_approval",
|
"kind": "tool_approval",
|
||||||
"approval_id": self.approval_id,
|
"approval_id": self.approval_id,
|
||||||
# The browser already owns this chat id. Persisting it with the
|
"question": "Allow this exact action once?",
|
||||||
# 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 (
|
"description": reason or (
|
||||||
"Untrusted context influenced this run, so continuing with "
|
"Untrusted context influenced this run, so this action needs "
|
||||||
"otherwise-gated actions needs your explicit approval."
|
"your explicit approval."
|
||||||
),
|
),
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{
|
||||||
"label": "Allow for this task",
|
"label": "Allow once",
|
||||||
"value": TASK_APPROVAL_DECISION,
|
"value": "approve",
|
||||||
"description": (
|
"description": "Execute only the sealed action shown here.",
|
||||||
"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",
|
"label": "Deny",
|
||||||
"value": DENY_APPROVAL_DECISION,
|
"value": "deny",
|
||||||
"description": "Do not execute the proposed action.",
|
"description": "Do not execute it.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"action": {
|
"action": {
|
||||||
@@ -217,23 +137,12 @@ class PendingToolApproval:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExactToolApproval:
|
class ExactToolApproval:
|
||||||
"""A consumed exact first action plus an explicit continuation scope."""
|
"""A consumed grant that the dispatcher can claim exactly once."""
|
||||||
|
|
||||||
pending: PendingToolApproval
|
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)
|
_claimed: bool = field(default=False, init=False, repr=False)
|
||||||
_lock: threading.Lock = field(default_factory=threading.Lock, 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(
|
def _matches_unlocked(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -266,8 +175,6 @@ class ExactToolApproval:
|
|||||||
external_untrusted_context_seen=(
|
external_untrusted_context_seen=(
|
||||||
self.pending.external_untrusted_context_seen
|
self.pending.external_untrusted_context_seen
|
||||||
),
|
),
|
||||||
selected_tools=self.pending.selected_tools,
|
|
||||||
continuation_query=self.pending.continuation_query,
|
|
||||||
effects=effects,
|
effects=effects,
|
||||||
result_integrity=result_integrity,
|
result_integrity=result_integrity,
|
||||||
)
|
)
|
||||||
@@ -348,8 +255,6 @@ class ToolApprovalStore:
|
|||||||
document_id: Any = None,
|
document_id: Any = None,
|
||||||
document_version: Any = None,
|
document_version: Any = None,
|
||||||
document_digest: Any = None,
|
document_digest: Any = None,
|
||||||
selected_tools: Any = None,
|
|
||||||
continuation_query: Any = None,
|
|
||||||
external_untrusted_context_seen: bool,
|
external_untrusted_context_seen: bool,
|
||||||
capabilities: ToolCapabilities,
|
capabilities: ToolCapabilities,
|
||||||
) -> PendingToolApproval:
|
) -> PendingToolApproval:
|
||||||
@@ -367,8 +272,6 @@ class ToolApprovalStore:
|
|||||||
document_version=document_version,
|
document_version=document_version,
|
||||||
document_digest=document_digest,
|
document_digest=document_digest,
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
selected_tools=selected_tools,
|
|
||||||
continuation_query=continuation_query,
|
|
||||||
effects=effects,
|
effects=effects,
|
||||||
result_integrity=result_integrity,
|
result_integrity=result_integrity,
|
||||||
)
|
)
|
||||||
@@ -391,8 +294,6 @@ class ToolApprovalStore:
|
|||||||
digest=_canonical_digest(payload),
|
digest=_canonical_digest(payload),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
expires_at=now + self._ttl_seconds,
|
expires_at=now + self._ttl_seconds,
|
||||||
selected_tools=tuple(payload["selected_tools"]),
|
|
||||||
continuation_query=payload["continuation_query"],
|
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_expired_locked(now)
|
self._purge_expired_locked(now)
|
||||||
@@ -430,17 +331,7 @@ class ToolApprovalStore:
|
|||||||
decision: Any,
|
decision: Any,
|
||||||
owner: Any,
|
owner: Any,
|
||||||
session_id: Any,
|
session_id: Any,
|
||||||
allow_continuation: bool = True,
|
|
||||||
) -> ExactToolApproval | None:
|
) -> 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()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_expired_locked(now)
|
self._purge_expired_locked(now)
|
||||||
@@ -457,21 +348,9 @@ class ToolApprovalStore:
|
|||||||
# another owner's pending action.
|
# another owner's pending action.
|
||||||
return None
|
return None
|
||||||
self._pending.pop(approval_key, None)
|
self._pending.pop(approval_key, None)
|
||||||
normalized_decision = str(decision or "").strip().lower()
|
if str(decision or "").strip().lower() != "approve":
|
||||||
scope = scope_for_decision(normalized_decision)
|
|
||||||
if scope is None:
|
|
||||||
return None
|
return None
|
||||||
if not allow_continuation:
|
return ExactToolApproval(pending)
|
||||||
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:
|
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from enum import Enum
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any, Iterable, Mapping
|
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
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
|
||||||
@@ -619,30 +618,13 @@ class ToolRunSecurityContext:
|
|||||||
external_untrusted_context_seen: bool = False
|
external_untrusted_context_seen: bool = False
|
||||||
external_sources: list[str] = field(default_factory=list)
|
external_sources: list[str] = field(default_factory=list)
|
||||||
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
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:
|
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
"""Promote any server-labelled untrusted prompt context into the gate."""
|
||||||
message_list = list(messages or ())
|
if messages_contain_external_untrusted_context(messages):
|
||||||
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
|
self.external_untrusted_context_seen = True
|
||||||
|
|
||||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
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:
|
if not self.external_untrusted_context_seen:
|
||||||
return ToolGateDecision(True)
|
return ToolGateDecision(True)
|
||||||
capabilities = capabilities_for_action(tool_name, content)
|
capabilities = capabilities_for_action(tool_name, content)
|
||||||
|
|||||||
+20
-31
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timedelta, timezone, tzinfo
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -65,31 +65,19 @@ def format_utc_offset(offset_min: Optional[int]) -> str:
|
|||||||
return f"{sign}{hours:02d}:{minutes:02d}"
|
return f"{sign}{hours:02d}:{minutes:02d}"
|
||||||
|
|
||||||
|
|
||||||
def _zoneinfo_from_name():
|
def user_timezone() -> timezone:
|
||||||
"""Return ZoneInfo for the request's IANA name, or None if missing/invalid."""
|
"""Return the best known user timezone as a fixed-offset tzinfo."""
|
||||||
name = get_user_tz_name()
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
return ZoneInfo(name)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def user_timezone() -> tzinfo:
|
|
||||||
"""Return the best known user timezone.
|
|
||||||
|
|
||||||
A valid IANA name wins over x-tz-offset. The offset is a fixed number and
|
|
||||||
can disagree with the name (wrong sign, stale client); the name carries DST.
|
|
||||||
"""
|
|
||||||
zone = _zoneinfo_from_name()
|
|
||||||
if zone is not None:
|
|
||||||
return zone
|
|
||||||
offset = get_user_tz_offset()
|
offset = get_user_tz_offset()
|
||||||
if offset is not None:
|
if offset is None:
|
||||||
return timezone(timedelta(minutes=offset))
|
name = get_user_tz_name()
|
||||||
return datetime.now().astimezone().tzinfo or timezone.utc
|
if name:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return ZoneInfo(name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||||
|
return timezone(timedelta(minutes=offset))
|
||||||
|
|
||||||
|
|
||||||
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
||||||
@@ -112,13 +100,14 @@ def _clock_label(dt: datetime) -> str:
|
|||||||
|
|
||||||
def timezone_label(dt: Optional[datetime] = None) -> str:
|
def timezone_label(dt: Optional[datetime] = None) -> str:
|
||||||
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
||||||
if dt is None:
|
offset = get_user_tz_offset()
|
||||||
dt = now_user_local()
|
if offset is None:
|
||||||
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
if dt is None:
|
||||||
|
dt = datetime.now().astimezone()
|
||||||
|
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
||||||
offset_label = f"UTC{format_utc_offset(offset)}"
|
offset_label = f"UTC{format_utc_offset(offset)}"
|
||||||
if _zoneinfo_from_name() is not None:
|
name = get_user_tz_name()
|
||||||
return f"{get_user_tz_name()}, {offset_label}"
|
return f"{name}, {offset_label}" if name else offset_label
|
||||||
return offset_label
|
|
||||||
|
|
||||||
|
|
||||||
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
||||||
|
|||||||
+3
-3
@@ -10,8 +10,8 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
|||||||
import ragModule from './js/rag.js';
|
import ragModule from './js/rag.js';
|
||||||
import presetsModule from './js/presets.js';
|
import presetsModule from './js/presets.js';
|
||||||
import searchModule from './js/search.js';
|
import searchModule from './js/search.js';
|
||||||
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
|
import chatModule from './js/chat.js?v=20260815toolapproval4';
|
||||||
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
|
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||||
import documentModule from './js/document.js?v=20260815approvalsave1';
|
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||||
import searchChatModule from './js/search-chat.js';
|
import searchChatModule from './js/search-chat.js';
|
||||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
settleSessionHydration
|
settleSessionHydration
|
||||||
} from './js/startupShell.js';
|
} from './js/startupShell.js';
|
||||||
import markdownModule from './js/markdown.js';
|
import markdownModule from './js/markdown.js';
|
||||||
import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
|
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
|
||||||
import sessionModule from './js/sessions.js';
|
import sessionModule from './js/sessions.js';
|
||||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
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/tts-ai.js"></script>
|
||||||
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></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/gallery.js?v=20260708match1"></script>
|
||||||
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
|
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
|
||||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js?v=20260819approvalcontrol1"></script>
|
<script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></script>
|
<script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
|
|||||||
+16
-19
@@ -8,8 +8,8 @@
|
|||||||
import Storage from './storage.js';
|
import Storage from './storage.js';
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import chatRenderer from './chatRenderer.js?v=20260819approvalcontrol1';
|
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
|
||||||
import chatStream from './chatStream.js?v=20260819approvalcontrol1';
|
import chatStream from './chatStream.js?v=20260815approvalsave1';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
@@ -62,18 +62,20 @@ import { loadPanel } from './panels.js';
|
|||||||
let _contextHeaderBound = false;
|
let _contextHeaderBound = false;
|
||||||
let _pendingToolApproval = null;
|
let _pendingToolApproval = null;
|
||||||
|
|
||||||
function _submitToolApprovalWhenIdle(approvalId) {
|
function _submitToolApprovalWhenIdle(approvalId, label) {
|
||||||
if (
|
if (
|
||||||
!_pendingToolApproval
|
!_pendingToolApproval
|
||||||
|| _pendingToolApproval.approval_id !== approvalId
|
|| _pendingToolApproval.approval_id !== approvalId
|
||||||
) return;
|
) return;
|
||||||
if (isStreaming || _sendInFlight) {
|
if (isStreaming || _sendInFlight) {
|
||||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 120);
|
setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const input = document.getElementById('message');
|
const input = document.getElementById('message');
|
||||||
if (input) {
|
if (input) {
|
||||||
_pendingToolApproval.draft = input.value || '';
|
_pendingToolApproval.draft = input.value || '';
|
||||||
|
input.value = label;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
const sendButton = document.querySelector('.send-btn');
|
const sendButton = document.querySelector('.send-btn');
|
||||||
if (sendButton) sendButton.click();
|
if (sendButton) sendButton.click();
|
||||||
@@ -82,13 +84,16 @@ import { loadPanel } from './panels.js';
|
|||||||
document.addEventListener('odysseus:tool-approval', (event) => {
|
document.addEventListener('odysseus:tool-approval', (event) => {
|
||||||
const detail = event && event.detail ? event.detail : {};
|
const detail = event && event.detail ? event.detail : {};
|
||||||
const decision = String(detail.decision || '').toLowerCase();
|
const decision = String(detail.decision || '').toLowerCase();
|
||||||
if (!detail.approval_id || !['approve', 'approve_task', 'deny'].includes(decision)) return;
|
if (!detail.approval_id || !['approve', 'deny'].includes(decision)) return;
|
||||||
_pendingToolApproval = {
|
_pendingToolApproval = {
|
||||||
approval_id: String(detail.approval_id),
|
approval_id: String(detail.approval_id),
|
||||||
decision,
|
decision,
|
||||||
document_id: String(detail.document_id || ''),
|
document_id: String(detail.document_id || ''),
|
||||||
};
|
};
|
||||||
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
_submitToolApprovalWhenIdle(
|
||||||
|
_pendingToolApproval.approval_id,
|
||||||
|
detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function _fmtContextNumber(n) {
|
function _fmtContextNumber(n) {
|
||||||
@@ -1304,10 +1309,10 @@ import { loadPanel } from './panels.js';
|
|||||||
}
|
}
|
||||||
|
|
||||||
const el = uiModule.el;
|
const el = uiModule.el;
|
||||||
const msg = approvalForSend ? '' : el('message').value;
|
const msg = el('message').value;
|
||||||
// Allow empty text when a regen carries over the original message's
|
// Allow empty text when a regen carries over the original message's
|
||||||
// attachment ids — a photo-only message still has something to send.
|
// attachment ids — a photo-only message still has something to send.
|
||||||
if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||||
|
|
||||||
// --- Slash commands: execute directly without AI (no session needed) ---
|
// --- Slash commands: execute directly without AI (no session needed) ---
|
||||||
if (!approvalForSend && isCommand(msg.trim())) {
|
if (!approvalForSend && isCommand(msg.trim())) {
|
||||||
@@ -1585,7 +1590,7 @@ import { loadPanel } from './panels.js';
|
|||||||
|
|
||||||
const userDisplay = _displayOverride || msg;
|
const userDisplay = _displayOverride || msg;
|
||||||
_displayOverride = null;
|
_displayOverride = null;
|
||||||
const skipBubble = _hideUserBubble || !!approvalForSend;
|
const skipBubble = _hideUserBubble;
|
||||||
_hideUserBubble = false;
|
_hideUserBubble = false;
|
||||||
// Auto-recovery counter: carries across a turn's auto-continues, but resets
|
// 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).
|
// when the user genuinely sends a new message (so each task gets a fresh cap).
|
||||||
@@ -1828,7 +1833,7 @@ import { loadPanel } from './panels.js';
|
|||||||
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
|
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
|
||||||
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
fd.append('message', _finalMsgWithInject);
|
||||||
fd.append('session', streamSessionId);
|
fd.append('session', streamSessionId);
|
||||||
if (approvalForSend) {
|
if (approvalForSend) {
|
||||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||||
@@ -2868,7 +2873,7 @@ import { loadPanel } from './panels.js';
|
|||||||
if (spinner && spinner.element) spinner.destroy();
|
if (spinner && spinner.element) spinner.destroy();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
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') {
|
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') {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
clearProcessingProbe();
|
clearProcessingProbe();
|
||||||
clearFirstTokenWaitTimers();
|
clearFirstTokenWaitTimers();
|
||||||
@@ -2885,14 +2890,6 @@ import { loadPanel } from './panels.js';
|
|||||||
}
|
}
|
||||||
continue;
|
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) {
|
if (json.delta) {
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
_removeThinkingSpinner();
|
_removeThinkingSpinner();
|
||||||
|
|||||||
+15
-75
@@ -2327,42 +2327,6 @@ export function removeAskUserCards(root) {
|
|||||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
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.
|
* Render an ask_user payload as a durable choice card.
|
||||||
*
|
*
|
||||||
@@ -2372,15 +2336,11 @@ document.addEventListener('keydown', _handleAskUserShortcut);
|
|||||||
*/
|
*/
|
||||||
export function renderAskUserCard(payload, options) {
|
export function renderAskUserCard(payload, options) {
|
||||||
const aq = payload || {};
|
const aq = payload || {};
|
||||||
if (aq.resolved) return null;
|
|
||||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||||
const renderOptions = options || {};
|
const chatBox = document.getElementById('chat-history');
|
||||||
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;
|
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||||
|
|
||||||
|
const renderOptions = options || {};
|
||||||
removeAskUserCards(chatBox);
|
removeAskUserCards(chatBox);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -2389,7 +2349,6 @@ export function renderAskUserCard(payload, options) {
|
|||||||
card.tabIndex = -1;
|
card.tabIndex = -1;
|
||||||
const multi = !!aq.multi;
|
const multi = !!aq.multi;
|
||||||
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
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 emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||||
|
|
||||||
const head = document.createElement('div');
|
const head = document.createElement('div');
|
||||||
@@ -2398,6 +2357,7 @@ export function renderAskUserCard(payload, options) {
|
|||||||
closeBtn.type = 'button';
|
closeBtn.type = 'button';
|
||||||
closeBtn.className = 'modal-close ask-user-close';
|
closeBtn.className = 'modal-close ask-user-close';
|
||||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||||
|
closeBtn.textContent = '×';
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener('click', () => {
|
||||||
card.remove();
|
card.remove();
|
||||||
const input = uiModule.el('message');
|
const input = uiModule.el('message');
|
||||||
@@ -2440,17 +2400,6 @@ export function renderAskUserCard(payload, options) {
|
|||||||
|
|
||||||
const send = (text) => {
|
const send = (text) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (onSubmit) {
|
|
||||||
const accepted = onSubmit({
|
|
||||||
kind: 'answer',
|
|
||||||
text,
|
|
||||||
label: text,
|
|
||||||
payload: aq,
|
|
||||||
card,
|
|
||||||
});
|
|
||||||
if (accepted !== false) card.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
card.remove();
|
card.remove();
|
||||||
const input = uiModule.el('message');
|
const input = uiModule.el('message');
|
||||||
if (input) input.value = text;
|
if (input) input.value = text;
|
||||||
@@ -2484,26 +2433,17 @@ export function renderAskUserCard(payload, options) {
|
|||||||
row.type = 'button';
|
row.type = 'button';
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
if (isToolApproval) {
|
if (isToolApproval) {
|
||||||
const detail = {
|
card.remove();
|
||||||
approval_id: aq.approval_id,
|
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
|
||||||
decision: String((opt && opt.value) || '').toLowerCase(),
|
detail: {
|
||||||
label,
|
approval_id: aq.approval_id,
|
||||||
document_id: aq.action && aq.action.document_id
|
decision: String((opt && opt.value) || '').toLowerCase(),
|
||||||
? String(aq.action.document_id)
|
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 {
|
} else {
|
||||||
send(label);
|
send(label);
|
||||||
}
|
}
|
||||||
@@ -2688,7 +2628,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||||
let outHtml = '';
|
let outHtml = '';
|
||||||
if (ev.output && ev.output.trim()) {
|
if (ev.output && ev.output.trim()) {
|
||||||
|
|||||||
@@ -9,35 +9,6 @@ import markdownModule from './markdown.js';
|
|||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import documentModule from './document.js?v=20260815approvalsave1';
|
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.
|
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||||
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
|
* 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 { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
|
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
||||||
import {
|
import {
|
||||||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||||||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||||||
@@ -1006,16 +1006,11 @@ async function _executeCompare(message) {
|
|||||||
console.error('Compare error:', err);
|
console.error('Compare error:', err);
|
||||||
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
// A pane may have started its own ask_user/approval continuation while the
|
state._streaming = false;
|
||||||
// original all-pane Promise was settling. Keep Compare busy until every
|
_setSendBtn('send');
|
||||||
// pane-owned controller is gone instead of exposing a second broadcast send.
|
// Re-enable header buttons
|
||||||
const compareStillStreaming = state._abortControllers.some(Boolean);
|
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||||||
state._streaming = compareStillStreaming;
|
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
||||||
_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' : '';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1519,7 +1514,7 @@ async function showShufflePoolEditor() {
|
|||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerCompareActions({ stopAll, resetCompare });
|
registerCompareActions({ stopAll, resetCompare });
|
||||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
|
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
|
||||||
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+6
-189
@@ -1,7 +1,7 @@
|
|||||||
// compare/stream.js — SSE streaming to panes
|
// compare/stream.js — SSE streaming to panes
|
||||||
import state from './state.js';
|
import state from './state.js';
|
||||||
import { addFinishBadge } from './vote.js';
|
import { addFinishBadge } from './vote.js';
|
||||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
|
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
||||||
import markdownModule from '../markdown.js';
|
import markdownModule from '../markdown.js';
|
||||||
import spinnerModule from '../spinner.js';
|
import spinnerModule from '../spinner.js';
|
||||||
import uiModule from '../ui.js';
|
import uiModule from '../ui.js';
|
||||||
@@ -24,157 +24,11 @@ function _safeHttpHref(raw) {
|
|||||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||||
let _rerollPane = null;
|
let _rerollPane = null;
|
||||||
let _autoPreviewHtml = null;
|
let _autoPreviewHtml = null;
|
||||||
let _setSendBtn = null;
|
|
||||||
|
|
||||||
/** Register external functions that live in compare.js. */
|
/** Register external functions that live in compare.js. */
|
||||||
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
|
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
||||||
_rerollPane = rerollPane;
|
_rerollPane = rerollPane;
|
||||||
_autoPreviewHtml = autoPreviewHtml;
|
_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"). */
|
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
||||||
@@ -310,7 +164,6 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
let metrics = null;
|
let metrics = null;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let streamOk = false;
|
let streamOk = false;
|
||||||
let awaitingChoice = false;
|
|
||||||
let currentToolBlock = null; // track active agent tool block
|
let currentToolBlock = null; // track active agent tool block
|
||||||
// Idle timeout — abort only if no data is received for this many seconds.
|
// 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
|
// Long generations (SVG, big code) are fine as long as the stream stays
|
||||||
@@ -366,10 +219,6 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('message', message);
|
fd.append('message', message);
|
||||||
fd.append('session', sessionId);
|
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
|
// Compare mode determines what tools/features are enabled
|
||||||
const isAgent = state._compareMode === 'agent';
|
const isAgent = state._compareMode === 'agent';
|
||||||
@@ -473,36 +322,6 @@ 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) ──
|
// ── Tool start (bash, web search agent tool) ──
|
||||||
} else if (json.type === 'tool_start') {
|
} else if (json.type === 'tool_start') {
|
||||||
// Finalize any accumulated text before the tool block
|
// Finalize any accumulated text before the tool block
|
||||||
@@ -821,21 +640,19 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
// TTFT removed from the header per user request — just show total time.
|
// TTFT removed from the header per user request — just show total time.
|
||||||
_timerEl.textContent = _formatMs(_totalMs);
|
_timerEl.textContent = _formatMs(_totalMs);
|
||||||
}
|
}
|
||||||
if (state._abortControllers[paneIdx] === ac) {
|
state._abortControllers[paneIdx] = null;
|
||||||
state._abortControllers[paneIdx] = null;
|
|
||||||
}
|
|
||||||
// Hide stop button, show response action buttons
|
// Hide stop button, show response action buttons
|
||||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||||
if (_paneElFinal) {
|
if (_paneElFinal) {
|
||||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||||
if (!awaitingChoice && accumulated.trim()) {
|
if (accumulated.trim()) {
|
||||||
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state._paneMetrics[paneIdx] = metrics;
|
state._paneMetrics[paneIdx] = metrics;
|
||||||
state._paneElapsed[paneIdx] = _totalMs;
|
state._paneElapsed[paneIdx] = _totalMs;
|
||||||
if (!opts.skipBadge && !awaitingChoice) {
|
if (!opts.skipBadge) {
|
||||||
if (streamOk) {
|
if (streamOk) {
|
||||||
state._finishOrder++;
|
state._finishOrder++;
|
||||||
if (state._parallel) {
|
if (state._parallel) {
|
||||||
@@ -865,7 +682,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
||||||
if (streamOk && !awaitingChoice && state._expectedAnswer) {
|
if (streamOk && state._expectedAnswer) {
|
||||||
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
||||||
}
|
}
|
||||||
// Show copy/reroll buttons now that response exists
|
// Show copy/reroll buttons now that response exists
|
||||||
|
|||||||
+4
-45
@@ -123,7 +123,7 @@ def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# atomic_write_json — failure paths
|
# atomic_write_json — failure path: target preserved on serialization error.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
|
def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
|
||||||
target = tmp_path / "data.json"
|
target = tmp_path / "data.json"
|
||||||
@@ -136,26 +136,6 @@ def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
|
|||||||
atomic_write_json(str(target), {"bad": {1, 2, 3}})
|
atomic_write_json(str(target), {"bad": {1, 2, 3}})
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == before
|
assert target.read_text(encoding="utf-8") == before
|
||||||
# Temp file should be cleaned up
|
|
||||||
assert _tmp_siblings(tmp_path, "data.json") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_write_json_preserves_target_when_replace_fails(tmp_path, monkeypatch):
|
|
||||||
target = tmp_path / "data.json"
|
|
||||||
atomic_write_json(str(target), {"existing": "value"})
|
|
||||||
before = target.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def boom(src, dst):
|
|
||||||
raise PermissionError("replace failed")
|
|
||||||
|
|
||||||
monkeypatch.setattr(atomic_io.os, "replace", boom)
|
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="replace failed"):
|
|
||||||
atomic_write_json(str(target), {"new": "content"})
|
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == before
|
|
||||||
# Temp file should be cleaned up
|
|
||||||
assert _tmp_siblings(tmp_path, "data.json") == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -207,7 +187,7 @@ def test_atomic_write_text_rejects_non_string_before_tmp_file(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# atomic_write_text — failure paths
|
# atomic_write_text — failure path: target preserved when replace fails.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeypatch):
|
def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeypatch):
|
||||||
target = tmp_path / "note.txt"
|
target = tmp_path / "note.txt"
|
||||||
@@ -215,32 +195,11 @@ def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeyp
|
|||||||
before = target.read_text(encoding="utf-8")
|
before = target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
def boom(src, dst):
|
def boom(src, dst):
|
||||||
raise PermissionError("replace failed")
|
raise OSError("replace failed")
|
||||||
|
|
||||||
monkeypatch.setattr(atomic_io.os, "replace", boom)
|
monkeypatch.setattr(atomic_io.os, "replace", boom)
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="replace failed"):
|
with pytest.raises(OSError):
|
||||||
atomic_write_text(str(target), "new content that never lands")
|
atomic_write_text(str(target), "new content that never lands")
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == before
|
assert target.read_text(encoding="utf-8") == before
|
||||||
# Temp file should be cleaned up
|
|
||||||
assert _tmp_siblings(tmp_path, "note.txt") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_error_swallows_and_preserves_original_exception(tmp_path, monkeypatch):
|
|
||||||
target = tmp_path / "note.txt"
|
|
||||||
atomic_write_text(str(target), "original content")
|
|
||||||
|
|
||||||
def replace_boom(src, dst):
|
|
||||||
raise PermissionError("replace failed")
|
|
||||||
|
|
||||||
def unlink_boom(path):
|
|
||||||
raise OSError("unlink failed")
|
|
||||||
|
|
||||||
monkeypatch.setattr(atomic_io.os, "replace", replace_boom)
|
|
||||||
monkeypatch.setattr(atomic_io.os, "unlink", unlink_boom)
|
|
||||||
|
|
||||||
# If BOTH the replace fails AND the cleanup unlink fails,
|
|
||||||
# the original replace error should surface, completely swallowing the unlink error.
|
|
||||||
with pytest.raises(PermissionError, match="replace failed"):
|
|
||||||
atomic_write_text(str(target), "new content")
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
@pytest.mark.asyncio
|
||||||
async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
|
async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
|
||||||
from src.tool_capabilities import capabilities_for_action
|
from src.tool_capabilities import capabilities_for_action
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
@@ -368,13 +368,11 @@ async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = await endpoint(request)
|
response = await endpoint(request)
|
||||||
chunks = [chunk async for chunk in response.body_iterator]
|
async for _ in response.body_iterator:
|
||||||
|
pass
|
||||||
|
|
||||||
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 "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
|
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"""Pin the mlx_image_server caller-chosen-model + DNS-rebinding regressions.
|
|
||||||
|
|
||||||
Background: scripts/mlx_image_server.py used to resolve the model per request
|
|
||||||
(``req.model or _args.model``) instead of serving the model the process was
|
|
||||||
launched with. ``_is_hidream`` is a substring test and ``_snapshot_path``
|
|
||||||
accepts either a local directory or a Hugging Face repo id, so a caller could
|
|
||||||
name any directory / repo and the HiDream branch would then run
|
|
||||||
``<model>/scripts/hidream_o1/generate_hidream_o1_mlx.py`` under
|
|
||||||
``sys.executable``. The server has no auth, and the cookbook binds it to
|
|
||||||
``0.0.0.0`` whenever it is serving to a remote host, so that was reachable
|
|
||||||
code execution.
|
|
||||||
|
|
||||||
The fix pins both request paths to ``_args.model``, matching
|
|
||||||
scripts/diffusion_server.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import importlib.util
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "mlx_image_server.py"
|
|
||||||
|
|
||||||
_BASE_URL = "http://127.0.0.1"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_module():
|
|
||||||
"""Fresh import of the server module. Unlike diffusion_server it pulls in no
|
|
||||||
heavy runtime (mlx / torch imports all live inside the request handlers), so
|
|
||||||
the real module is imported rather than AST-extracted."""
|
|
||||||
spec = importlib.util.spec_from_file_location("mlx_image_server_under_test", _SCRIPT)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def server(monkeypatch):
|
|
||||||
"""Server module launched with a pinned, non-HiDream model."""
|
|
||||||
module = _load_module()
|
|
||||||
module._args = argparse.Namespace(
|
|
||||||
model="mlx-community/pinned-model",
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=8100,
|
|
||||||
steps=0,
|
|
||||||
width=512,
|
|
||||||
height=512,
|
|
||||||
base_model="",
|
|
||||||
lora_style="",
|
|
||||||
lora_paths=[],
|
|
||||||
lora_scales=[],
|
|
||||||
vlm_model="",
|
|
||||||
)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def _client(module):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
return TestClient(module.app, base_url=_BASE_URL)
|
|
||||||
|
|
||||||
|
|
||||||
def _plant_hidream_model_dir(tmp_path: Path) -> tuple[Path, Path]:
|
|
||||||
"""A directory that satisfies _is_hidream() and carries the script the
|
|
||||||
HiDream branch executes. The script writes a marker so the test can tell
|
|
||||||
whether it ran."""
|
|
||||||
model_dir = tmp_path / "hidream-planted"
|
|
||||||
generator = model_dir / "scripts" / "hidream_o1"
|
|
||||||
generator.mkdir(parents=True)
|
|
||||||
marker = model_dir / "executed.txt"
|
|
||||||
(generator / "generate_hidream_o1_mlx.py").write_text(
|
|
||||||
f"open({str(marker)!r}, 'w').write('ran')\n", encoding="utf-8"
|
|
||||||
)
|
|
||||||
return model_dir, marker
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_does_not_run_code_from_a_caller_named_model_dir(server, tmp_path):
|
|
||||||
"""The regression: naming a local directory as the model must not execute
|
|
||||||
the generator script inside it."""
|
|
||||||
model_dir, marker = _plant_hidream_model_dir(tmp_path)
|
|
||||||
|
|
||||||
_client(server).post(
|
|
||||||
"/v1/images/generations",
|
|
||||||
json={"model": str(model_dir), "prompt": "x", "size": "64x64"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not marker.exists(), (
|
|
||||||
"code inside the caller-named model directory ran; the request model "
|
|
||||||
"must not select the generator"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_does_not_fetch_a_caller_named_repo(server, monkeypatch):
|
|
||||||
"""The remote half of the same defect: the caller's string must never reach
|
|
||||||
the Hugging Face downloader."""
|
|
||||||
downloaded = []
|
|
||||||
stub = types.ModuleType("huggingface_hub")
|
|
||||||
stub.snapshot_download = lambda repo: downloaded.append(repo)
|
|
||||||
monkeypatch.setitem(sys.modules, "huggingface_hub", stub)
|
|
||||||
|
|
||||||
_client(server).post(
|
|
||||||
"/v1/images/generations",
|
|
||||||
json={"model": "attacker-account/hidream-anything", "prompt": "x"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "attacker-account/hidream-anything" not in downloaded
|
|
||||||
|
|
||||||
|
|
||||||
def test_edits_does_not_run_code_from_a_caller_named_model_dir(server, tmp_path):
|
|
||||||
"""/v1/images/edits resolved the model the same way and must be pinned too.
|
|
||||||
A "lama" name reaches the inpaint bridge, so the caller's model string is
|
|
||||||
what picks the branch here."""
|
|
||||||
model_dir = tmp_path / "lama-planted"
|
|
||||||
model_dir.mkdir()
|
|
||||||
called = []
|
|
||||||
server._run_inpaint_bridge = lambda *a, **kw: called.append(a)
|
|
||||||
server._run_ddcolor_bridge = lambda *a, **kw: called.append(a)
|
|
||||||
|
|
||||||
resp = _client(server).post(
|
|
||||||
"/v1/images/edits",
|
|
||||||
data={"model": str(model_dir), "prompt": "x"},
|
|
||||||
files={"image": ("i.png", b"not-a-real-png", "image/png")},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not called, "caller-supplied model selected the edit branch"
|
|
||||||
assert resp.status_code == 422, "pinned non-edit model should be refused"
|
|
||||||
|
|
||||||
|
|
||||||
def test_pinned_hidream_model_is_still_served(server, tmp_path, monkeypatch):
|
|
||||||
"""Behaviour preservation: pinning must not break a server that was actually
|
|
||||||
launched with a HiDream model."""
|
|
||||||
model_dir, marker = _plant_hidream_model_dir(tmp_path)
|
|
||||||
server._args.model = str(model_dir)
|
|
||||||
|
|
||||||
_client(server).post("/v1/images/generations", json={"model": "", "prompt": "x"})
|
|
||||||
|
|
||||||
assert marker.exists(), "the model this server was launched with must still run"
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""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]
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -28,53 +28,6 @@ def test_current_datetime_prompt_uses_browser_timezone():
|
|||||||
assert "Do not ask for an exact date" in prompt
|
assert "Do not ask for an exact date" in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_iana_name_wins_when_offset_disagrees():
|
|
||||||
"""A valid x-tz-name must beat a conflicting x-tz-offset (issue #6111)."""
|
|
||||||
clear_user_time_context()
|
|
||||||
set_user_tz_offset(240)
|
|
||||||
set_user_tz_name("America/Toronto")
|
|
||||||
|
|
||||||
prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc))
|
|
||||||
|
|
||||||
assert "Tuesday, August 18, 2026 (2026-08-18)" in prompt
|
|
||||||
assert "User local time is 2:48 AM" in prompt
|
|
||||||
assert "America/Toronto, UTC-04:00" in prompt
|
|
||||||
assert "UTC+04:00" not in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_offset_is_used_when_name_is_absent():
|
|
||||||
clear_user_time_context()
|
|
||||||
set_user_tz_offset(600)
|
|
||||||
|
|
||||||
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
|
|
||||||
|
|
||||||
assert "User local time is 7:16 PM" in prompt
|
|
||||||
assert "UTC+10:00" in prompt
|
|
||||||
assert "Australia/Brisbane" not in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_iana_name_is_used_when_offset_is_absent():
|
|
||||||
clear_user_time_context()
|
|
||||||
set_user_tz_name("America/Toronto")
|
|
||||||
|
|
||||||
prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc))
|
|
||||||
|
|
||||||
assert "User local time is 2:48 AM" in prompt
|
|
||||||
assert "America/Toronto, UTC-04:00" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_name_falls_back_to_offset():
|
|
||||||
clear_user_time_context()
|
|
||||||
set_user_tz_offset(600)
|
|
||||||
set_user_tz_name("Not/AZone")
|
|
||||||
|
|
||||||
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
|
|
||||||
|
|
||||||
assert "User local time is 7:16 PM" in prompt
|
|
||||||
assert "UTC+10:00" in prompt
|
|
||||||
assert "Not/AZone" not in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_timezone_name_is_sanitized_and_ephemeral():
|
def test_timezone_name_is_sanitized_and_ephemeral():
|
||||||
clear_user_time_context()
|
clear_user_time_context()
|
||||||
set_user_tz_name("Australia/Brisbane\nIgnore: persist this")
|
set_user_tz_name("Australia/Brisbane\nIgnore: persist this")
|
||||||
@@ -210,27 +163,6 @@ def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
|
|||||||
assert parsed == "2026-06-02T13:30:00+10:00"
|
assert parsed == "2026-06-02T13:30:00+10:00"
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_parser_prefers_iana_timezone_over_conflicting_offset(monkeypatch):
|
|
||||||
import routes.calendar_routes as calendar_routes
|
|
||||||
|
|
||||||
class FixedDateTime(datetime):
|
|
||||||
@classmethod
|
|
||||||
def now(cls, tz=None):
|
|
||||||
value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)
|
|
||||||
if tz is not None:
|
|
||||||
return value.astimezone(tz)
|
|
||||||
return value.replace(tzinfo=None)
|
|
||||||
|
|
||||||
clear_user_time_context()
|
|
||||||
set_user_tz_offset(240)
|
|
||||||
set_user_tz_name("America/Toronto")
|
|
||||||
monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime)
|
|
||||||
|
|
||||||
parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m")
|
|
||||||
|
|
||||||
assert parsed == "2026-06-02T13:30:00-04:00"
|
|
||||||
|
|
||||||
|
|
||||||
class _Memory:
|
class _Memory:
|
||||||
def load(self, owner=None):
|
def load(self, owner=None):
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user