mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-30 00:22:10 -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'
|
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
# ask_user: remember the payload now, but emit the interactive event
|
||||||
# frontend renders clickable options, then end the turn (below) and
|
# only *after* tool_output below. Emitting it before tool_output let
|
||||||
# wait — the user's pick becomes the next message.
|
# 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:
|
if "ask_user" in result:
|
||||||
# The question lives in the tool args. ChatMessage.to_dict()
|
# The question lives in the tool args. ChatMessage.to_dict()
|
||||||
# replays only role+content to the model next turn — tool_event
|
# 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
|
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||||
full_response += _auq_delta
|
full_response += _auq_delta
|
||||||
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||||
yield (
|
_pending_ask_user_event = _auq
|
||||||
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
|
||||||
)
|
|
||||||
_awaiting_user = True
|
_awaiting_user = True
|
||||||
|
|
||||||
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
# 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)
|
# 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")}
|
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:
|
if "ui_event" in result:
|
||||||
tool_output_data["ui_event"] = result["ui_event"]
|
tool_output_data["ui_event"] = result["ui_event"]
|
||||||
for k in (
|
for k in (
|
||||||
@@ -3319,6 +3324,14 @@ async def stream_agent_loop(
|
|||||||
tool_output_data["diff"] = result["diff"]
|
tool_output_data["diff"] = result["diff"]
|
||||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
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.
|
# 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
|
# 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"
|
# 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.
|
# this the diff shows live but vanishes from saved history.
|
||||||
if result.get("diff"):
|
if result.get("diff"):
|
||||||
tool_event["diff"] = result["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)
|
tool_events.append(tool_event)
|
||||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||||
_effectful_used = True
|
_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).",
|
"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.",
|
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||||
"search_chats": "Search past session transcripts across chats.",
|
"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.",
|
"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.",
|
"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.",
|
"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."},
|
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
||||||
"options": {
|
"options": {
|
||||||
"type": "array",
|
"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": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"required": ["label"]
|
"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"]
|
"required": ["question", "options"]
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
elif tool_type == "ask_teacher":
|
elif tool_type == "ask_teacher":
|
||||||
content = args.get("model", "auto") + "\n" + args.get("problem", "")
|
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:
|
else:
|
||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
|
|
||||||
|
|||||||
+13
-142
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
|
|||||||
import chatStream from './chatStream.js';
|
import chatStream from './chatStream.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import { svgifyEmoji } from './markdown.js';
|
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import presetsModule from './presets.js';
|
import presetsModule from './presets.js';
|
||||||
import fileHandlerModule from './fileHandler.js';
|
import fileHandlerModule from './fileHandler.js';
|
||||||
@@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
} else if (json.type === 'ask_user') {
|
} else if (json.type === 'ask_user') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
// The agent posed a multiple-choice question; the turn has ended.
|
// The agent posed a multiple-choice question; the turn has ended.
|
||||||
// Render clickable options at the bottom of the history. The
|
// Use the shared history renderer so the live and restored
|
||||||
// user's pick is sent as the next message and the agent resumes.
|
// versions have identical behavior.
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
_removeThinkingSpinner();
|
_removeThinkingSpinner();
|
||||||
const _aq = json.data || {};
|
chatRenderer.renderAskUserCard(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 (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (json.type === 'plan_update') {
|
} else if (json.type === 'plan_update') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
@@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
if (!header) return;
|
if (!header) return;
|
||||||
const node = header.closest('.agent-thread-node');
|
const node = header.closest('.agent-thread-node');
|
||||||
if (!node) return;
|
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;
|
window.__odysseus_thread_click_bound = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
|
import { svgifyEmoji } from './markdown.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import { providerLogo, providerLabel } from './providers.js';
|
import { providerLogo, providerLabel } from './providers.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
@@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
if (uiModule) uiModule.scrollHistory();
|
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.
|
* Add a message to the chat history.
|
||||||
*/
|
*/
|
||||||
@@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
if (!box) { console.error('Chat history element not found'); return; }
|
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;
|
var esc = uiModule.esc;
|
||||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
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) {
|
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||||
const roundTexts = metadata.round_texts || [];
|
const roundTexts = metadata.round_texts || [];
|
||||||
const toolEvents = metadata.tool_events;
|
const toolEvents = metadata.tool_events;
|
||||||
|
let pendingAskUser = null;
|
||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
let lastMsgAi = null;
|
let lastMsgAi = null;
|
||||||
@@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
|
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||||
let outHtml = '';
|
let outHtml = '';
|
||||||
if (ev.output && ev.output.trim()) {
|
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));
|
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||||
}
|
}
|
||||||
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
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;
|
return lastWrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2461,6 +2611,8 @@ const chatRenderer = {
|
|||||||
copyMessageText,
|
copyMessageText,
|
||||||
safeToolScreenshotSrc,
|
safeToolScreenshotSrc,
|
||||||
safeDisplayImageSrc,
|
safeDisplayImageSrc,
|
||||||
|
removeAskUserCards,
|
||||||
|
renderAskUserCard,
|
||||||
buildSourcesBox,
|
buildSourcesBox,
|
||||||
buildFindingsBox,
|
buildFindingsBox,
|
||||||
appendReportButton,
|
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
|
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():
|
def test_registered_everywhere():
|
||||||
# TOOL_TAGS gate (serializer rejects unknown tools)
|
# TOOL_TAGS gate (serializer rejects unknown tools)
|
||||||
assert "ask_user" in TOOL_TAGS
|
assert "ask_user" in TOOL_TAGS
|
||||||
|
|||||||
Reference in New Issue
Block a user