From 91bba117c1b7012f8a8d673a220e695180ec20c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Pe=C3=B1a?= <55098657+lechuit@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:49:49 -0400 Subject: [PATCH] fix ask-user choices across reloads (#4669) --- src/agent_loop.py | 30 ++++-- src/tool_index.py | 2 +- src/tool_schemas.py | 10 +- static/js/chat.js | 155 +++-------------------------- static/js/chatRenderer.js | 152 ++++++++++++++++++++++++++++ tests/test_ask_user_persistence.py | 97 ++++++++++++++++++ tests/test_ask_user_tool.py | 13 +++ 7 files changed, 308 insertions(+), 151 deletions(-) create mode 100644 tests/test_ask_user_persistence.py diff --git a/src/agent_loop.py b/src/agent_loop.py index c90168324..c87da45b3 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -3215,9 +3215,12 @@ async def stream_agent_loop( f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n' ) - # ask_user: the agent posed a multiple-choice question. Emit it so the - # frontend renders clickable options, then end the turn (below) and - # wait — the user's pick becomes the next message. + # ask_user: remember the payload now, but emit the interactive event + # only *after* tool_output below. Emitting it before tool_output let + # the subsequent tool-card rewrite/scroll push the choices out of + # view. The payload is also copied into the persisted tool event so + # history reload can reconstruct an unanswered card. + _pending_ask_user_event = None if "ask_user" in result: # The question lives in the tool args. ChatMessage.to_dict() # replays only role+content to the model next turn — tool_event @@ -3232,9 +3235,7 @@ async def stream_agent_loop( _auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q full_response += _auq_delta yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n' - yield ( - f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n' - ) + _pending_ask_user_event = _auq _awaiting_user = True # update_plan: agent wrote back to the plan (ticked a step / revised). @@ -3289,6 +3290,10 @@ async def stream_agent_loop( # Emit tool_output (include ui_event data if present) tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")} + if _pending_ask_user_event: + # Keep enough state in the streamed tool result for alternate + # clients to render the prompt without depending on event order. + tool_output_data["ask_user"] = _pending_ask_user_event if "ui_event" in result: tool_output_data["ui_event"] = result["ui_event"] for k in ( @@ -3319,6 +3324,14 @@ async def stream_agent_loop( tool_output_data["diff"] = result["diff"] yield f'data: {json.dumps(tool_output_data)}\n\n' + # This must be the final UI event for ask_user: the frontend appends + # the card below the now-settled tool node and cancels any between- + # round spinner. The turn ends after the current tool batch. + if _pending_ask_user_event: + yield ( + f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n' + ) + # Native document tools open in the editor + carry the REAL doc id. # Emit a doc_update so the frontend opens/activates it and sends it # back as active_doc_id next turn (otherwise the agent can't "see" @@ -3376,6 +3389,11 @@ async def stream_agent_loop( # this the diff shows live but vanishes from saved history. if result.get("diff"): tool_event["diff"] = result["diff"] + if _pending_ask_user_event: + # Persist the structured question with the tool event. On a + # reload, chatRenderer can restore the card; a later user + # message removes it as answered. + tool_event["ask_user"] = _pending_ask_user_event tool_events.append(tool_event) if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS: _effectful_used = True diff --git a/src/tool_index.py b/src/tool_index.py index 64640bcef..73a07545e 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).", "send_to_session": "Send a message to another chat. Cross-chat communication.", "search_chats": "Search past session transcripts across chats.", - "ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.", + "ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.", "update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.", "ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel `. Use `open_email_reply reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply reply `. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.", "list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 1d64b5db6..5e2ad2045 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [ "question": {"type": "string", "description": "The question to ask. Be specific and self-contained."}, "options": { "type": "array", - "description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.", + "description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.", "items": { "type": "object", "properties": { @@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [ "required": ["label"] } }, - "multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."} + "multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."} }, "required": ["question", "options"] } @@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock content = json.dumps(args) elif tool_type == "ask_teacher": content = args.get("model", "auto") + "\n" + args.get("problem", "") + elif tool_type == "ask_user": + # Keep user-facing labels readable in the tool trace. The outer SSE + # JSON encoder will escape them for transport and JSON.parse restores + # them once; pre-escaping here caused literal ``\u00f1`` sequences to + # remain visible in the debug panel. + content = json.dumps(args, ensure_ascii=False) else: content = json.dumps(args) diff --git a/static/js/chat.js b/static/js/chat.js index c0b91f980..99a37203b 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js'; import chatStream from './chatStream.js'; import { addAITTSButton } from './tts-ai.js'; import markdownModule from './markdown.js'; -import { svgifyEmoji } from './markdown.js'; import spinnerModule from './spinner.js'; import presetsModule from './presets.js'; import fileHandlerModule from './fileHandler.js'; @@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } else if (json.type === 'ask_user') { if (_isBg) continue; // The agent posed a multiple-choice question; the turn has ended. - // Render clickable options at the bottom of the history. The - // user's pick is sent as the next message and the agent resumes. + // Use the shared history renderer so the live and restored + // versions have identical behavior. _cancelThinkingTimer(); _removeThinkingSpinner(); - const _aq = json.data || {}; - const _opts = Array.isArray(_aq.options) ? _aq.options : []; - if (_aq.question && _opts.length) { - const chatBox = document.getElementById('chat-history'); - // Drop any prior unanswered card so only the latest shows. - chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove()); - const card = document.createElement('div'); - card.className = 'ask-user-card'; - const multi = !!_aq.multi; - // Group the choices for assistive tech and label the group with - // the question (set below); make the card focusable so it can be - // moved to when it appears. - card.setAttribute('role', 'group'); - card.tabIndex = -1; - // Render any emoji in agent-supplied text through the app's - // pipeline: escape, then svgify to monochrome theme-tinted - // glyphs (project rule: never colorful emoji; respects the - // "Text-only Emojis" setting like the rest of the chat). - const _emo = (s) => svgifyEmoji(uiModule.esc(String(s))); - - // Header row holds the close (×) to dismiss the affordances and - // just type a reply instead. - const head = document.createElement('div'); - head.className = 'ask-user-head'; - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'modal-close ask-user-close'; - closeBtn.setAttribute('aria-label', 'Dismiss question'); - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', () => { - card.remove(); - const mi = uiModule.el('message'); - if (mi) mi.focus(); - }); - head.appendChild(closeBtn); - card.appendChild(head); - - // Render the question inside the card so it's self-contained: - // some models call ask_user without first narrating the question - // as assistant text, in which case the card would otherwise show - // bare options with no prompt. - if (_aq.question) { - const q = document.createElement('div'); - q.className = 'ask-user-question'; - q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; - q.innerHTML = _emo(_aq.question); - card.appendChild(q); - // Label the choice group with the question for screen readers. - card.setAttribute('aria-labelledby', q.id); - } else { - card.setAttribute('aria-label', 'Question from the assistant'); - } - - const list = document.createElement('div'); - list.className = 'ask-user-options'; - card.appendChild(list); - - const _send = (text) => { - if (!text) return; - // Remove the card once answered — the choice is sent as a - // normal user message (and the question persists as the - // assistant text above), so the affordances are spent. - card.remove(); - const mi = uiModule.el('message'); - if (mi) mi.value = text; - const sb = document.querySelector('.send-btn'); - if (sb) sb.click(); - }; - - _opts.forEach((opt, i) => { - const label = (opt && opt.label) ? String(opt.label) : String(opt || ''); - if (!label) return; - const descr = (opt && opt.description) ? String(opt.description) : ''; - const row = document.createElement(multi ? 'label' : 'button'); - row.className = 'ask-user-option'; - if (multi) { - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.value = label; - row.appendChild(cb); - } - const txt = document.createElement('span'); - txt.className = 'ask-user-option-label'; - txt.innerHTML = _emo(label); - row.appendChild(txt); - if (descr) { - const d = document.createElement('span'); - d.className = 'ask-user-option-desc'; - d.innerHTML = _emo(descr); - row.appendChild(d); - } - if (!multi) { - row.type = 'button'; - row.addEventListener('click', () => _send(label)); - } - list.appendChild(row); - }); - - // Free-text "Other" — type a custom answer + send (Enter or →). - const other = document.createElement('div'); - other.className = 'ask-user-other'; - const otherInput = document.createElement('input'); - otherInput.type = 'text'; - otherInput.className = 'styled-prompt-input ask-user-other-input'; - otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)'; - otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer'); - const otherSend = document.createElement('button'); - otherSend.type = 'button'; - otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send'; - otherSend.setAttribute('aria-label', 'Send answer'); - otherSend.textContent = multi ? 'Send selection' : 'Send'; - const _submit = () => { - const free = otherInput.value.trim(); - if (multi) { - const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value); - if (free) picked.push(free); - if (picked.length) _send(picked.join(', ')); - } else if (free) { - _send(free); - } - }; - otherSend.addEventListener('click', _submit); - otherInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { - e.preventDefault(); - _submit(); - } - }); - other.appendChild(otherInput); - other.appendChild(otherSend); - card.appendChild(other); - - chatBox.appendChild(card); - card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - // Move focus to the card so keyboard/screen-reader users land on - // the question + choices when it appears. - try { card.focus(); } catch (_) {} - } + chatRenderer.renderAskUserCard(json.data || {}); } else if (json.type === 'plan_update') { if (_isBg) continue; @@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!header) return; const node = header.closest('.agent-thread-node'); if (!node) return; - node.classList.toggle('open'); + const opened = node.classList.toggle('open'); + if (opened) { + // Expanding the final tool trace can push a pending ask_user card below + // the viewport. Keep that immediately-adjacent prompt visible. + const thread = node.closest('.agent-thread'); + const pendingCard = thread?.nextElementSibling; + if (pendingCard?.classList.contains('ask-user-card')) { + requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' })); + } + } }); window.__odysseus_thread_click_bound = true; } diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 253fa5724..cec28fc8c 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -3,6 +3,7 @@ import uiModule from './ui.js'; import markdownModule from './markdown.js'; +import { svgifyEmoji } from './markdown.js'; import { addAITTSButton } from './tts-ai.js'; import { providerLogo, providerLabel } from './providers.js'; import settingsModule from './settings.js'; @@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) { if (uiModule) uiModule.scrollHistory(); } +/** Remove any unanswered multiple-choice cards currently in the chat. */ +export function removeAskUserCards(root) { + const scope = root || document.getElementById('chat-history') || document; + scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove()); +} + +/** + * Render an ask_user payload as a durable choice card. + * + * This lives in the history renderer rather than the streaming loop so the + * same UI can be used both for a live SSE event and for a persisted tool event + * after a session reload. + */ +export function renderAskUserCard(payload, options) { + const aq = payload || {}; + const opts = Array.isArray(aq.options) ? aq.options : []; + const chatBox = document.getElementById('chat-history'); + if (!chatBox || !aq.question || opts.length < 2) return null; + + const renderOptions = options || {}; + removeAskUserCards(chatBox); + + const card = document.createElement('div'); + card.className = 'ask-user-card'; + card.setAttribute('role', 'group'); + card.tabIndex = -1; + const multi = !!aq.multi; + const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value))); + + const head = document.createElement('div'); + head.className = 'ask-user-head'; + const closeBtn = document.createElement('button'); + 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'); + if (input) input.focus(); + }); + head.appendChild(closeBtn); + card.appendChild(head); + + const question = document.createElement('div'); + question.className = 'ask-user-question'; + question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; + question.innerHTML = emojiText(aq.question); + card.appendChild(question); + card.setAttribute('aria-labelledby', question.id); + + const list = document.createElement('div'); + list.className = 'ask-user-options'; + card.appendChild(list); + + const send = (text) => { + if (!text) return; + card.remove(); + const input = uiModule.el('message'); + if (input) input.value = text; + const sendButton = document.querySelector('.send-btn'); + if (sendButton) sendButton.click(); + }; + + opts.forEach((opt) => { + const label = (opt && opt.label) ? String(opt.label) : String(opt || ''); + if (!label) return; + const description = (opt && opt.description) ? String(opt.description) : ''; + const row = document.createElement(multi ? 'label' : 'button'); + row.className = 'ask-user-option'; + if (multi) { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = label; + row.appendChild(checkbox); + } + const labelText = document.createElement('span'); + labelText.className = 'ask-user-option-label'; + labelText.innerHTML = emojiText(label); + row.appendChild(labelText); + if (description) { + const descriptionText = document.createElement('span'); + descriptionText.className = 'ask-user-option-desc'; + descriptionText.innerHTML = emojiText(description); + row.appendChild(descriptionText); + } + if (!multi) { + row.type = 'button'; + row.addEventListener('click', () => send(label)); + } + list.appendChild(row); + }); + + const other = document.createElement('div'); + other.className = 'ask-user-other'; + const otherInput = document.createElement('input'); + otherInput.type = 'text'; + otherInput.className = 'styled-prompt-input ask-user-other-input'; + otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)'; + otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer'); + const otherSend = document.createElement('button'); + otherSend.type = 'button'; + otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send'; + otherSend.setAttribute('aria-label', 'Send answer'); + otherSend.textContent = multi ? 'Send selection' : 'Send'; + const submit = () => { + const freeText = otherInput.value.trim(); + if (multi) { + const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value); + if (freeText) picked.push(freeText); + if (picked.length) send(picked.join(', ')); + } else if (freeText) { + send(freeText); + } + }; + otherSend.addEventListener('click', submit); + otherInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + submit(); + } + }); + other.appendChild(otherInput); + other.appendChild(otherSend); + card.appendChild(other); + + chatBox.appendChild(card); + if (renderOptions.scroll !== false) { + card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + if (renderOptions.focus !== false) { + try { card.focus(); } catch (_) {} + } + return card; +} + /** * Add a message to the chat history. */ @@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) { const box = document.getElementById('chat-history'); if (!box) { console.error('Chat history element not found'); return; } + // Loading a later user message means any earlier ask_user card was + // answered. This also removes the live card as soon as a manual reply is + // appended, even when the user did not click one of its buttons. + if (role === 'user') removeAskUserCards(box); + var esc = uiModule.esc; const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; @@ -1990,6 +2132,7 @@ export function addMessage(role, content, modelName, metadata) { if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { const roundTexts = metadata.round_texts || []; const toolEvents = metadata.tool_events; + let pendingAskUser = null; let lastWrap = null; let firstMsgAi = null; let lastMsgAi = null; @@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) { box.appendChild(threadWrap); } for (const ev of roundTools) { + if (ev.ask_user) pendingAskUser = ev.ask_user; const ok = (ev.exit_code === 0 || ev.exit_code == null); let outHtml = ''; if (ev.output && ev.output.trim()) { @@ -2129,6 +2273,12 @@ export function addMessage(role, content, modelName, metadata) { box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); } if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); + if (pendingAskUser) { + // Session history is rendered oldest-to-newest. A later user message + // removes this card; if there is none, the pending choice survives a + // refresh. Avoid stealing focus while the history is loading. + renderAskUserCard(pendingAskUser, { focus: false, scroll: false }); + } return lastWrap; } @@ -2461,6 +2611,8 @@ const chatRenderer = { copyMessageText, safeToolScreenshotSrc, safeDisplayImageSrc, + removeAskUserCards, + renderAskUserCard, buildSourcesBox, buildFindingsBox, appendReportButton, diff --git a/tests/test_ask_user_persistence.py b/tests/test_ask_user_persistence.py new file mode 100644 index 000000000..56dabd1b2 --- /dev/null +++ b/tests/test_ask_user_persistence.py @@ -0,0 +1,97 @@ +"""Regression coverage for durable ``ask_user`` choice cards. + +The live event must arrive after ``tool_output`` so the settled tool trace +cannot cover/push away the card. The same payload must be persisted inside +``tool_events`` so chat history can reconstruct it after a reload. +""" + +import asyncio +import json +from pathlib import Path + +import src.agent_loop as agent_loop + + +ROOT = Path(__file__).resolve().parents[1] + + +def _collect(gen): + async def _run(): + return [chunk async for chunk in gen] + + return asyncio.run(_run()) + + +def _events(chunks): + events = [] + for chunk in chunks: + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): + events.append(json.loads(chunk[6:])) + return events + + +def test_ask_user_is_emitted_last_and_persisted(monkeypatch): + payload = { + "question": "¿Qué proyecto prefieres?", + "options": [ + {"label": "Análisis de reseñas"}, + {"label": "Clasificación temática"}, + ], + "multi": False, + } + + monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False) + monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False) + monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False) + + async def fake_stream(_candidates, messages, **kwargs): + call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)} + yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n' + yield "data: [DONE]\n\n" + + async def fake_execute(block, *args, **kwargs): + parsed = json.loads(block.content) + return ( + "ask_user", + { + "ask_user": parsed, + "output": "Awaiting their selection.", + "exit_code": 0, + }, + ) + + monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False) + monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False) + + chunks = _collect( + agent_loop.stream_agent_loop( + "https://api.openai.com/v1", + "gpt-4o", + [{"role": "user", "content": "Ayúdame a elegir un proyecto."}], + relevant_tools={"ask_user"}, + _is_teacher_run=True, + ) + ) + events = _events(chunks) + + tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output") + ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user") + assert tool_output_index < ask_user_index + + tool_output = events[tool_output_index] + assert tool_output["ask_user"] == payload + assert "¿Qué proyecto prefieres?" in tool_output["command"] + assert "\\u00" not in tool_output["command"] + + metrics = next(event["data"] for event in events if event.get("type") == "metrics") + assert metrics["tool_events"][0]["ask_user"] == payload + + +def test_frontend_uses_one_renderer_for_live_and_restored_cards(): + chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8") + renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8") + + assert "chatRenderer.renderAskUserCard(json.data || {})" in chat + assert "export function renderAskUserCard" in renderer + assert "renderAskUserCard(pendingAskUser" in renderer + assert "if (role === 'user') removeAskUserCards(box)" in renderer diff --git a/tests/test_ask_user_tool.py b/tests/test_ask_user_tool.py index edcd14741..64c23e220 100644 --- a/tests/test_ask_user_tool.py +++ b/tests/test_ask_user_tool.py @@ -85,6 +85,19 @@ def test_serializer_round_trips_structured_args(): assert json.loads(block.content) == args +def test_serializer_keeps_unicode_readable_for_tool_trace(): + from src.tool_schemas import function_call_to_tool_block + + args = { + "question": "¿Qué proyecto prefieres?", + "options": [{"label": "Reseñas"}, {"label": "Clasificación"}], + } + block = function_call_to_tool_block("ask_user", json.dumps(args, ensure_ascii=False)) + assert "¿Qué proyecto prefieres?" in block.content + assert "Reseñas" in block.content + assert "\\u00" not in block.content + + def test_registered_everywhere(): # TOOL_TAGS gate (serializer rejects unknown tools) assert "ask_user" in TOOL_TAGS