From 624b4b03f3ea6cc6512576595117d44689fd2646 Mon Sep 17 00:00:00 2001 From: euvre <93761161+euvre@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:30:50 +0800 Subject: [PATCH] fix[go]: skip LLM call for shared chatbot session handshake (#17095) --- internal/service/bot_completion.go | 25 ++++++- .../service/bot_completion_history_test.go | 75 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/internal/service/bot_completion.go b/internal/service/bot_completion.go index f045c2f2f6..b83a0553a8 100644 --- a/internal/service/bot_completion.go +++ b/internal/service/bot_completion.go @@ -264,8 +264,10 @@ func WriteAgentbotFrame(w http.ResponseWriter, f ChatbotSSEFrame) error { // // The full LLM session-lifecycle implementation is added below. It // is a v1 port: it yields a single frame per turn (the Go LLMBundle -// chat call is non-streaming), seeded with the dialog's prologue -// when the request creates a new session. +// chat call is non-streaming). A request without session_id only +// creates the prologue-seeded session and streams the prologue back; +// the LLM runs exclusively for follow-up turns that carry a +// session_id. // // Authorisation: dialog must exist, belong to the requester's tenant, // and have status == common.StatusDialogValid. @@ -336,6 +338,25 @@ func (s *BotService) ChatbotCompletion( if err = s.api4ConversationDAO.Create(session); err != nil { return nil, common.CodeServerError, err } + + // Mirror python async_iframe_completion + // (conversation_service.py:324-334): a request without a + // session_id is the share page's opening handshake — the + // front-end sends an empty question only to obtain a session. + // Persist the prologue-seeded session and stream the prologue + // back WITHOUT invoking the LLM; running the model here would + // fabricate a reply to a message the user never sent. + out := make(chan ChatbotSSEFrame, 2) + go func() { + defer close(out) + out <- ChatbotSSEFrame{ + Data: prologue, + Reference: map[string]any{}, + SessionID: session.ID, + } + out <- ChatbotSSEFrame{Done: true} + }() + return out, common.CodeSuccess, nil } // 3. Resolve the chat LLM via ModelProviderService. The python diff --git a/internal/service/bot_completion_history_test.go b/internal/service/bot_completion_history_test.go index a0989c9e5d..a64c04701b 100644 --- a/internal/service/bot_completion_history_test.go +++ b/internal/service/bot_completion_history_test.go @@ -322,6 +322,81 @@ func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) { } } +// TestBotService_ChatbotCompletion_NewSessionSkipsLLM locks in the +// share-page handshake behaviour: the front-end opens a shared chat +// with an empty question and no session_id only to obtain a session +// (web/src/pages/next-chats/hooks/use-send-shared-message.ts +// fetchSessionId). Mirrors python async_iframe_completion +// (conversation_service.py:324-334): the server must create the +// prologue-seeded session and stream the prologue back WITHOUT +// invoking the LLM. The previous Go implementation ran the model +// with an empty user turn, so merely opening a share link produced +// a fabricated "you sent a blank message" reply. +func TestBotService_ChatbotCompletion_NewSessionSkipsLLM(t *testing.T) { + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&entity.Chat{}); err != nil { + t.Fatalf("migrate chat: %v", err) + } + pushServiceDB(t, db) + + prologue := "你好!我是你的AI助手。" + if err := db.Create(&entity.Chat{ + ID: "dlg-1", + TenantID: "tenant-1", + Name: sptr("bot"), + LLMSetting: entity.JSONMap{}, + PromptType: "simple", + PromptConfig: entity.JSONMap{"prologue": prologue}, + KBIDs: entity.JSONSlice{}, + Status: sptr(common.StatusDialogValid), + }).Error; err != nil { + t.Fatalf("seed dialog: %v", err) + } + + // llmService is nil — any attempt to reach the LLM path would + // fail loudly, so a successful run proves the LLM was skipped. + svc := NewBotService(nil, nil) + frames, code, err := svc.ChatbotCompletion(context.Background(), + "tenant-1", "dlg-1", ChatbotCompletionRequest{Question: ""}) + if err != nil { + t.Fatalf("ChatbotCompletion: %v", err) + } + if code != common.CodeSuccess { + t.Fatalf("want code %d, got %d", common.CodeSuccess, code) + } + + var got []ChatbotSSEFrame + for f := range frames { + got = append(got, f) + } + if len(got) != 2 { + t.Fatalf("want 2 frames (prologue + done), got %d: %+v", len(got), got) + } + if got[0].Err != nil { + t.Errorf("frame 0 unexpected error: %v", got[0].Err) + } + if got[0].Data != prologue { + t.Errorf("frame 0 data = %q, want prologue %q", got[0].Data, prologue) + } + if got[0].SessionID == "" { + t.Errorf("frame 0 must carry the new session_id") + } + if !got[1].Done { + t.Errorf("frame 1 must be the done marker") + } + + // The persisted session must hold exactly the prologue turn — + // no empty user message may be written. + row, derr := dao.NewAPI4ConversationDAO().GetBySessionID(got[0].SessionID, "dlg-1") + if derr != nil || row == nil { + t.Fatalf("session not persisted: row=%v err=%v", row, derr) + } + msgs := historyToMessages(row.Message) + if len(msgs) != 1 || msgs[0].Role != "assistant" || msgs[0].Content != prologue { + t.Errorf("persisted messages = %+v, want single prologue assistant turn", msgs) + } +} + // recordingResponseWriter is a minimal http.ResponseWriter stub // for SSE frame tests. Tracks writes so the test can assert the // emitted frame contents.