Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@

## [Unreleased]

## [v0.51.151] — 2026-05-28 — Release DW (stage-batch33 — 3-PR mid-risk batch: SSE reattach + title-lang + composer cap)

### Fixed

- Live SSE stream now reattaches when returning to a session that lost its connection during a session switch, closing the connection-leak window where stale `EventSource`s could accumulate. Also fixes a `_dirty_suffix` correctness path and yields the GIL after every SSE put so the HTTP server stays responsive under burst load. (#2924, #2925)
- Generated session titles now stay in the conversation language by adding an explicit title-generation instruction to the auxiliary prompt. Prevents the default prompt from drifting into English for non-English conversations. (#2984)

### Changed

- Composer box max-width is now capped at 1600px on ultrawide viewports (≥1600px) so chips stay anchored against a content-sized boundary instead of stretching across 3440px+ displays. Maintainer-confirmed cap from the #2856 thread. (#2946)

## [v0.51.150] — 2026-05-28 — Release DV (stage-batch32 — single-PR reasoning-effort agent metadata)

### Fixed
Expand Down
59 changes: 59 additions & 0 deletions api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,12 +1378,60 @@ def _is_provisional_title(current_title: str, messages) -> bool:
return current == candidate


def _detect_title_language(text: str) -> str:
"""Best-effort language hint for title generation/validation."""
s = re.sub(r'\s+', ' ', str(text or '')).strip().lower()
if not s:
return ''
german_markers = {
'warum', 'werden', 'wird', 'wurde', 'hier', 'nicht', 'mehr', 'alte', 'alten',
'bilder', 'angezeigt', 'session', 'prüfe', 'ich', 'die', 'der', 'das', 'den',
'und', 'oder', 'mit', 'für', 'von', 'zu', 'ist', 'sind', 'bitte', 'kannst',
}
tokens = re.findall(r'[A-Za-zÀ-ÖØ-öø-ÿ]+', s)
german_hits = sum(1 for tok in tokens if tok in german_markers)
if re.search(r'[äöüß]', s) or german_hits >= 2:
return 'de'
return ''


def _title_prompt_language_rule(user_text: str) -> str:
lang = _detect_title_language(user_text)
if lang == 'de':
return (
"Match the language of the user question.\n"
"If the user writes German, output a German title.\n"
"German good: Alte Session Bilder, WebUI Attachment-Pfade, Kontextkompression Status.\n"
)
return "Match the language of the user question.\n"


def _title_language_mismatch(user_text: str, title: str) -> bool:
"""Reject obvious English titles for German conversation starts."""
if _detect_title_language(user_text) != 'de':
return False
candidate = str(title or '').strip().lower()
if not candidate:
return False
if _detect_title_language(candidate) == 'de':
return False
english_markers = {
'old', 'image', 'display', 'issue', 'problem', 'discussion', 'conversation',
'session', 'title', 'fix', 'bug', 'attachment', 'attachments', 'context',
}
tokens = re.findall(r'[a-z]+', candidate)
english_hits = sum(1 for tok in tokens if tok in english_markers)
return english_hits >= 2


def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]:
qa = f"User question:\n{user_text[:500]}\n\nAssistant answer:\n{assistant_text[:500]}"
language_rule = _title_prompt_language_rule(user_text)
prompts = [
(
"Generate a short session title from this conversation start.\n"
"Use BOTH the user's question and the assistant's visible answer.\n"
f"{language_rule}"
"Return only the title text, 3-8 words, as a topic label.\n"
"Do not use markdown, bullets, labels, or prefixes like Session Title:.\n"
"Do not output a full sentence.\n"
Expand All @@ -1395,6 +1443,7 @@ def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]
(
"Rewrite this conversation start as a concise noun-phrase title.\n"
"Use the actual topic, not the task outcome.\n"
f"{language_rule}"
"Return title text only.\n"
"Do not use markdown, bullets, labels, or prefixes like Session Title:.\n"
"Never output acknowledgements, completion status, or meta commentary."
Expand Down Expand Up @@ -1750,6 +1799,8 @@ def _generate_llm_session_title_for_agent(agent, user_text: str, assistant_text:
return None, status, ''
title = _sanitize_generated_title(raw)
if title:
if _title_language_mismatch(user_text, title):
return None, 'llm_language_mismatch', str(raw)[:120]
return title, status, ''
return None, 'llm_invalid', str(raw)[:120]

Expand Down Expand Up @@ -1782,6 +1833,8 @@ def _generate_llm_session_title_via_aux(user_text: str, assistant_text: str, age
return None, status, ''
title = _sanitize_generated_title(raw)
if title:
if _title_language_mismatch(user_text, title):
return None, 'llm_language_mismatch_aux', str(raw)[:120]
return title, status, ''
return None, 'llm_invalid_aux', str(raw)[:120]

Expand Down Expand Up @@ -1816,6 +1869,12 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option
assistant_text = re.sub(r'\s+', ' ', assistant_text).strip()
combined = f"{user_text} {assistant_text}".strip().lower()
combined_raw = f"{user_text} {assistant_text}".strip()
source_lang = _detect_title_language(user_text)

if source_lang == 'de' and 'bilder' in combined and 'session' in combined:
if 'alt' in combined or 'alte' in combined or 'alten' in combined:
return 'Alte Session Bilder'
return 'Session Bilder'

def _contains_latin(text: str) -> bool:
return bool(re.search(r'[A-Za-z]', text or ''))
Expand Down
14 changes: 11 additions & 3 deletions api/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,17 @@ def _dirty_suffix(path: Path, timeout=1) -> str:
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
if ok:
return ""
# diff-index exits 1 with no output for a dirty tree. Timeouts and real git
# failures include a diagnostic; skip the suffix so the base version remains.
return "-dirty" if not out else ""
# diff-index --quiet exits 1 with no stdout/stderr to *signal* a dirty tree
# (not an error). _run_git() substitutes a synthetic "git exited with
# status N" diagnostic when both streams are empty, which makes the naive
# `if not out` guard always false on dirty trees — silently dropping the
# suffix and defeating dev-build cache busting (static/foo.js?v=… stays
# identical to the last-committed version). Treat the synthetic shape as
# the dirty signal; real errors (timeouts, missing git) carry a different
# diagnostic and correctly suppress the suffix.
if not out or out.startswith('git exited with status '):
return "-dirty"
return ""


def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
Expand Down
30 changes: 27 additions & 3 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,16 @@ function closeLiveStream(sessionId, streamId, source){
if(source&&live.source!==source) return;
try{live.source.close();}catch(_){ }
delete LIVE_STREAMS[sessionId];
// closeLiveStream() is called during session-switch teardown for any session
// the user is no longer viewing. The stream is still active on the server,
// so mark the in-memory INFLIGHT entry for reattach — otherwise
// loadSession() returning to this session skips the reattach branch
// (`INFLIGHT.reattach` was only set by the storage-load path) and the SSE
// is never reopened. The user then sees no streamed tokens until the LLM
// finishes and a metadata refresh swaps in the final reply.
// If the stream is terminating cleanly, _clearOwnerInflightState() has
// already deleted INFLIGHT[sessionId], so this is a safe no-op.
if(INFLIGHT[sessionId]) INFLIGHT[sessionId].reattach=true;
}

function closeOtherLiveStreams(activeSid){
Expand Down Expand Up @@ -648,9 +658,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
closeOtherLiveStreams(activeSid);
closeLiveStream(activeSid);

let assistantText='';
let reasoningText='';
let liveReasoningText='';
// On reconnect, restore accumulated text from INFLIGHT so we don't lose
// progress made before the session switch. Without this the closure starts
// empty and tokens arriving on the new SSE connection append to nothing —
// the already-rendered content vanishes.
const _lastLiveAssistant = reconnecting
? INFLIGHT[activeSid]?.messages?.findLast?.(m => m.role === 'assistant' && m._live)
: null;
let assistantText = _lastLiveAssistant ? (_lastLiveAssistant.content || '') : '';
let reasoningText = _lastLiveAssistant ? (_lastLiveAssistant.reasoning || '') : '';
let liveReasoningText = reasoningText;
let visibleInterimSnippets=[];
let _latestGoalStatus=null;
let _pendingGoalContinuation=null;
Expand Down Expand Up @@ -2135,6 +2152,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_closeSource(source);
// If the user has switched to a different session, don't attempt to
// reconnect — the old stream's EventSource was closed intentionally
// during session switch and reconnecting would leak a background stream.
if(!_isSessionActivelyViewed(activeSid)) return;
if(_terminalStateReached || _streamFinalized){
return;
}
// Attempt one reconnect if the stream is still active server-side
if(!_reconnectAttempted && streamId){
_reconnectAttempted=true;
Expand Down
7 changes: 7 additions & 0 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,13 @@ async function loadSession(sid){
S.toolCalls = [];
_messagesTruncated = false;
_oldestIdx = 0;
// Close live SSE streams from the session we're leaving. The error
// handler checks _isSessionActivelyViewed() and won't auto-reconnect
// for a backgrounded session, preventing leaked connections that would
// pump token events into an orphaned closure, freezing the main thread.
if (currentSid && currentSid !== sid && typeof closeOtherLiveStreams === 'function') {
closeOtherLiveStreams(sid);
}
_loadingOlder = false;
const _msgInner = $('msgInner');
if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
Expand Down
5 changes: 5 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -4535,3 +4535,8 @@ main.main.showing-logs > #mainLogs{display:flex;}
text-align:left;
unicode-bidi:isolate;
}

/* Cap composer width on very wide displays so the chip-cluster gap stays bounded */
@media (min-width:1600px) {
.composer-box{max-width:1600px;margin:0 auto;}
}
72 changes: 72 additions & 0 deletions tests/test_inflight_stream_reuse.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Regression tests for preserving live streams across session switches."""
import re
from pathlib import Path

REPO_ROOT = Path(__file__).parent.parent
Expand Down Expand Up @@ -99,3 +100,74 @@ def test_load_session_reattach_path_uses_attach_live_stream_for_running_sessions
assert reattach_pos != -1
assert active_pos < reattach_pos
assert "{reconnecting:true}" in body[reattach_pos : reattach_pos + 200]


def test_close_live_stream_marks_inflight_for_reattach_on_return():
"""When closeLiveStream() tears down a still-active SSE transport (e.g. the
user switched to another session), the corresponding INFLIGHT entry must be
flagged so loadSession() reopens the SSE on return.

Without this flag the in-memory INFLIGHT entry stays as it was (no
`reattach:true`, which is only set on the storage-load path), so
loadSession()'s reattach branch is skipped — the SSE is never reopened and
the user sees no streamed tokens until the LLM finishes and a metadata
refresh swaps in the final reply.
"""
body = _function_body(MESSAGES_JS, "closeLiveStream")
assert "INFLIGHT" in body, (
"closeLiveStream() must touch INFLIGHT so loadSession() reattaches the "
"SSE when the user switches back to a still-streaming session"
)
assert re.search(r"INFLIGHT\[\w+\]\s*&&\s*\(?INFLIGHT\[\w+\]\.reattach\s*=\s*true", body) \
or re.search(r"if\s*\(\s*INFLIGHT\[\w+\]\s*\)\s*INFLIGHT\[\w+\]\.reattach\s*=\s*true", body), (
"closeLiveStream() must set INFLIGHT[sessionId].reattach = true "
"(guarded by an existence check) so loadSession()'s reattach branch fires"
)


def test_close_other_live_streams_triggers_reattach_for_backgrounded_sessions():
"""closeOtherLiveStreams() during session switch must mark every closed
background session for reattach. Otherwise switching back to a session whose
stream was closed during the switch leaves the SSE permanently disconnected.
"""
helper_body = _function_body(MESSAGES_JS, "closeOtherLiveStreams")
close_body = _function_body(MESSAGES_JS, "closeLiveStream")
# closeOtherLiveStreams delegates per-session teardown to closeLiveStream,
# so the reattach flag must be set inside closeLiveStream itself for the
# chain to work — this guards the indirection.
assert "closeLiveStream(sid)" in helper_body.replace(" ", ""), (
"closeOtherLiveStreams() must delegate teardown to closeLiveStream()"
)
assert "reattach" in close_body, (
"closeLiveStream() must set the reattach flag so closeOtherLiveStreams() "
"propagates the reattach intent to every backgrounded session"
)


def test_load_session_reattaches_when_inflight_is_in_memory_and_marked_for_reattach():
"""The session-switch return path must hit attachLiveStream() even when
INFLIGHT[sid] is already in memory (i.e. wasn't loaded from storage).

Before the fix, only the storage-load path set `reattach:true` on INFLIGHT,
so a switch-back through an in-memory INFLIGHT entry skipped the reattach
branch. Once closeLiveStream() also sets reattach=true, the existing
`INFLIGHT[sid].reattach && activeStreamId` gate is enough — this test
pins the gate's shape so future refactors don't drop the flag check.
"""
body = _function_body(SESSIONS_JS, "loadSession")
inflight_idx = body.find("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = body[inflight_idx : inflight_idx + 2400]
assert "INFLIGHT[sid].reattach" in inflight_block, (
"loadSession()'s INFLIGHT branch must gate the SSE reattach on the "
"reattach flag so closeLiveStream()'s marking flows through"
)
reattach_gate = re.search(
r"if\(INFLIGHT\[sid\]\.reattach\s*&&\s*activeStreamId.*?attachLiveStream\(sid, activeStreamId",
inflight_block,
re.DOTALL,
)
assert reattach_gate, (
"loadSession() must reattach via attachLiveStream() when "
"INFLIGHT[sid].reattach && activeStreamId"
)
9 changes: 8 additions & 1 deletion tests/test_issue2540_models_endpoint_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,14 @@ def test_named_custom_provider_models_endpoint_network_error_uses_short_timeout(
observed_timeouts = []

def fake_urlopen(req, timeout=10):
observed_timeouts.append(timeout)
# Only record timeouts for the broken-proxy custom endpoint — unrelated
# background probes (Copilot token fetch, OpenRouter free-tier discovery, etc.)
# also call urlopen during get_available_models() and would otherwise pollute
# the assertion. The contract we're pinning: the broken-proxy /v1/models call
# uses CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, not the urllib default 10.
full_url = getattr(req, "full_url", "")
if "broken.example" in str(full_url):
observed_timeouts.append(timeout)
raise urllib.error.URLError("timed out")

monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
Expand Down
32 changes: 25 additions & 7 deletions tests/test_parallel_session_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

import pathlib
import re
import threading
import time
from unittest.mock import patch, MagicMock
Expand Down Expand Up @@ -487,10 +488,20 @@ def test_load_older_checks_loading_session_id(self):

def test_loading_older_reset_on_session_switch(self):
"""loadSession must reset _loadingOlder when switching sessions."""
# Find the reset block in loadSession
marker = "_messagesTruncated = false;\n _oldestIdx = 0;\n _loadingOlder = false;"
idx = SESSIONS_JS.find(marker)
assert idx >= 0, (
# Locate the on-switch reset block — it lives in the `if (currentSid !== sid || forceReload)`
# arm of loadSession. Match by the surrounding state-resets rather than by a fragile
# multi-line substring, so unrelated code (like the closeOtherLiveStreams teardown
# that was inserted between _oldestIdx and _loadingOlder) doesn't break the test.
switch_arm = re.search(
r"if \(currentSid !== sid \|\| forceReload\) \{(.*?)\n \}",
SESSIONS_JS,
re.DOTALL,
)
assert switch_arm, "loadSession's session-switch reset arm not found"
block = switch_arm.group(1)
assert "_messagesTruncated = false;" in block
assert "_oldestIdx = 0;" in block
assert "_loadingOlder = false;" in block, (
"loadSession must reset _loadingOlder=false on session switch "
"to prevent a stale _loadOlderMessages lock from blocking the "
"new session's scroll-to-top loading."
Expand All @@ -517,13 +528,20 @@ def test_stale_cannot_mutate_messages(self):

def test_messages_truncated_reset_on_switch(self):
"""loadSession must reset _messagesTruncated on session switch."""
marker = "_messagesTruncated = false;\n _oldestIdx = 0;\n _loadingOlder = false;"
idx = SESSIONS_JS.find(marker)
assert idx >= 0, (
switch_arm = re.search(
r"if \(currentSid !== sid \|\| forceReload\) \{(.*?)\n \}",
SESSIONS_JS,
re.DOTALL,
)
assert switch_arm, "loadSession's session-switch reset arm not found"
block = switch_arm.group(1)
assert "_messagesTruncated = false;" in block, (
"_messagesTruncated must be reset to false on session switch "
"to prevent the scroll-to-top handler from trying to load "
"older messages from the previous session."
)
assert "_oldestIdx = 0;" in block
assert "_loadingOlder = false;" in block

def test_oldest_idx_reset_prevents_wrong_cursor(self):
"""_oldestIdx=0 after switch prevents passing stale cursor to API."""
Expand Down
8 changes: 6 additions & 2 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,9 +761,13 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se
until the final done snapshot redraws the whole turn.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
assert "let reasoningText=''" in src, \
# reasoningText is initialised at closure scope in attachLiveStream.
# On initial connect it defaults to ''; on reconnect it restores from
# INFLIGHT so the already-rendered content survives the session switch.
assert ("let reasoningText=''" in src
or "let reasoningText = _lastLiveAssistant" in src), \
"messages.js must track streamed reasoning text separately from assistant text"
assert "let liveReasoningText=''" in src or 'let liveReasoningText = ""' in src, \
assert ("let liveReasoningText=''" in src or "let liveReasoningText = reasoningText" in src), \
"messages.js must track the currently active reasoning segment separately from cumulative reasoning"
assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \
"messages.js must listen for live reasoning SSE events"
Expand Down
Loading
Loading