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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Fixed

- **Issue #2341** by @franksong2702 — Reattaching to an active streaming session now keeps the user prompt that started the running turn visible. Pre-fix, reload/session-switch restore could hydrate from the browser's INFLIGHT stream cache while the backend still held the initiating prompt only as `pending_user_message`, so the transcript showed assistant Thinking/Tool activity without the user's just-submitted message. The restore path now merges that pending user row into the live transcript before rendering and updates the INFLIGHT cache, while duplicate suppression checks the current message array so final session payloads do not show the prompt twice.

- **PR #2322** by @Michaelyklam (refs #2271) — LAN Ollama models selected from endpoint-discovered `custom:<host>-<port>` / `custom:<host>:<port>` picker entries now route through the configured `ollama` provider and base URL instead of surfacing a missing `CUSTOM_*_API_KEY` error. The picker still surfaces endpoint-discovered entries; the fix is to recognize them as UI routing hints matching the configured local-server base URL and resolve them via the actual `ollama` provider.


Expand Down
16 changes: 14 additions & 2 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,16 @@ async function loadSession(sid){
S.busy=false;
}

function _mergePendingSessionMessage(session,messages){
if(!Array.isArray(messages)) return false;
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(session,messages):null;
if(!pendingMsg) return false;
const liveAssistantIdx=messages.findIndex(m=>m&&m.role==='assistant'&&m._live);
if(liveAssistantIdx>=0) messages.splice(liveAssistantIdx,0,pendingMsg);
else messages.push(pendingMsg);
return true;
}

// Phase 2a: If session is streaming, restore from INFLIGHT cache before
// loading full messages (INFLIGHT state is self-contained and sufficient).
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
Expand All @@ -558,6 +568,9 @@ async function loadSession(sid){
// Streaming session: use cached INFLIGHT messages (already has pending assistant output).
S.messages=INFLIGHT[sid].messages;
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
if(_mergePendingSessionMessage(S.session,S.messages)){
INFLIGHT[sid].messages=S.messages;
}
S.busy=true;
// appendLiveToolCard() is guarded by S.activeStreamId; restore it before
// replaying persisted live tools so the compact Activity count survives
Expand Down Expand Up @@ -634,8 +647,7 @@ async function loadSession(sid){
updateQueueBadge(sid);

// Attach pending user message if one is queued.
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(S.session):null;
if(pendingMsg) S.messages.push(pendingMsg);
_mergePendingSessionMessage(S.session,S.messages);

if(activeStreamId){
S.busy=true;
Expand Down
7 changes: 4 additions & 3 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3902,7 +3902,7 @@ async function refreshSession() {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
S.session = data.session;
S.messages = data.session.messages || [];
const pendingMsg=getPendingSessionMessage(data.session);
const pendingMsg=getPendingSessionMessage(data.session,S.messages);
if(pendingMsg) S.messages.push(pendingMsg);
S.activeStreamId=data.session.active_stream_id||null;

Expand Down Expand Up @@ -4302,11 +4302,12 @@ async function _waitForServerThenReload(opts){
if(msgEl) msgEl.textContent='⚠️ Server is taking longer than expected — click Reload when ready';
}

function getPendingSessionMessage(session){
function getPendingSessionMessage(session, messagesOverride=null){
const text=String(session?.pending_user_message||'').trim();
if(!text) return null;
const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[];
const messages=Array.isArray(session?.messages)?session.messages:[];
const sourceMessages=Array.isArray(messagesOverride)?messagesOverride:session?.messages;
const messages=Array.isArray(sourceMessages)?sourceMessages:[];
const lastUser=[...messages].reverse().find(m=>m&&m.role==='user');
if(lastUser){
const lastText=String(msgContent(lastUser)||'').trim();
Expand Down
64 changes: 64 additions & 0 deletions tests/test_issue2341_pending_user_reattach.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")


def _load_session_inflight_branch() -> str:
start = SESSIONS_JS.find("if(INFLIGHT[sid]){")
assert start != -1, "loadSession INFLIGHT branch not found"
end = SESSIONS_JS.find("}else{", start)
assert end != -1, "loadSession INFLIGHT branch end not found"
return SESSIONS_JS[start:end]


def test_load_session_inflight_reattach_merges_pending_user_message_before_render():
"""#2341: Reattaching to an active stream must show the initiating user turn.

A reload or session switch can hydrate a running turn from INFLIGHT while the
backend still carries the user prompt only as pending_user_message. Without
merging that pending row before renderMessages(), the user sees assistant
thinking/tool activity without the prompt that started it.
"""
block = _load_session_inflight_branch()

merge_pos = block.find("_mergePendingSessionMessage")
render_pos = block.find("renderMessages();appendThinking();")

assert merge_pos != -1, (
"loadSession's INFLIGHT reattach branch must merge pending_user_message "
"into S.messages before rendering the running turn"
)
assert render_pos != -1, "INFLIGHT branch render call not found"
assert merge_pos < render_pos, (
"The pending user row must be present before renderMessages() rebuilds "
"the active transcript"
)
assert "INFLIGHT[sid].messages=S.messages;" in block, (
"After merging the pending user row, the INFLIGHT cache should be updated "
"so later session switches keep the same visible turn"
)
assert "messages.findIndex(m=>m&&m.role==='assistant'&&m._live)" in SESSIONS_JS
assert "messages.splice(liveAssistantIdx,0,pendingMsg)" in SESSIONS_JS


def test_pending_user_message_dedup_checks_current_message_array():
"""#2341: Pending merge must not duplicate an already-visible user row."""
assert "function getPendingSessionMessage(session, messagesOverride=null)" in UI_JS
helper_start = UI_JS.find("function getPendingSessionMessage(session, messagesOverride=null)")
assert helper_start != -1, "getPendingSessionMessage helper not found"
helper_end = UI_JS.find("async function checkInflightOnBoot", helper_start)
assert helper_end != -1, "getPendingSessionMessage helper end not found"
helper = UI_JS[helper_start:helper_end]

assert "messagesOverride" in helper
assert "Array.isArray(messagesOverride)?messagesOverride" in helper.replace(" ", ""), (
"Pending-message dedup must inspect the current S.messages/INFLIGHT "
"array, not only session.messages from the metadata response"
)
assert "lastText===text" in helper, (
"Pending-message merge must suppress duplicates when the last user row "
"already matches pending_user_message"
)
Loading