Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3e470b6
fix(agent): allow remaining actions for an approved task
RaresKeY Aug 19, 2026
5fe6f32
fix(agent): make approval continuation control-only
RaresKeY Aug 19, 2026
732d962
fix(ci): preserve approval taint and cache-buster contract
RaresKeY Aug 19, 2026
2aba259
fix(ui): keep tool approvals in current chat
RaresKeY Aug 19, 2026
0c29276
fix(ui): route tool approvals through chat submit
RaresKeY Aug 19, 2026
8b35b48
test(ui): pin approval submit routing
RaresKeY Aug 19, 2026
da2ddab
fix(agent): complete approval denial flow
RaresKeY Aug 19, 2026
77b673d
fix(ui): avoid duplicate ask-user close icon
RaresKeY Aug 19, 2026
949ffd3
fix(agent): retain approved tool in continuation set
RaresKeY Aug 19, 2026
fb3a401
revert(ui): keep PR 6113 scoped to approval continuation
RaresKeY Aug 19, 2026
8041b8c
fix(agent): add task and chat approval scopes
RaresKeY Aug 19, 2026
e6b64f5
fix(ui): prevent duplicate ask-user close icon
RaresKeY Aug 19, 2026
d8489db
feat(ui): add ask-user option shortcuts
RaresKeY Aug 19, 2026
d98da54
fix(compare): route ask-user choices per pane
RaresKeY Aug 19, 2026
8afb8bf
fix(agent): keep skill-test approvals to a single action
o3LL Aug 19, 2026
0f5c6f0
fix(ui): cache-bust every module the approval click depends on
o3LL Aug 19, 2026
8f26110
fix(ui): keep the digit shortcuts off tool approval cards
o3LL Aug 19, 2026
abc674b
fix(compare): restore a pane's ask_user card instead of dropping the …
o3LL Aug 19, 2026
978a686
refactor(chat): drop the unreachable deny branch
o3LL Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
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

Expand All @@ -31,6 +36,35 @@ def get_session_manager_instance() -> Optional["SessionManager"]:
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."""
Expand Down Expand Up @@ -116,11 +150,27 @@ def get_context_messages(self) -> List[Dict[str, Any]]:
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."""
Expand Down
25 changes: 20 additions & 5 deletions routes/chat_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading