diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index bb1c236cb..06f236b4c 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -2450,6 +2450,8 @@ private: server_slot * slot = get_slot_by_cmpl_id(task.params.control_cmpl_id); if (slot == nullptr) { + SRV_WRN("control %s on unknown completion id=%s, no live slot\n", + task.params.control_action.c_str(), task.params.control_cmpl_id.c_str()); res->success = false; res->message = "no active completion for this id"; queue_results.send(std::move(res)); diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp index 0380f98a3..81da00c0e 100644 --- a/tools/server/server-models.cpp +++ b/tools/server/server-models.cpp @@ -1983,7 +1983,10 @@ void server_models_routes::init_routes() { cli.set_read_timeout(0, STREAM_LOOKUP_TIMEOUT_MS * 1000); cli.set_write_timeout(0, STREAM_LOOKUP_TIMEOUT_MS * 1000); auto resp = cli.Delete(child_path.c_str()); - (void) resp; // best effort, 404 and network errors are equivalent to no op + (void) resp; // the child logs its own miss when the session is unknown there + } else { + SRV_WRN("router stop for unknown conv_id=%s, no owning child in the conv map\n", + conv_id.c_str()); } // drop the tracking entry, the session is being torn down models.conv_models.forget(conv_id); diff --git a/tools/server/server-stream.cpp b/tools/server/server-stream.cpp index 785c28b3a..c2bba8ec4 100644 --- a/tools/server/server-stream.cpp +++ b/tools/server/server-stream.cpp @@ -218,6 +218,13 @@ void stream_session_manager::evict_and_cancel(const std::string & conversation_i std::unique_lock lock(map_mu); auto it = sessions.find(conversation_id); if (it == sessions.end()) { + std::string live; + for (const auto & kv : sessions) { + if (!live.empty()) live += ", "; + live += kv.first; + } + SRV_WRN("stop on unknown stream session, conv_id=%s matched nothing, %zu live: [%s]\n", + conversation_id.c_str(), sessions.size(), live.c_str()); return; } s = it->second; diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index faaaa9755..43f9e72c7 100644 --- a/tools/ui/src/lib/stores/chat.svelte.ts +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -154,7 +154,13 @@ class ChatStore { }); if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response; } - private clearChatStreaming(convId: string): void { + private clearChatStreaming(convId: string, messageId?: string): void { + // session aware: a stale generation must not wipe a newer one's streaming state on the + // same conversation, that would drop the frozen stop identity and stop the wrong session + if (messageId !== undefined) { + const cur = this.chatStreamingStates.get(convId); + if (cur && cur.messageId !== messageId) return; + } this.chatStreamingStates.delete(convId); if (convId === conversationsStore.activeConversation?.id) this.currentResponse = ''; } @@ -1055,11 +1061,14 @@ class ChatStore { modelOverride?: string | null, firstUserMessageContent?: string ): Promise { - let effectiveModel = modelOverride; + // the ::model suffix in the stream identity is only for router mode, where it routes to the + // owning child. in single-model mode the identity stays the bare conv id so that attach, stop + // and reattach all agree, regardless of fresh send vs regenerate passing a resolved model + let effectiveModel: string | null | undefined = undefined; - if (isRouterMode() && !effectiveModel) { + if (isRouterMode()) { const conversationModel = this.getConversationModel(allMessages); - effectiveModel = selectedModelName() || conversationModel; + effectiveModel = modelOverride || selectedModelName() || conversationModel; } if (isRouterMode() && effectiveModel) { @@ -1074,6 +1083,9 @@ class ChatStore { let resolvedModel: string | null = null; let modelPersisted = false; const convId = assistantMessage.convId; + // freeze the POST identity from t0 so a stop cancels with the exact session key, + // never a stale or empty model resolved later + this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel); const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { if (!modelName) return; @@ -1103,7 +1115,7 @@ class ChatStore { }; const updateStreamingUI = () => { - this.setChatStreaming(convId, streamedContent, currentMessageId); + this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel); const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); }; @@ -1111,7 +1123,7 @@ class ChatStore { const cleanupStreamingState = () => { this.setStreamingActive(false); this.setChatLoading(convId, false); - this.clearChatStreaming(convId); + this.clearChatStreaming(convId, currentMessageId); this.setProcessingState(convId, null); }; @@ -1128,7 +1140,7 @@ class ChatStore { onReasoningChunk: (chunk: string) => { streamedReasoningContent += chunk; // mark streaming state so a stop mid-thinking can persist the partial reasoning - this.setChatStreaming(convId, streamedContent, currentMessageId); + this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel); const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { reasoningContent: streamedReasoningContent @@ -1405,7 +1417,7 @@ class ChatStore { // detached drain keeps producing tokens until eos or max_tokens. use the frozen identity // captured when the session started, not the live dropdown const streamStateForStop = this.chatStreamingStates.get(convId); - const modelForStop = streamStateForStop?.model ?? selectedModelName(); + const modelForStop = streamStateForStop?.model; void ChatService.cancelServerStream(convId, modelForStop); this.abortRequest(convId); this.setChatLoading(convId, false); @@ -1846,6 +1858,14 @@ class ChatStore { updateStreamingContent(originalContent + appendedContent); this.setChatReasoning(msg.convId, false); }, + onCompletionId: (id: string) => { + if (!id) return; + // refresh the message id so a later skip targets the live slot after a continue + conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), { + completionId: id + }); + DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {}); + }, onReasoningChunk: (chunk: string) => { appendedReasoning += chunk; hasReceivedContent = true;