Compare commits

...
7 Commits
Author SHA1 Message Date
Wes HuberandClaude Fable 5 e4fa4ae5dd fix(brain): give the Add Memory form a submit button and reliable Enter handling (#5830)
The Brain > Add tab rendered only a text input and category select with no
submit control, and Enter submission relied on a deprecated keypress
listener that is not guaranteed to fire, so the form could not be
submitted at all (#5828).

Add a labelled submit button styled like the neighbouring Skill Import
button (theme-io-btn, inline SVG icon), switch the Enter handler to
keydown with preventDefault, ignore IME composition, and pin both submit
paths with a source-level regression test.

Fixes #5828

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:07:07 +02:00
Samyandsamy 378518f6df Fix #5870: stale skills panel data on tab reopen (#5876)
Remove early-return guard in loadSkills() that skipped both API re-fetch
and renderSkillsList() when the Skills tab was reopened after first load.
The cascade entrance animation is already handled inside renderSkillsList()
via _cascadeNext, so the guard was unnecessary and caused deleted/edited
skills to remain visible until a full page reload.

Co-authored-by: samy <samy@users.noreply.github.com>
2026-08-07 22:06:17 +02:00
Samyandsamy f06a0a30a8 fix(session): restore session URL hash writes (removed in cf4e240a) (#5872)
* Fix: restore session URL hash writes (removed in cf4e240a)

Restores history.replaceState() calls in selectSession() and
materializePendingSession() that were dropped during the July 23 merge.
Without these, chat URLs never update the address bar hash, making
sessions unshareable and causing bare-URL reloads to land on the
welcome screen instead of restoring the last active chat.

Root cause: selectSession() had its hash-write deliberately removed;
materializePendingSession() lost its during a larger refactor that
added the stale-response and incognito guards.

Fixes #5870 (upstream)

* fix: session URL hash lost when sending message mid-stream

Two independent bugs caused the session hash to disappear from the URL:

Bug 1 — ReferenceError in catch block silently killed error recovery
  In handleChatSubmit, two const variables (streamingTTS at line 1922 and
  abortCtrl at line 1741) were declared inside the try block but referenced
  in the catch block. Since const is block-scoped in JavaScript, they were
  undefined in catch, causing a ReferenceError that silently aborted the
  error handler. This prevented materializePendingSession() from ever being
  called, so no hash was written to the URL.
  Fix: Hoisted both as let declarations before the try { block.

Bug 2 — Dual sessions.js ES module instances with mismatched state
  app.js imported sessions.js with a version query string
  (?v=20260722ctxheader4) while every other module imported ./sessions.js
  without one. The browser treated them as different URLs, creating two
  separate module instances with independent _pendingChat and
  currentSessionId state. createDirectChat() set pending on one instance
  while handleChatSubmit() checked hasPendingChat() on the other — so the
  pending session never materialized.
  Fix: Removed the version query string from the sessions.js import in
  app.js and from the modulepreload + script tags in index.html. All
  modules now share a single sessions.js instance.

Bonus guard: _adoptOpenedSessionBeforeAutoCreate() now checks
hasPendingChat() before adopting a stale DOM-active session, preventing
the send path from landing in the wrong session when a New Chat is pending.

---------

Co-authored-by: samy <samy@users.noreply.github.com>
2026-08-07 22:04:53 +02:00
Husam 99566d28b5 fix(chat): stop ArrowUp from eating an unsent multi-line prompt (#5875)
static/app.js carried a near-verbatim copy of the prompt-recall logic in
static/js/composerArrowUpRecall.js, wired as a second capture-phase
keydown listener on the same #message textarea. The copy omitted the
draft guard the module has: it called preventDefault() and
stopImmediatePropagation() unconditionally, then recalled history[0]
over whatever the user had typed.

Because it stopped immediate propagation, the copy won regardless of
registration order — if it ran first the module never saw the event, and
if it ran second the module had already declined to stop propagation on
an unmatched draft. The guard at composerArrowUpRecall.js:109 was
unreachable on the real page, so ArrowUp on a multi-line draft replaced
it with the last sent prompt instead of moving the caret up a line.

Delete the duplicate. The module keeps ownership of ArrowUp/ArrowDown
recall, which is the behavior MODULE_SUMMARY.md documents ("on an empty
composer") and the behavior tests/test_composer_arrow_up_recall_js.py
already pins via test_non_empty_composer_does_not_recall and
test_multiline_caret_navigation_preserved.

Also correct a stale comment in the module that described the deleted
behavior and contradicted the guard 35 lines above it, and add a
regression test asserting app.js does not reintroduce a second handler.

Fixes #5862
2026-08-07 19:34:50 +02:00
Husam f1e96d102e fix(tool_parsing): require a pipe on the Qwen bare end marker (#5829)
The `end` branch of _QWEN_BARE_MARKER_RE had both pipes optional
(`\|?end\|?`), so it also matched a bare `end` between whitespace and
replaced it with a space. Messages containing Ruby, Lua or shell code that
closes a block with a lone `end` had those lines deleted, and ordinary prose
lost the word too.

Require at least one pipe so only real turn markers match; `|end`, `end|`,
`|end|` and `/|end|` strip exactly as before. Applied to the duplicated
pattern in static/js/chatRenderer.js as well.

Fixes #5547
2026-08-07 19:33:14 +02:00
Jakub Grula 36d4098421 fix: Edit box formatting was removing triple tick boxes (#5737) 2026-08-07 19:15:50 +02:00
adabarbulescu 5ddef23d94 fix(welcome): rotate startup tips (#5871) 2026-08-07 19:12:21 +02:00
11 changed files with 222 additions and 101 deletions
+5 -1
View File
@@ -187,8 +187,12 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
# At least one pipe is required around `end`. Both pipes used to be optional
# (`\|?end\|?`), which also matched a bare `end` on its own line and deleted it
# from ordinary prose and from Ruby/Lua/shell snippets that close blocks with
# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
_QWEN_BARE_MARKER_RE = re.compile(
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
re.IGNORECASE,
)
+16 -83
View File
@@ -10,14 +10,14 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js?v=20260722ctxheader4';
import chatModule from './js/chat.js?v=20260801fix1';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
@@ -1689,12 +1689,20 @@ function initializeEventListeners() {
const newMemoryInput = el('new-memory-input');
if (newMemoryInput) {
newMemoryInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
// keydown, not the deprecated keypress: keypress is not guaranteed to
// fire for Enter everywhere, which left the Add Memory form with no
// working submit path (#5828).
newMemoryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
memoryModule.addNewMemory();
}
});
}
const newMemoryAddBtn = el('new-memory-add-btn');
if (newMemoryAddBtn) {
newMemoryAddBtn.addEventListener('click', () => memoryModule.addNewMemory());
}
// Voice recording is handled by the dual-purpose send/mic button (see below)
@@ -3908,85 +3916,10 @@ function startOdysseusApp() {
const messageInput = el('message');
const modelPickerWrap = document.getElementById('model-picker-wrap');
function _readComposerPromptHistory() {
const chatBox = document.getElementById('chat-history');
if (!chatBox) return [];
return Array.from(chatBox.querySelectorAll('.msg-user'))
.reverse()
.map(msg => {
const body = msg.querySelector('.body');
return msg.dataset?.raw || (body ? body.textContent : '') || '';
})
.filter(Boolean);
}
if (messageInput && !messageInput._odysseusPromptRecallCapture) {
messageInput._odysseusPromptRecallCapture = true;
let recallHistory = [];
let recallIndex = -1;
let lastRecalled = '';
const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
messageInput.addEventListener('input', () => {
if (norm(messageInput.value) === norm(lastRecalled)) return;
recallHistory = [];
recallIndex = -1;
lastRecalled = '';
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
}, true);
messageInput.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
if (window._ghostAutocomplete?.isActive?.()) return;
const fresh = _readComposerPromptHistory();
const history = fresh.length ? fresh : recallHistory;
if (!history.length) return;
const current = norm(messageInput.value);
let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
if (current && currentIndex < 0) {
const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
currentIndex = markedIndex;
}
}
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (e.key === 'ArrowDown') {
if (currentIndex < 0) return;
const nextIndex = currentIndex - 1;
if (nextIndex < 0) {
recallHistory = history;
recallIndex = -1;
lastRecalled = '';
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
messageInput.value = '';
try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
return;
}
const recalled = history[nextIndex];
recallHistory = history;
recallIndex = nextIndex;
lastRecalled = recalled;
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
messageInput.value = recalled;
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
return;
}
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) return;
recallHistory = history;
recallIndex = nextIndex;
lastRecalled = recalled;
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
messageInput.value = recalled;
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
}, true);
}
// ArrowUp/ArrowDown prompt recall on #message lives in
// static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
// copy here: two capture-phase listeners on the same textarea meant the one
// without the draft guard won and ate unsent multi-line prompts (#5862).
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
+6 -5
View File
@@ -250,9 +250,9 @@
</script>
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
<link rel="modulepreload" href="/static/js/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
<link rel="modulepreload" href="/static/js/sessions.js">
<link rel="modulepreload" href="/static/js/markdown.js">
</head>
<body>
@@ -365,6 +365,7 @@
<span class="skill-rich-ph"><span class="k">Add a memory</span> &mdash; e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
</div>
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
<button type="button" id="new-memory-add-btn" class="theme-io-btn" title="Save this memory" style="flex:none;height:28px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Add</button>
</div>
</div>
<div class="admin-card">
@@ -1005,7 +1006,7 @@
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
el.textContent = 'Pick a model if you want, or just type.';
el.textContent = tips[Math.floor(Math.random() * tips.length)];
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
@@ -2504,7 +2505,7 @@
<script type="module" src="/static/js/ui.js"></script>
<script type="module" src="/static/js/markdown.js"></script>
<script type="module" src="/static/js/dragSort.js"></script>
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
<script type="module" src="/static/js/sessions.js"></script>
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
<script type="module" src="/static/js/skills.js"></script>
<script type="module" src="/static/js/tourHints.js"></script>
@@ -2522,7 +2523,7 @@
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
+9 -3
View File
@@ -349,6 +349,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
async function _adoptOpenedSessionBeforeAutoCreate() {
if (!sessionModule || !sessionModule.getCurrentSessionId || sessionModule.getCurrentSessionId()) return true;
// Don't adopt a stale session when the user explicitly started a New Chat
// (pending state set) — the send path must materialize the pending session.
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) return false;
const activeRowId = document.querySelector('.list-item.active-session[data-session-id], .session-item.active[data-session-id]')?.dataset?.sessionId || '';
const hashId = _hashSessionCandidate();
const lastSelectedId = String(window.__odysseusLastSelectedSessionId || '').trim();
@@ -1403,6 +1406,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
currentAccumulated = '';
currentHolder = null;
let abortCtrl = null;
let streamingTTS = false;
try {
// Re-enable auto-scroll when user sends a message
uiModule.setAutoScroll(true);
@@ -1716,7 +1721,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
const abortCtrl = new AbortController();
abortCtrl = new AbortController();
abortCtrl._reason = '';
currentAbort = abortCtrl;
@@ -1897,7 +1902,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let isThinking = false;
let thinkingStartTime = null;
// Streaming TTS: synthesize sentence-by-sentence during streaming
const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
if (streamingTTS) window.aiTTSManager.streamingStart();
// Multi-bubble agent tracking
let roundHolder = holder; // Current AI text bubble (changes per round)
@@ -4787,7 +4792,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (msgIndex < 0) return;
const bodyEl = userMsgElement.querySelector('.body');
const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
let currentText = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
currentText = currentText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
// Replace body with an editable textarea
const editor = document.createElement('textarea');
+4 -1
View File
@@ -478,7 +478,10 @@ const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
+3 -3
View File
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
return;
}
// ArrowUp owns prompt history in the chat composer. If the current text
// is not already a recalled prompt, start from newest instead of letting
// the browser move the caret inside the textarea.
// ArrowUp walks older prompts. An unmatched draft already returned above,
// so reaching here means the composer is empty or holds a recalled prompt
// — the caret-navigation case is never hijacked.
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) {
+5
View File
@@ -1847,6 +1847,10 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
if (!_isTransientChat) {
Storage.set('lastSessionId', id);
// Update URL hash without triggering hashchange handler
if (window.location.hash !== '#' + id) {
history.replaceState(null, '', '#' + id);
}
}
// Restore character preset for persistent chats
try {
@@ -2313,6 +2317,7 @@ export async function materializePendingSession() {
currentSessionId = payload.id;
if (!isIncognito) {
Storage.set('lastSessionId', payload.id);
history.replaceState(null, '', '#' + payload.id);
}
// Reload the sidebar in the background. Awaiting this used to block the first
+3 -5
View File
@@ -83,11 +83,9 @@ export async function loadSkills(cascade = false) {
// Play the domino-in entrance on this load (set when the tab is opened,
// not for the silent re-loads after an edit/delete).
if (cascade) _cascadeNext = true;
if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
_cascadeNext = false;
updateCount();
return;
}
// Always re-fetch when the tab is explicitly opened — the cascade
// animation is handled inside renderSkillsList() via _cascadeNext.
// Skipping the fetch here caused stale data on panel close/reopen (#5870).
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
try {
+21
View File
@@ -306,3 +306,24 @@ def test_integration_recalls_from_chat_history_dom():
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
def test_prompt_recall_is_not_duplicated_in_app_js():
"""Only composerArrowUpRecall.js may own ArrowUp on #message (issue #5862).
static/app.js once carried a near-verbatim copy of this recall logic, wired
as a second capture-phase listener on the same textarea. That copy lacked
the draft guard here, and because it called stopImmediatePropagation it won
regardless of registration order — so a typed multi-line prompt was replaced
by the last sent one instead of the caret moving up a line.
"""
app_js = (_REPO / "static" / "app.js").read_text(encoding="utf-8")
for marker in (
"_odysseusPromptRecallCapture",
"_readComposerPromptHistory",
"odysseusRecallIndex",
):
assert marker not in app_js, (
f"static/app.js reintroduces prompt recall ({marker!r}); "
"it belongs to static/js/composerArrowUpRecall.js alone"
)
@@ -0,0 +1,54 @@
"""The Brain > Add Memory form must be submittable (#5828).
The form previously had no submit button and relied on a deprecated
``keypress`` listener for Enter, which is not guaranteed to fire on all
platforms — leaving the form with no working submit path. Pins:
- a visible, keyboard-accessible submit button next to the category select;
- the button wired to ``memoryModule.addNewMemory()``;
- Enter handled via ``keydown`` with ``preventDefault()`` (and no lingering
``keypress`` handler on the input).
"""
from pathlib import Path
APP_JS = Path("static/app.js")
INDEX_HTML = Path("static/index.html")
def _add_memory_row(html):
start = html.index('id="new-memory-input"')
end = html.index("</div>", html.index('id="new-memory-add-btn"', start))
return html[start:end]
def test_add_memory_form_renders_a_submit_button():
html = INDEX_HTML.read_text()
row = _add_memory_row(html)
assert 'id="new-memory-category"' in row, "button must sit in the same row as the form fields"
btn_start = row.index('id="new-memory-add-btn"')
btn_tag = row[row.rindex("<button", 0, btn_start):row.index(">", btn_start)]
assert 'type="button"' in btn_tag, "must not rely on implicit submit semantics"
def _new_memory_wiring_block(source):
start = source.index("const newMemoryInput = el('new-memory-input');")
end = source.index("// Voice recording", start)
return source[start:end]
def test_submit_button_is_wired_to_add_new_memory():
block = _new_memory_wiring_block(APP_JS.read_text())
assert "el('new-memory-add-btn')" in block
assert "addEventListener('click', () => memoryModule.addNewMemory())" in block
def test_enter_uses_keydown_with_prevent_default():
block = _new_memory_wiring_block(APP_JS.read_text())
assert "addEventListener('keydown'" in block
assert "addEventListener('keypress'" not in block, "keypress is deprecated and unreliable for Enter"
assert "e.preventDefault();" in block
assert "!e.isComposing" in block, "IME composition must not submit the form"
assert "memoryModule.addNewMemory();" in block
@@ -0,0 +1,96 @@
"""Regression: the Qwen bare-marker scrub must not eat a lone `end` (#5547).
`_QWEN_BARE_MARKER_RE` cleans Qwen turn markers that leak into content. Its
`end` branch was `\\|?end\\|?` — both pipes optional — so it also matched a bare
`end` surrounded by whitespace and replaced it with a space. Any message
containing Ruby, Lua or shell code that closes a block with a lone `end` had
those lines silently deleted, in the stored text and in the rendered message.
Requiring at least one pipe keeps every real marker (`|end`, `end|`, `|end|`,
`/|end|`) stripping as before. The same pattern is duplicated in
static/js/chatRenderer.js, so the JS copy is checked here too — the two must
not drift.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
from src.tool_parsing import strip_tool_blocks
_REPO = Path(__file__).resolve().parent.parent
_CHAT_RENDERER = _REPO / "static" / "js" / "chatRenderer.js"
# Inputs that must survive untouched, and the substring that proves they did.
KEPT = [
("loop do\n puts \"yo\"\nend\n", "\nend"), # the reported Ruby case
("if x then\nend", "\nend"),
("function f()\nend\n", "\nend"),
("a end b", "a end b"),
("append end", "append end"),
("END", "END"),
("\nEnd\n", "End"),
]
# Real markers — at least one pipe, plus the role word — with the exact output
# they must still produce. Asserted as equality rather than "marker not in out"
# so narrowing the pattern can't pass by deleting more than it should.
STRIPPED = [
("a |end| b", "a b"),
("a /|end| b", "a b"),
("a |end b", "a b"),
("a end| b", "a b"),
("x assistant y", "x y"),
]
@pytest.mark.parametrize("text,kept", KEPT)
def test_bare_end_survives_stripping(text, kept):
assert kept in strip_tool_blocks(text)
@pytest.mark.parametrize("text,expected", STRIPPED)
def test_piped_end_markers_are_still_stripped(text, expected):
assert strip_tool_blocks(text) == expected
def test_bare_end_inside_a_fenced_block_survives():
"""The scrub runs over the whole message, fenced regions included."""
out = strip_tool_blocks("Here:\n```ruby\nloop do\n puts 1\nend\n```\nDone.")
assert "\nend\n" in out
def _js_bare_marker_regex_source():
src = _CHAT_RENDERER.read_text(encoding="utf-8")
m = re.search(r"^const QWEN_BARE_MARKER_RE = (/.*/[gimsuy]*);$", src, re.MULTILINE)
assert m, "QWEN_BARE_MARKER_RE literal not found in chatRenderer.js"
return m.group(1)
def test_js_copy_of_the_pattern_matches_the_python_one():
"""Guard the duplication: the JS branch must require a pipe too."""
if shutil.which("node") is None:
pytest.skip("node binary not on PATH")
cases = [text for text, _ in KEPT] + [text for text, _ in STRIPPED]
script = (
"const RE = %s;\n"
"const cases = JSON.parse(process.argv[1]);\n"
"console.log(JSON.stringify(cases.map(c => c.replace(RE, ' '))));"
% _js_bare_marker_regex_source()
)
result = subprocess.run(
["node", "--input-type=module", "-e", script, json.dumps(cases)],
cwd=_REPO, capture_output=True, timeout=15, text=True,
)
assert result.returncode == 0, f"node failed:\n{result.stderr}"
got = json.loads(result.stdout.splitlines()[-1])
for (text, kept), out in zip(KEPT, got):
assert kept in out, f"JS regex dropped {kept!r} from {text!r}"
for (text, expected), out in zip(STRIPPED, got[len(KEPT):]):
assert out == expected, f"JS regex: {text!r} -> {out!r}, expected {expected!r}"