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