From fd50561af636bfd88d97c0b61fe2c5d8ff7c18e8 Mon Sep 17 00:00:00 2001
From: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Date: Sat, 15 Aug 2026 05:51:54 +0000
Subject: [PATCH] fix(ui): complete exact approval continuation
---
static/app.js | 4 +--
static/index.html | 10 ++++----
static/js/chat.js | 30 +++++++++++++++++-----
static/js/chatRenderer.js | 9 ++++---
static/js/sessions.js | 2 +-
tests/test_agent_rounds_exhausted.py | 18 ++++++++++---
tests/test_external_context_tool_gate.py | 7 +++++
tests/test_startup_session_bootstrap_js.py | 2 +-
8 files changed, 59 insertions(+), 23 deletions(-)
diff --git a/static/app.js b/static/app.js
index 914d66cec..d232e78a2 100644
--- a/static/app.js
+++ b/static/app.js
@@ -10,7 +10,7 @@ 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=20260815toolapproval1';
+import chatModule from './js/chat.js?v=20260815toolapproval2';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
@@ -22,7 +22,7 @@ import {
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
-import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval1';
+import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval2';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
diff --git a/static/index.html b/static/index.html
index a1c937ea3..6c6c9b668 100644
--- a/static/index.html
+++ b/static/index.html
@@ -258,8 +258,8 @@
-
-
+
+
@@ -2534,10 +2534,10 @@
-
+
-
+
@@ -2545,7 +2545,7 @@
-
+
diff --git a/static/js/chat.js b/static/js/chat.js
index e7a9ff748..45a7ad9a1 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -8,7 +8,7 @@
import Storage from './storage.js';
import uiModule from './ui.js';
import sessionModule from './sessions.js';
-import chatRenderer from './chatRenderer.js?v=20260815toolapproval1';
+import chatRenderer from './chatRenderer.js?v=20260815toolapproval2';
import chatStream from './chatStream.js';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
@@ -61,6 +61,24 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
let _contextHeaderBound = false;
let _pendingToolApproval = null;
+ function _submitToolApprovalWhenIdle(approvalId, label) {
+ if (
+ !_pendingToolApproval
+ || _pendingToolApproval.approval_id !== approvalId
+ ) return;
+ if (isStreaming || _sendInFlight) {
+ setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
+ return;
+ }
+ const input = document.getElementById('message');
+ if (input) {
+ input.value = label;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ const sendButton = document.querySelector('.send-btn');
+ if (sendButton) sendButton.click();
+ }
+
document.addEventListener('odysseus:tool-approval', (event) => {
const detail = event && event.detail ? event.detail : {};
const decision = String(detail.decision || '').toLowerCase();
@@ -69,12 +87,10 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
approval_id: String(detail.approval_id),
decision,
};
- const input = document.getElementById('message');
- if (input) {
- input.value = detail.label || (decision === 'approve' ? 'Allow once' : 'Deny');
- }
- const sendButton = document.querySelector('.send-btn');
- if (sendButton) sendButton.click();
+ _submitToolApprovalWhenIdle(
+ _pendingToolApproval.approval_id,
+ detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
+ );
});
function _fmtContextNumber(n) {
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index ab62a6654..e00b6f369 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -2532,9 +2532,12 @@ export function addMessage(role, content, modelName, metadata) {
const toolRounds = Object.keys(toolsByRound).map(Number);
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
- for (let r = 0; r < maxRound; r++) {
- const roundNum = r + 1;
- const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
+ const firstRound = (toolsByRound[0] || []).length ? 0 : 1;
+ for (let roundNum = firstRound; roundNum <= maxRound; roundNum++) {
+ const r = roundNum - 1;
+ const txt = r >= 0
+ ? resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata)
+ : '';
if (txt) {
const wrap = document.createElement('div');
diff --git a/static/js/sessions.js b/static/js/sessions.js
index 9938c8ccc..03c9c4f5b 100644
--- a/static/js/sessions.js
+++ b/static/js/sessions.js
@@ -3,7 +3,7 @@
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
-import chatRenderer from './chatRenderer.js?v=20260815toolapproval1';
+import chatRenderer from './chatRenderer.js?v=20260815toolapproval2';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
import themeModule from './theme.js';
diff --git a/tests/test_agent_rounds_exhausted.py b/tests/test_agent_rounds_exhausted.py
index b79245170..7dc8e92a3 100644
--- a/tests/test_agent_rounds_exhausted.py
+++ b/tests/test_agent_rounds_exhausted.py
@@ -37,7 +37,7 @@ def _patch_common(monkeypatch):
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
async def _fake_exec(block, *a, **k):
- return ("bash", {"output": "ok", "exit_code": 0})
+ return (block.tool_type, {"output": "ok", "exit_code": 0})
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
@@ -58,8 +58,14 @@ def _run_loop(monkeypatch, round_text, max_rounds=2):
def test_emits_rounds_exhausted_when_cap_hit_mid_task(monkeypatch):
_patch_common(monkeypatch)
- # Every round returns a tool block -> never "done" -> loop exhausts the cap.
- events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=2)
+ # Use a system-owned interaction result so this remains a loop-control test:
+ # Bash output is workspace-derived and now correctly pauses for exact user
+ # approval before a later Bash call.
+ events = _run_loop(
+ monkeypatch,
+ '```update_plan\n{"plan":"- [ ] keep going"}\n```',
+ max_rounds=2,
+ )
assert any(e.get("type") == "rounds_exhausted" for e in events), events
@@ -84,7 +90,11 @@ def test_emits_intent_nudge_exhausted_when_cap_is_exhausted(monkeypatch):
def test_emits_loop_breaker_triggered_when_loop_breaker_trips(monkeypatch):
_patch_common(monkeypatch)
- events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=6)
+ events = _run_loop(
+ monkeypatch,
+ '```update_plan\n{"plan":"- [ ] keep going"}\n```',
+ max_rounds=6,
+ )
guard = next((e for e in events if e.get("type") == "loop_breaker_triggered"), None)
assert guard is not None, events
diff --git a/tests/test_external_context_tool_gate.py b/tests/test_external_context_tool_gate.py
index 808d3e6b0..a69e38303 100644
--- a/tests/test_external_context_tool_gate.py
+++ b/tests/test_external_context_tool_gate.py
@@ -854,6 +854,7 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
root = Path(__file__).parents[1]
chat = (root / "static/js/chat.js").read_text()
renderer = (root / "static/js/chatRenderer.js").read_text()
+ index = (root / "static/index.html").read_text()
assert "fd.append('tool_approval_id'" in chat
assert "fd.append('tool_approval_decision'" in chat
@@ -861,6 +862,12 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
assert "aq.kind === 'tool_approval'" in renderer
assert "aq.action.content" in renderer
assert "decision: String((opt && opt.value)" in renderer
+ assert "if (isStreaming || _sendInFlight)" in chat
+ assert "_submitToolApprovalWhenIdle" in chat
+ assert "input.dispatchEvent(new Event('input'" in chat
+ assert "const firstRound = (toolsByRound[0] || []).length ? 0 : 1" in renderer
+ assert index.count("app.js?v=20260815toolapproval2") == 2
+ assert "app.js?v=20260808startupshell1" not in index
def test_frontend_raw_fences_do_not_call_document_mutators():
diff --git a/tests/test_startup_session_bootstrap_js.py b/tests/test_startup_session_bootstrap_js.py
index 184a720e3..86bcae232 100644
--- a/tests/test_startup_session_bootstrap_js.py
+++ b/tests/test_startup_session_bootstrap_js.py
@@ -23,7 +23,7 @@ _IMPORT_REWRITES = {
"import uiModule, { autoResize, styledPrompt } from './ui.js';": (
"import uiModule, { autoResize, styledPrompt } from './ui.mjs';"
),
- "import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';": (
+ "import chatRenderer from './chatRenderer.js?v=20260815toolapproval2';": (
"import chatRenderer from './chatRenderer.mjs';"
),
"import { providerLogo } from './providers.js';": (