mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-22 20:55:29 -04:00
fix ask-user choices across reloads (#4669)
This commit is contained in:
+24
-6
@@ -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
|
||||
|
||||
+1
-1
@@ -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 <name>`. Use `open_email_reply <uid> <folder> 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 <uid> <folder> reply <body text>`. 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.",
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
|
||||
+13
-142
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user