feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
//
|
|
|
|
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
|
|
//
|
|
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
//
|
|
|
|
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
|
// See the License for the specific language governing permissions and
|
|
|
|
|
|
// limitations under the License.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
2026-07-21 10:43:06 +08:00
|
|
|
|
// Tests for the message-building / flag-normalisation helpers used
|
|
|
|
|
|
// by BotService.ChatbotCompletion. A resumed session_id must carry
|
|
|
|
|
|
// prior turns (assistant prologue + earlier user/assistant
|
|
|
|
|
|
// exchanges) into the next pipeline call so multi-turn chatbot
|
|
|
|
|
|
// clients retain context, and the filtering must match python
|
|
|
|
|
|
// async_iframe_completion (drop system turns and the leading
|
|
|
|
|
|
// assistant prologue).
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
|
|
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-30 16:28:48 +08:00
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
"encoding/json"
|
2026-06-30 16:28:48 +08:00
|
|
|
|
"errors"
|
2026-07-21 10:43:06 +08:00
|
|
|
|
"fmt"
|
2026-06-30 16:28:48 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"strings"
|
2026-07-21 10:43:06 +08:00
|
|
|
|
"sync"
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
"testing"
|
|
|
|
|
|
|
2026-06-30 16:28:48 +08:00
|
|
|
|
"ragflow/internal/agent/canvas"
|
|
|
|
|
|
"ragflow/internal/common"
|
|
|
|
|
|
"ragflow/internal/dao"
|
|
|
|
|
|
"ragflow/internal/entity"
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-21 10:43:06 +08:00
|
|
|
|
func TestBuildChatbotPipelineMessages_Empty(t *testing.T) {
|
|
|
|
|
|
// A freshly-seeded session with no prior turns produces just
|
|
|
|
|
|
// the new user question. Matches python conversation_service.
|
|
|
|
|
|
msgs := buildChatbotPipelineMessages(nil, "hi", "msg-1")
|
|
|
|
|
|
if len(msgs) != 1 {
|
|
|
|
|
|
t.Fatalf("nil raw: want 1 message, got %d", len(msgs))
|
|
|
|
|
|
}
|
|
|
|
|
|
if msgs[0]["role"] != "user" || msgs[0]["content"] != "hi" || msgs[0]["id"] != "msg-1" {
|
|
|
|
|
|
t.Errorf("got %+v", msgs[0])
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
2026-07-21 10:43:06 +08:00
|
|
|
|
|
|
|
|
|
|
msgs = buildChatbotPipelineMessages(json.RawMessage(`[]`), "hi", "msg-1")
|
|
|
|
|
|
if len(msgs) != 1 {
|
|
|
|
|
|
t.Fatalf("empty array: want 1 message, got %d", len(msgs))
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 10:43:06 +08:00
|
|
|
|
func TestBuildChatbotPipelineMessages_DropsLeadingAssistantAndSystem(t *testing.T) {
|
|
|
|
|
|
// Prologue (leading assistant) and system turns must not reach
|
|
|
|
|
|
// the pipeline; later assistant turns are kept so the LLM sees
|
|
|
|
|
|
// prior exchanges.
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
turns := []map[string]any{
|
|
|
|
|
|
{"role": "assistant", "content": "Hello, how can I help?", "created_at": 1},
|
2026-07-21 10:43:06 +08:00
|
|
|
|
{"role": "system", "content": "hidden", "created_at": 2},
|
|
|
|
|
|
{"role": "user", "content": "What is Go?", "created_at": 3},
|
|
|
|
|
|
{"role": "assistant", "content": "Go is a compiled language.", "created_at": 4},
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
raw, err := json.Marshal(turns)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("marshal seed: %v", err)
|
|
|
|
|
|
}
|
2026-07-21 10:43:06 +08:00
|
|
|
|
msgs := buildChatbotPipelineMessages(raw, "and Rust?", "msg-2")
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
if len(msgs) != 3 {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("want 3 messages (user, assistant, new user), got %d: %+v", len(msgs), msgs)
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
2026-07-21 10:43:06 +08:00
|
|
|
|
if msgs[0]["role"] != "user" || msgs[0]["content"] != "What is Go?" {
|
|
|
|
|
|
t.Errorf("turn 0: %+v", msgs[0])
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
2026-07-21 10:43:06 +08:00
|
|
|
|
if msgs[1]["role"] != "assistant" || msgs[1]["content"] != "Go is a compiled language." {
|
|
|
|
|
|
t.Errorf("turn 1: %+v", msgs[1])
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
2026-07-21 10:43:06 +08:00
|
|
|
|
if msgs[2]["role"] != "user" || msgs[2]["content"] != "and Rust?" || msgs[2]["id"] != "msg-2" {
|
|
|
|
|
|
t.Errorf("turn 2: %+v", msgs[2])
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 10:43:06 +08:00
|
|
|
|
func TestBuildChatbotPipelineMessages_Malformed(t *testing.T) {
|
|
|
|
|
|
// Malformed JSON must not panic; falls back to just the new
|
|
|
|
|
|
// question rather than failing the request.
|
|
|
|
|
|
msgs := buildChatbotPipelineMessages(json.RawMessage(`not json`), "hi", "msg-3")
|
|
|
|
|
|
if len(msgs) != 1 || msgs[0]["content"] != "hi" {
|
|
|
|
|
|
t.Fatalf("malformed raw: want single user turn, got %+v", msgs)
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 10:43:06 +08:00
|
|
|
|
func TestNormalizeBotBoolFlag(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
in any
|
|
|
|
|
|
value bool
|
|
|
|
|
|
ok bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{true, true, true},
|
|
|
|
|
|
{false, false, true},
|
|
|
|
|
|
{float64(1), true, true},
|
|
|
|
|
|
{float64(0), false, true},
|
|
|
|
|
|
{1, true, true},
|
|
|
|
|
|
{0, false, true},
|
|
|
|
|
|
{nil, false, false},
|
|
|
|
|
|
{"yes", false, false},
|
|
|
|
|
|
{float64(2), false, false},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
|
value, ok := normalizeBotBoolFlag(c.in)
|
|
|
|
|
|
if value != c.value || ok != c.ok {
|
|
|
|
|
|
t.Errorf("normalizeBotBoolFlag(%v) = (%v, %v), want (%v, %v)",
|
|
|
|
|
|
c.in, value, ok, c.value, c.ok)
|
feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary
Ports five Python agent APIs to Go under the v1 Gin router:
- `GET /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions` (SSE)
- `GET /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET /api/v1/agentbots/<agent_id>/inputs`
Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.
## Behavioural parity with Python
| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |
## Verification
```bash
$ go vet ./... # clean (only pre-existing issues)
$ go build ./... # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok ragflow/internal/handler 0.617s
ok ragflow/internal/service 1.729s
ok ragflow/internal/agent/dsl 0.008s
ok ragflow/internal/common 0.087s
ok ragflow/internal/dao 0.083s
```
1199 tests pass across 5 packages.
## Known follow-ups (out of scope for this PR)
- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:52:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 16:28:48 +08:00
|
|
|
|
|
|
|
|
|
|
// TestBotService_AgentbotInputs_CrossTenantDenied mirrors PR
|
|
|
|
|
|
// #15457: when a beta API token authenticates a caller with
|
|
|
|
|
|
// tenantID, that caller must not be able to read an agent
|
|
|
|
|
|
// belonging to a different tenant. The Go guard runs inside
|
|
|
|
|
|
// loadCanvas (called at the entry of AgentbotInputs and
|
|
|
|
|
|
// AgentbotCompletion) and returns ErrUserCanvasNotFound — same
|
|
|
|
|
|
// 404-equivalent shape as the python fix returns "Can't find
|
|
|
|
|
|
// agent by ID: <id>". This test seeds a canvas under tenant-A
|
|
|
|
|
|
// and asks for it via tenant-B; the call must fail with the
|
|
|
|
|
|
// not-found error and never expose the canvas.
|
|
|
|
|
|
func TestBotService_AgentbotInputs_CrossTenantDenied(t *testing.T) {
|
|
|
|
|
|
db := setupServiceTestDB(t)
|
|
|
|
|
|
if err := db.AutoMigrate(
|
|
|
|
|
|
&entity.UserCanvas{},
|
|
|
|
|
|
&entity.UserTenant{},
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
t.Fatalf("migrate: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
orig := dao.DB
|
|
|
|
|
|
dao.DB = db
|
|
|
|
|
|
t.Cleanup(func() { dao.DB = orig })
|
|
|
|
|
|
|
|
|
|
|
|
// Seed tenant-A and a canvas owned by user-A.
|
|
|
|
|
|
if err := db.Create(&entity.UserTenant{
|
|
|
|
|
|
ID: "ut-A",
|
|
|
|
|
|
UserID: "user-A",
|
|
|
|
|
|
TenantID: "tenant-A",
|
|
|
|
|
|
Role: "owner",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed tenant-A: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&entity.UserCanvas{
|
|
|
|
|
|
ID: "agent-victim",
|
|
|
|
|
|
UserID: "user-A",
|
|
|
|
|
|
Title: sptr("Victim Agent"),
|
|
|
|
|
|
CanvasCategory: "agent_canvas",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed victim canvas: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Seed tenant-B (the attacker's tenant).
|
|
|
|
|
|
if err := db.Create(&entity.UserTenant{
|
|
|
|
|
|
ID: "ut-B",
|
|
|
|
|
|
UserID: "user-B",
|
|
|
|
|
|
TenantID: "tenant-B",
|
|
|
|
|
|
Role: "owner",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed tenant-B: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
svc := NewBotService(nil, nil)
|
|
|
|
|
|
|
|
|
|
|
|
// Attacker (tenant-B) asks for victim (tenant-A's canvas).
|
|
|
|
|
|
title, _, _, _, _, code, err := svc.AgentbotInputs(context.Background(),
|
|
|
|
|
|
"tenant-B", "agent-victim")
|
|
|
|
|
|
if !errors.Is(err, dao.ErrUserCanvasNotFound) {
|
|
|
|
|
|
t.Errorf("cross-tenant: want ErrUserCanvasNotFound, got %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if code != common.CodeDataError {
|
|
|
|
|
|
t.Errorf("cross-tenant: want code %d, got %d", common.CodeDataError, code)
|
|
|
|
|
|
}
|
|
|
|
|
|
if title != "" {
|
|
|
|
|
|
t.Errorf("cross-tenant: title should be empty, got %q (data leak)", title)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 19:13:45 +08:00
|
|
|
|
func TestBotService_AgentbotInputsReadsBeginParams(t *testing.T) {
|
|
|
|
|
|
db := setupServiceTestDB(t)
|
|
|
|
|
|
if err := db.AutoMigrate(
|
|
|
|
|
|
&entity.UserCanvas{},
|
|
|
|
|
|
&entity.UserTenant{},
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
t.Fatalf("migrate: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
orig := dao.DB
|
|
|
|
|
|
dao.DB = db
|
|
|
|
|
|
t.Cleanup(func() { dao.DB = orig })
|
|
|
|
|
|
|
|
|
|
|
|
if err := db.Create(&entity.UserTenant{
|
|
|
|
|
|
ID: "ut-owner",
|
|
|
|
|
|
UserID: "owner-1",
|
|
|
|
|
|
TenantID: "tenant-1",
|
|
|
|
|
|
Role: "owner",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed tenant: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
title := "Agent"
|
|
|
|
|
|
avatar := "avatar.png"
|
|
|
|
|
|
if err := db.Create(&entity.UserCanvas{
|
|
|
|
|
|
ID: "agent-1",
|
|
|
|
|
|
UserID: "owner-1",
|
|
|
|
|
|
Title: &title,
|
|
|
|
|
|
Avatar: &avatar,
|
|
|
|
|
|
CanvasCategory: "agent_canvas",
|
|
|
|
|
|
DSL: entity.JSONMap{
|
|
|
|
|
|
"components": map[string]any{
|
|
|
|
|
|
"begin": map[string]any{
|
|
|
|
|
|
"obj": map[string]any{
|
|
|
|
|
|
"component_name": "Begin",
|
|
|
|
|
|
"params": map[string]any{
|
|
|
|
|
|
"mode": "conversational",
|
|
|
|
|
|
"prologue": "Welcome from the agent",
|
|
|
|
|
|
"inputs": map[string]any{
|
|
|
|
|
|
"topic": map[string]any{"type": "line", "name": "Topic"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed canvas: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
svc := NewBotService(nil, nil)
|
|
|
|
|
|
gotTitle, gotAvatar, prologue, mode, inputs, code, err := svc.AgentbotInputs(
|
|
|
|
|
|
context.Background(), "owner-1", "agent-1")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("AgentbotInputs: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if code != common.CodeSuccess {
|
|
|
|
|
|
t.Fatalf("code = %d, want %d", code, common.CodeSuccess)
|
|
|
|
|
|
}
|
|
|
|
|
|
if gotTitle != title || gotAvatar != avatar {
|
|
|
|
|
|
t.Fatalf("title/avatar = %q/%q, want %q/%q", gotTitle, gotAvatar, title, avatar)
|
|
|
|
|
|
}
|
|
|
|
|
|
if prologue != "Welcome from the agent" {
|
|
|
|
|
|
t.Errorf("prologue = %q, want Welcome from the agent", prologue)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mode != "conversational" {
|
|
|
|
|
|
t.Errorf("mode = %q, want conversational", mode)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := inputs["topic"].(map[string]any); !ok {
|
|
|
|
|
|
t.Errorf("inputs = %#v, want topic input", inputs)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 16:28:48 +08:00
|
|
|
|
// TestWriteChatbotRunEvent_UserInputsEvent guards PR #14589: the
|
|
|
|
|
|
// SSE envelope must carry the canvas event type so the front-end
|
|
|
|
|
|
// can distinguish interactive "user_inputs" / "workflow_finished"
|
|
|
|
|
|
// events (which need a UserFillUp form) from plain "message"
|
|
|
|
|
|
// events (assistant text). Without the `event` field the form
|
|
|
|
|
|
// UI never appears and the canvas appears to hang.
|
|
|
|
|
|
func TestWriteChatbotRunEvent_UserInputsEvent(t *testing.T) {
|
|
|
|
|
|
rec := &recordingResponseWriter{header: http.Header{}}
|
|
|
|
|
|
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
|
|
|
|
|
Type: "user_inputs",
|
2026-07-03 13:25:14 +08:00
|
|
|
|
MessageID: "msg-1",
|
2026-06-30 16:28:48 +08:00
|
|
|
|
Data: `{"components":[{"id":"email","type":"text","required":true}]}`,
|
|
|
|
|
|
SessionID: "sess-1",
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteChatbotRunEvent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
body := rec.body.String()
|
|
|
|
|
|
if !strings.Contains(body, `"event":"user_inputs"`) {
|
|
|
|
|
|
t.Errorf("body missing event=user_inputs: %s", body)
|
|
|
|
|
|
}
|
2026-07-03 13:25:14 +08:00
|
|
|
|
if !strings.Contains(body, `"message_id":"msg-1"`) {
|
|
|
|
|
|
t.Errorf("body missing message_id: %s", body)
|
|
|
|
|
|
}
|
2026-07-28 14:59:34 +08:00
|
|
|
|
if !strings.Contains(body, `"task_id":"sess-1"`) {
|
|
|
|
|
|
t.Errorf("body missing session-backed task_id alias: %s", body)
|
2026-07-03 13:25:14 +08:00
|
|
|
|
}
|
2026-06-30 16:28:48 +08:00
|
|
|
|
if !strings.Contains(body, `"session_id":"sess-1"`) {
|
|
|
|
|
|
t.Errorf("body missing session_id: %s", body)
|
|
|
|
|
|
}
|
2026-07-03 13:25:14 +08:00
|
|
|
|
if strings.Contains(body, `"answer":"`) {
|
|
|
|
|
|
t.Errorf("body should not wrap run events in data.answer: %s", body)
|
|
|
|
|
|
}
|
2026-06-30 16:28:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestWriteChatbotRunEvent_WorkflowFinishedEvent covers the second
|
|
|
|
|
|
// new event type from PR #14589. The envelope must also carry
|
|
|
|
|
|
// "workflow_finished" verbatim.
|
|
|
|
|
|
func TestWriteChatbotRunEvent_WorkflowFinishedEvent(t *testing.T) {
|
|
|
|
|
|
rec := &recordingResponseWriter{header: http.Header{}}
|
|
|
|
|
|
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
|
|
|
|
|
Type: "workflow_finished",
|
2026-07-03 13:25:14 +08:00
|
|
|
|
Data: `{"outputs":"done"}`,
|
2026-06-30 16:28:48 +08:00
|
|
|
|
SessionID: "sess-2",
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteChatbotRunEvent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
body := rec.body.String()
|
|
|
|
|
|
if !strings.Contains(body, `"event":"workflow_finished"`) {
|
|
|
|
|
|
t.Errorf("body missing event=workflow_finished: %s", body)
|
|
|
|
|
|
}
|
2026-07-03 13:25:14 +08:00
|
|
|
|
if !strings.Contains(body, `"outputs":"done"`) {
|
|
|
|
|
|
t.Errorf("body missing workflow output payload: %s", body)
|
|
|
|
|
|
}
|
2026-06-30 16:28:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestWriteChatbotRunEvent_MessageEventCarriesEvent ensures the
|
|
|
|
|
|
// existing "message" path also carries the event field. The
|
|
|
|
|
|
// front-end can rely on `data.event` to distinguish message
|
|
|
|
|
|
// frames from user_inputs / workflow_finished frames without
|
|
|
|
|
|
// a separate header.
|
|
|
|
|
|
func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) {
|
|
|
|
|
|
rec := &recordingResponseWriter{header: http.Header{}}
|
|
|
|
|
|
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
|
|
|
|
|
Type: "message",
|
2026-07-03 13:25:14 +08:00
|
|
|
|
MessageID: "msg-3",
|
|
|
|
|
|
Data: `{"content":"hi"}`,
|
2026-06-30 16:28:48 +08:00
|
|
|
|
SessionID: "sess-3",
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteChatbotRunEvent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
body := rec.body.String()
|
|
|
|
|
|
if !strings.Contains(body, `"event":"message"`) {
|
|
|
|
|
|
t.Errorf("message frame should carry event=message: %s", body)
|
|
|
|
|
|
}
|
2026-07-03 13:25:14 +08:00
|
|
|
|
if !strings.Contains(body, `"message_id":"msg-3"`) {
|
|
|
|
|
|
t.Errorf("message frame should carry top-level message_id: %s", body)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(body, `"content":"hi"`) {
|
|
|
|
|
|
t.Errorf("message frame should carry data.content: %s", body)
|
|
|
|
|
|
}
|
2026-06-30 16:28:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 14:59:34 +08:00
|
|
|
|
func TestWriteChatbotRunEvent_ErrorCarriesSessionAlias(t *testing.T) {
|
|
|
|
|
|
rec := &recordingResponseWriter{header: http.Header{}}
|
|
|
|
|
|
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
|
|
|
|
|
Type: "error",
|
|
|
|
|
|
Data: `{"message":"failed"}`,
|
|
|
|
|
|
SessionID: "sess-error",
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("WriteChatbotRunEvent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
body := rec.body.String()
|
|
|
|
|
|
if !strings.Contains(body, `"task_id":"sess-error"`) ||
|
|
|
|
|
|
!strings.Contains(body, `"session_id":"sess-error"`) {
|
|
|
|
|
|
t.Fatalf("error frame missing session-backed aliases: %s", body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 15:30:50 +08:00
|
|
|
|
// 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.
|
2026-07-23 12:15:58 +08:00
|
|
|
|
ctx := t.Context()
|
2026-07-23 21:48:54 +08:00
|
|
|
|
row, derr := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, got[0].SessionID, "dlg-1")
|
2026-07-20 15:30:50 +08:00
|
|
|
|
if derr != nil || row == nil {
|
|
|
|
|
|
t.Fatalf("session not persisted: row=%v err=%v", row, derr)
|
|
|
|
|
|
}
|
2026-07-21 10:43:06 +08:00
|
|
|
|
msgs := parseChatbotTurns(row.Message)
|
|
|
|
|
|
if len(msgs) != 1 || msgs[0]["role"] != "assistant" || msgs[0]["content"] != prologue {
|
2026-07-20 15:30:50 +08:00
|
|
|
|
t.Errorf("persisted messages = %+v, want single prologue assistant turn", msgs)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 10:43:06 +08:00
|
|
|
|
func TestParseChatbotTurns(t *testing.T) {
|
|
|
|
|
|
// Empty / malformed input must yield an empty (non-nil) slice so
|
|
|
|
|
|
// callers can always append.
|
|
|
|
|
|
for _, raw := range []json.RawMessage{
|
|
|
|
|
|
nil,
|
|
|
|
|
|
json.RawMessage(`[]`),
|
|
|
|
|
|
json.RawMessage(`null`),
|
|
|
|
|
|
json.RawMessage(`not json`),
|
|
|
|
|
|
} {
|
|
|
|
|
|
turns := parseChatbotTurns(raw)
|
|
|
|
|
|
if turns == nil {
|
|
|
|
|
|
t.Errorf("parseChatbotTurns(%q) returned a nil slice", string(raw))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(turns) != 0 {
|
|
|
|
|
|
t.Errorf("parseChatbotTurns(%q) = %+v, want empty", string(raw), turns)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
turns := parseChatbotTurns(json.RawMessage(`[{"role":"user","content":"hi"}]`))
|
|
|
|
|
|
if len(turns) != 1 || turns[0]["role"] != "user" || turns[0]["content"] != "hi" {
|
|
|
|
|
|
t.Errorf("valid input: got %+v", turns)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestReferenceOrEmpty(t *testing.T) {
|
|
|
|
|
|
got := referenceOrEmpty(nil)
|
|
|
|
|
|
if got == nil || len(got) != 0 {
|
|
|
|
|
|
t.Errorf("nil reference must yield an empty non-nil map, got %v", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
ref := map[string]any{"chunks": []any{map[string]any{"chunk_id": "c1"}}}
|
|
|
|
|
|
got = referenceOrEmpty(ref)
|
|
|
|
|
|
chunks, _ := got["chunks"].([]any)
|
|
|
|
|
|
if len(chunks) != 1 {
|
|
|
|
|
|
t.Errorf("non-nil reference must pass through, got %+v", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPersistChatbotTurn_AppendsPairAndReference covers the turn
|
|
|
|
|
|
// persistence after a completed stream: the user/assistant pair is
|
|
|
|
|
|
// appended to the existing history, shares one message id, and the
|
|
|
|
|
|
// retrieval reference is appended to the reference list.
|
|
|
|
|
|
func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
|
|
|
|
|
|
db := setupServiceTestDB(t)
|
|
|
|
|
|
pushServiceDB(t, db)
|
|
|
|
|
|
|
|
|
|
|
|
seed, _ := json.Marshal([]map[string]any{
|
|
|
|
|
|
{"role": "assistant", "content": "Hello!", "created_at": 1},
|
|
|
|
|
|
})
|
|
|
|
|
|
sess := &entity.API4Conversation{
|
|
|
|
|
|
ID: "sess-p1",
|
|
|
|
|
|
DialogID: "dlg-p1",
|
|
|
|
|
|
UserID: "tenant-1",
|
|
|
|
|
|
Message: seed,
|
|
|
|
|
|
}
|
2026-07-23 12:15:58 +08:00
|
|
|
|
ctx := t.Context()
|
2026-07-23 21:48:54 +08:00
|
|
|
|
if err := dao.NewAPI4ConversationDAO().Create(ctx, dao.DB, sess); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("seed session: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
svc := NewBotService(nil, nil)
|
|
|
|
|
|
ref := map[string]any{
|
|
|
|
|
|
"chunks": []any{map[string]any{"chunk_id": "c1"}},
|
|
|
|
|
|
"doc_aggs": []any{},
|
|
|
|
|
|
}
|
2026-07-23 12:15:58 +08:00
|
|
|
|
if err := svc.persistChatbotTurn(ctx, sess, "What is Go?", "A language.", "msg-p1", ref); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("persistChatbotTurn: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 21:48:54 +08:00
|
|
|
|
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p1", "dlg-p1")
|
2026-07-21 10:43:06 +08:00
|
|
|
|
if err != nil || row == nil {
|
|
|
|
|
|
t.Fatalf("re-read session: row=%v err=%v", row, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
turns := parseChatbotTurns(row.Message)
|
|
|
|
|
|
if len(turns) != 3 {
|
|
|
|
|
|
t.Fatalf("want 3 turns (prologue + pair), got %d: %+v", len(turns), turns)
|
|
|
|
|
|
}
|
|
|
|
|
|
// The Q&A pair shares the message id so the exchange is addressed
|
|
|
|
|
|
// as a unit, matching the in-app chat pairing convention.
|
|
|
|
|
|
if turns[1]["role"] != "user" || turns[1]["content"] != "What is Go?" || turns[1]["id"] != "msg-p1" {
|
|
|
|
|
|
t.Errorf("user turn: %+v", turns[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
if turns[2]["role"] != "assistant" || turns[2]["content"] != "A language." || turns[2]["id"] != "msg-p1" {
|
|
|
|
|
|
t.Errorf("assistant turn: %+v", turns[2])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var refs []map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(row.Reference, &refs); err != nil {
|
|
|
|
|
|
t.Fatalf("reference decode: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(refs) != 1 {
|
|
|
|
|
|
t.Fatalf("want 1 reference entry, got %d: %+v", len(refs), refs)
|
|
|
|
|
|
}
|
|
|
|
|
|
chunks, _ := refs[0]["chunks"].([]any)
|
|
|
|
|
|
if len(chunks) != 1 {
|
|
|
|
|
|
t.Errorf("reference chunks: %+v", refs[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPersistChatbotTurn_NilReferenceDefaultsToEmpty ensures a turn
|
|
|
|
|
|
// without retrieval results still records an empty reference object,
|
|
|
|
|
|
// keeping the reference list index-aligned with the turn pairs.
|
|
|
|
|
|
func TestPersistChatbotTurn_NilReferenceDefaultsToEmpty(t *testing.T) {
|
|
|
|
|
|
db := setupServiceTestDB(t)
|
|
|
|
|
|
pushServiceDB(t, db)
|
|
|
|
|
|
|
2026-07-23 12:15:58 +08:00
|
|
|
|
ctx := t.Context()
|
2026-07-21 10:43:06 +08:00
|
|
|
|
sess := &entity.API4Conversation{ID: "sess-p2", DialogID: "dlg-p1", UserID: "tenant-1"}
|
2026-07-23 21:48:54 +08:00
|
|
|
|
if err := dao.NewAPI4ConversationDAO().Create(ctx, dao.DB, sess); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("seed session: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
svc := NewBotService(nil, nil)
|
2026-07-23 12:15:58 +08:00
|
|
|
|
if err := svc.persistChatbotTurn(ctx, sess, "q", "a", "msg-p2", nil); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("persistChatbotTurn: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 21:48:54 +08:00
|
|
|
|
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p2", "dlg-p1")
|
2026-07-21 10:43:06 +08:00
|
|
|
|
if err != nil || row == nil {
|
|
|
|
|
|
t.Fatalf("re-read session: row=%v err=%v", row, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
var refs []map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(row.Reference, &refs); err != nil {
|
|
|
|
|
|
t.Fatalf("reference decode: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(refs) != 1 {
|
|
|
|
|
|
t.Fatalf("want 1 reference entry, got %+v", refs)
|
|
|
|
|
|
}
|
|
|
|
|
|
if chunks, ok := refs[0]["chunks"].([]any); !ok || len(chunks) != 0 {
|
|
|
|
|
|
t.Errorf("default reference must carry empty chunks: %+v", refs[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
if aggs, ok := refs[0]["doc_aggs"].([]any); !ok || len(aggs) != 0 {
|
|
|
|
|
|
t.Errorf("default reference must carry empty doc_aggs: %+v", refs[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPersistChatbotTurn_ConcurrentSameSession guards the
|
|
|
|
|
|
// read-modify-write race on session.Message: two requests persisting
|
|
|
|
|
|
// to the same session_id at the same time must both land — without
|
|
|
|
|
|
// the persist lock + re-read the last Update would silently drop one
|
|
|
|
|
|
// exchange.
|
|
|
|
|
|
func TestPersistChatbotTurn_ConcurrentSameSession(t *testing.T) {
|
|
|
|
|
|
db := setupServiceTestDB(t)
|
|
|
|
|
|
pushServiceDB(t, db)
|
|
|
|
|
|
// In-memory sqlite opens a fresh database per connection, so pin
|
|
|
|
|
|
// the pool to a single connection for the concurrent goroutines.
|
|
|
|
|
|
sqlDB, err := db.DB()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("db handle: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
sqlDB.SetMaxOpenConns(1)
|
|
|
|
|
|
|
2026-07-23 12:15:58 +08:00
|
|
|
|
ctx := t.Context()
|
2026-07-21 10:43:06 +08:00
|
|
|
|
sess := &entity.API4Conversation{ID: "sess-p3", DialogID: "dlg-p1", UserID: "tenant-1"}
|
2026-07-23 21:48:54 +08:00
|
|
|
|
if err = dao.NewAPI4ConversationDAO().Create(ctx, dao.DB, sess); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("seed session: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
svc := NewBotService(nil, nil)
|
|
|
|
|
|
// Both callers hold the same stale snapshot loaded before their
|
|
|
|
|
|
// streams started — exactly the race the lock fixes.
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
for i, turn := range [][2]string{{"q1", "a1"}, {"q2", "a2"}} {
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
|
go func(i int, q, a string) {
|
|
|
|
|
|
defer wg.Done()
|
2026-07-23 12:15:58 +08:00
|
|
|
|
if err := svc.persistChatbotTurn(ctx, sess, q, a, fmt.Sprintf("msg-p3-%d", i), nil); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Errorf("persistChatbotTurn %d: %v", i, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}(i, turn[0], turn[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
2026-07-23 21:48:54 +08:00
|
|
|
|
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p3", "dlg-p1")
|
2026-07-21 10:43:06 +08:00
|
|
|
|
if err != nil || row == nil {
|
|
|
|
|
|
t.Fatalf("re-read session: row=%v err=%v", row, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if turns := parseChatbotTurns(row.Message); len(turns) != 4 {
|
|
|
|
|
|
t.Fatalf("want both exchanges persisted (4 turns), got %d: %+v", len(turns), turns)
|
|
|
|
|
|
}
|
|
|
|
|
|
var refs []map[string]any
|
2026-07-23 12:15:58 +08:00
|
|
|
|
if err = json.Unmarshal(row.Reference, &refs); err != nil {
|
2026-07-21 10:43:06 +08:00
|
|
|
|
t.Fatalf("reference decode: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(refs) != 2 {
|
|
|
|
|
|
t.Fatalf("want 2 reference entries, got %d: %+v", len(refs), refs)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 16:28:48 +08:00
|
|
|
|
// recordingResponseWriter is a minimal http.ResponseWriter stub
|
|
|
|
|
|
// for SSE frame tests. Tracks writes so the test can assert the
|
|
|
|
|
|
// emitted frame contents.
|
|
|
|
|
|
type recordingResponseWriter struct {
|
|
|
|
|
|
header http.Header
|
|
|
|
|
|
body bytes.Buffer
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (r *recordingResponseWriter) Header() http.Header {
|
|
|
|
|
|
return r.header
|
|
|
|
|
|
}
|
|
|
|
|
|
func (r *recordingResponseWriter) Write(b []byte) (int, error) {
|
|
|
|
|
|
return r.body.Write(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (r *recordingResponseWriter) WriteHeader(_ int) {}
|