fix(agent): allow remaining actions for an approved task (#6113)

* fix(agent): allow remaining actions for an approved task

* fix(agent): make approval continuation control-only

* fix(ci): preserve approval taint and cache-buster contract

* fix(ui): keep tool approvals in current chat

* fix(ui): route tool approvals through chat submit

* test(ui): pin approval submit routing

* fix(agent): complete approval denial flow

* fix(ui): avoid duplicate ask-user close icon

* fix(agent): retain approved tool in continuation set

* revert(ui): keep PR 6113 scoped to approval continuation

* fix(agent): add task and chat approval scopes

* fix(ui): prevent duplicate ask-user close icon

* feat(ui): add ask-user option shortcuts

* fix(compare): route ask-user choices per pane

* fix(agent): keep skill-test approvals to a single action

The chat card now reuses the wire value `approve` to mean chat-session
scope, and `consume()` returned `allow_remaining_actions=True` for it
unconditionally. The skill-test approval route was never updated: it still
sends `approve` meaning "once", and its button still reads "Allow once",
but the grant it got back set `approval_gate_bypassed` for the rest of the
resumed run. That surface wraps the skill body and every transcript byte
as untrusted context, so it is the last place where one click should
ungate everything that follows.

Give `consume()` an explicit `allow_continuation` flag. Callers that own a
resumable chat keep the scope the user picked; callers that do not — the
skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms
behind the sealed action, which is what their label promises.

* fix(ui): cache-bust every module the approval click depends on

chatStream.js, compare/index.js and compare/stream.js all changed
behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were
bumped. A returning browser therefore serves the new chat.js — which now
deliberately leaves the composer empty and clicks the send button — next to
the cached chatStream.js that has no interceptor. With an empty composer
that button sits at `data-mode="newchat"`, so the click opens a new chat
and the approval is dropped.

Bump the three, and version compare/stream.js's chatRenderer import to
match everyone else's so the ask_user keydown listener binds to one module
instance instead of two.

* fix(ui): keep the digit shortcuts off tool approval cards

With an approval card on screen and focus anywhere outside an input, a bare
`1` fired `approve_task` — the widest of the three grants — with no
modifier and no confirmation. That card is the one control whose entire
purpose is deliberate consent after untrusted context influenced the run,
and Deny sits at 3.

Label the card with its kind and skip the shortcut for approvals. Ordinary
ask_user questions keep 1-3.

* fix(compare): restore a pane's ask_user card instead of dropping the choice

renderAskUserCard removes the card as soon as onSubmit accepts, but the
resume loop gave up silently after 10s if the originating stream still owned
the pane. The user saw the click land, the card vanish, and nothing happen,
with no way to get it back.

Re-render the card on that deadline and say why. The reroll case still
returns without sending — that choice belongs to a stream that no longer
exists.

* refactor(chat): drop the unreachable deny branch

`if decision != "deny"` is always true — the deny path returns a
StreamingResponse a few lines above. It reads as if deny still falls
through to the toggle restore.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
RaresKeY
2026-08-19 08:01:34 -06:00
committed by GitHub
co-authored by Léo
parent 5c835014ac
commit 981652358e
20 changed files with 1361 additions and 119 deletions
+61
View File
@@ -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
+6 -4
View File
@@ -344,7 +344,7 @@ async def test_chat_stream_approval_restores_exact_shell_turn_toggle(monkeypatch
@pytest.mark.asyncio
async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
from src.tool_capabilities import capabilities_for_action
captured = {}
@@ -368,11 +368,13 @@ async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
)
response = await endpoint(request)
async for _ in response.body_iterator:
pass
chunks = [chunk async for chunk in response.body_iterator]
event = json.loads(chunks[0][len("data: "):])
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
assert chunks[-1] == "data: [DONE]\n\n"
assert "agent" not in captured
assert "exact_approval" not in captured
assert captured["agent_external_untrusted_context_seen"] is True
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
@@ -0,0 +1,103 @@
from pathlib import Path
def test_tool_approval_bypasses_polymorphic_send_button_actions():
root = Path(__file__).resolve().parents[1]
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
stream = (root / "static/js/chatStream.js").read_text(encoding="utf-8")
# chat.js still defers the sealed approval through a synthetic button click.
assert "if (sendButton) sendButton.click();" in chat
# The capture listener must intercept only that synthetic click and route it
# through the chat form submit path, before app.js can reinterpret an empty
# composer as New chat or Record voice.
assert "if (event.isTrusted) return;" in stream
assert "event.stopImmediatePropagation();" in stream
assert "chatForm.requestSubmit()" in stream
assert "sendButton.dataset.mode = ''" not in stream
def test_ask_user_close_button_uses_one_css_glyph():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
styles = (root / "static/style.css").read_text(encoding="utf-8")
assert "closeBtn.className = 'modal-close ask-user-close';" in renderer
assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" in renderer
assert "closeBtn.textContent = '×';" not in renderer
assert ".modal-close::before" in styles
def test_ask_user_number_shortcuts_reuse_option_click_path():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
start = renderer.index("function _handleAskUserShortcut(event)")
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
shortcut = renderer[start:end]
assert "if (!/^[1-3]$/.test(event.key)) return;" in shortcut
assert "event.repeat" in shortcut
assert "event.ctrlKey" in shortcut
assert "event.altKey" in shortcut
assert "event.metaKey" in shortcut
assert "event.shiftKey" in shortcut
assert "input, textarea, select, [contenteditable=\"true\"]" in shortcut
assert "card.querySelectorAll('.ask-user-option')[Number(event.key) - 1]" in shortcut
assert "event.preventDefault();" in shortcut
assert "option.click();" in shortcut
def test_digit_shortcuts_never_answer_a_tool_approval_card():
"""A stray digit must not grant a scope the user did not deliberately pick."""
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
start = renderer.index("function _handleAskUserShortcut(event)")
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
shortcut = renderer[start:end]
assert "if (card.dataset.askUserKind === 'tool_approval') return;" in shortcut
# The renderer has to label the card for that guard to ever fire.
assert (
"card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';"
in renderer
)
def test_ask_user_renderer_accepts_scoped_root_and_submit_callback():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
assert "const chatBox = renderOptions.root || document.getElementById('chat-history');" in renderer
assert "const onSubmit = typeof renderOptions.onSubmit === 'function'" in renderer
assert "kind: 'answer'" in renderer
assert "kind: 'tool_approval'" in renderer
assert "if (accepted !== false) card.remove();" in renderer
assert "document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }))" in renderer
def test_every_changed_approval_module_is_cache_busted_together():
"""A stale module here silently reinterprets the approval click.
chat.js leaves the composer empty and clicks the polymorphic send button,
so a browser that pairs the new chat.js with a cached chatStream.js has no
interceptor and lands on the New chat branch instead. The same holds for
the compare pane modules, which chatRenderer now shares a keydown listener
with.
"""
root = Path(__file__).resolve().parents[1]
version = "20260819approvalcontrol1"
index = (root / "static/index.html").read_text(encoding="utf-8")
app = (root / "static/app.js").read_text(encoding="utf-8")
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
compare_index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
assert f"chatStream.js?v={version}" in index
assert f"chatStream.js?v={version}" in chat
assert f"compare/index.js?v={version}" in app
assert f"stream.js?v={version}" in compare_index
# One chatRenderer instance, so the ask_user keydown listener binds once.
assert f"chatRenderer.js?v={version}" in compare_stream
@@ -0,0 +1,89 @@
"""Callers with no resumable chat keep the original one-use approval scope.
The chat card reuses the wire value ``approve`` for chat-session scope, so any
caller that still sends ``approve`` meaning "once" has to say so explicitly or
it silently inherits a run-long gate bypass.
"""
from pathlib import Path
from src.tool_approval_scopes import ToolApprovalScope
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
def _pending(store: ToolApprovalStore, *, session_id=""):
content = "printf exact"
return store.create(
owner="Alice",
session_id=session_id,
origin_run_id="run-1",
tool_name="bash",
content=content,
workspace=None,
external_untrusted_context_seen=True,
capabilities=capabilities_for_action("bash", content),
)
def test_single_action_grant_leaves_the_gate_armed_behind_the_sealed_action():
store = ToolApprovalStore()
pending = _pending(store)
grant = store.consume(
pending.approval_id,
decision="approve",
owner="alice",
session_id=None,
allow_continuation=False,
)
assert grant is not None
assert grant.scope is ToolApprovalScope.SINGLE_ACTION
assert grant.allow_remaining_actions is False
assert grant.grants_chat_session is False
resumed = ToolRunSecurityContext(
external_untrusted_context_seen=True,
approval_gate_bypassed=grant.allow_remaining_actions,
)
assert resumed.decision_for("bash").allowed is False
def test_chat_callers_still_get_the_continuation_scope_they_asked_for():
store = ToolApprovalStore()
pending = _pending(store, session_id="session-1")
grant = store.consume(
pending.approval_id,
decision="approve_task",
owner="alice",
session_id="session-1",
)
assert grant is not None
assert grant.scope is ToolApprovalScope.TASK
assert grant.allow_remaining_actions is True
def test_deny_is_unaffected_by_the_single_action_flag():
store = ToolApprovalStore()
pending = _pending(store)
assert store.consume(
pending.approval_id,
decision="deny",
owner="alice",
session_id=None,
allow_continuation=False,
) is None
assert store.peek(pending.approval_id) is None
def test_skill_test_approval_route_opts_out_of_continuation():
root = Path(__file__).resolve().parents[1]
skills = (root / "routes/skills_routes.py").read_text(encoding="utf-8")
approve_call = skills.index("exact_approval = tool_approval_store.consume(")
end = skills.index(")", skills.index("allow_continuation", approve_call))
assert "allow_continuation=False" in skills[approve_call:end]
+351
View File
@@ -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