fix: unable to get prologue in agent bot (#17299)

### Summary

As title
This commit is contained in:
Haruko386
2026-07-23 19:13:45 +08:00
committed by GitHub
parent efe6bca5b3
commit 5d8dfa610c
9 changed files with 256 additions and 83 deletions

View File

@@ -27,10 +27,8 @@ var (
ErrComponentNotFound = errors.New("dsl: component not found")
// ErrMissingInputForm is returned when the component exists
// but has no `obj.input_form` dict. The python Canvas returns
// None in this case; we surface 102 "component has no
// input_form" instead.
ErrMissingInputForm = errors.New("dsl: component has no input_form")
// but has no `obj.params.inputs` dict.
ErrMissingInputForm = errors.New("dsl: component has no params.inputs")
// ErrMalformedDSL is returned for structural problems — nil
// dsl, missing components map, wrong types. Distinct from

View File

@@ -24,23 +24,21 @@ package dsl
import "fmt"
// ExtractComponentInputForm returns the input-form schema dict stored at
// `dsl["components"][componentID]["obj"]["input_form"]`.
// `dsl["components"][componentID]["obj"]["params"]["inputs"]`.
//
// This is the Go equivalent of the python
// `Canvas.get_component_input_form(component_id)` method
// (api/agent/canvas.py:163) which reads the same path. The python
// version walks the live Canvas object; we walk the raw DSL map
// directly because the Go Canvas type does not expose an
// introspection API (see plan §Gap C — there is no `GetComponent` on
// the runtime Canvas type).
// which calls component.get_input_form(). For the Begin component,
// that returns the live component's `_param.inputs`, initialised from
// the raw DSL `obj.params.inputs`.
//
// Returns:
// - the form-schema dict if present and well-typed
// - ErrComponentNotFound if the componentID is missing from dsl
// - ErrMissingInputForm if the component exists but has no input_form
// - ErrMissingInputForm if the component exists but has no params.inputs
// - ErrMalformedDSL if the field is present but the wrong type
//
// Type errors (input_form is e.g. a list or a string) are NOT
// Type errors (params or inputs is e.g. a list or a string) are NOT
// collapsed into ErrMissingInputForm — they would mask a contract
// violation in the DSL and let DebugComponent run against corrupt
// data. CodeRabbit PR review #1 on PR #16403.
@@ -53,13 +51,21 @@ func ExtractComponentInputForm(dsl map[string]any, componentID string) (map[stri
if !ok {
return nil, fmt.Errorf("%w: component %q has no obj", ErrMalformedDSL, componentID)
}
rawForm, exists := obj["input_form"]
rawParams, exists := obj["params"]
if !exists || rawParams == nil {
return nil, fmt.Errorf("%w: component %q has no params.inputs", ErrMissingInputForm, componentID)
}
params, ok := rawParams.(map[string]any)
if !ok {
return nil, fmt.Errorf("%w: component %q params is not a dict", ErrMalformedDSL, componentID)
}
rawForm, exists := params["inputs"]
if !exists || rawForm == nil {
return nil, fmt.Errorf("%w: component %q has no input_form", ErrMissingInputForm, componentID)
return nil, fmt.Errorf("%w: component %q has no params.inputs", ErrMissingInputForm, componentID)
}
form, ok := rawForm.(map[string]any)
if !ok {
return nil, fmt.Errorf("%w: component %q input_form is not a dict", ErrMalformedDSL, componentID)
return nil, fmt.Errorf("%w: component %q params.inputs is not a dict", ErrMalformedDSL, componentID)
}
return form, nil
}
@@ -165,11 +171,9 @@ func FindBeginComponentID(dsl map[string]any) (string, error) {
return "", fmt.Errorf("%w: Begin component", ErrComponentNotFound)
}
// ExtractPrologue mirrors python Canvas.get_prologue
// (api/agent/canvas.py:190) — returns the "prologue" string stored at
// dsl["components"][<begin_id>]["obj"]["prologue"]. Reuses the
// shared navigateToComponent helper so the addressing rule is
// consistent with ExtractComponentInputForm.
// ExtractPrologue mirrors python Canvas.get_prologue, which returns
// the Begin component's `_param.prologue`. In raw DSL terms this is
// dsl["components"][<begin_id>]["obj"]["params"]["prologue"].
func ExtractPrologue(dsl map[string]any) (string, error) {
id, err := FindBeginComponentID(dsl)
if err != nil {
@@ -180,13 +184,13 @@ func ExtractPrologue(dsl map[string]any) (string, error) {
return "", err
}
obj, _ := comp["obj"].(map[string]any)
s, _ := obj["prologue"].(string)
params, _ := obj["params"].(map[string]any)
s, _ := params["prologue"].(string)
return s, nil
}
// ExtractMode mirrors python Canvas.get_mode (api/agent/canvas.py:200).
// Returns the canvas mode (e.g. "Agent" / "DataFlow") stored at
// dsl["components"][<begin_id>]["obj"]["mode"].
// ExtractMode mirrors python Canvas.get_mode. In raw DSL terms this is
// dsl["components"][<begin_id>]["obj"]["params"]["mode"].
func ExtractMode(dsl map[string]any) (string, error) {
id, err := FindBeginComponentID(dsl)
if err != nil {
@@ -197,6 +201,7 @@ func ExtractMode(dsl map[string]any) (string, error) {
return "", err
}
obj, _ := comp["obj"].(map[string]any)
s, _ := obj["mode"].(string)
params, _ := obj["params"].(map[string]any)
s, _ := params["mode"].(string)
return s, nil
}

View File

@@ -31,10 +31,10 @@ func happyDSL() map[string]any {
"component_name": "Begin",
"params": map[string]any{
"mode": "Manual",
},
"input_form": map[string]any{
"query": map[string]any{
"type": "string",
"inputs": map[string]any{
"query": map[string]any{
"type": "string",
},
},
},
},
@@ -78,7 +78,7 @@ func TestExtractComponentInputForm_MissingObj(t *testing.T) {
}
func TestExtractComponentInputForm_MissingInputForm(t *testing.T) {
// "answer" has obj but no input_form.
// "answer" has obj but no params.inputs.
_, err := ExtractComponentInputForm(happyDSL(), "answer")
if !errors.Is(err, ErrMissingInputForm) {
t.Errorf("err = %v, want ErrMissingInputForm", err)
@@ -219,7 +219,7 @@ func TestFindBeginComponentID_NilDSL(t *testing.T) {
// TestExtractPrologue_HappyPath pins the prologue lookup path.
func TestExtractPrologue_HappyPath(t *testing.T) {
dsl := happyDSL()
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["prologue"] = "hello"
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)["prologue"] = "hello"
got, err := ExtractPrologue(dsl)
if err != nil {
t.Fatalf("err = %v, want nil", err)
@@ -229,6 +229,29 @@ func TestExtractPrologue_HappyPath(t *testing.T) {
}
}
func TestExtractPrologue_FromBeginParams(t *testing.T) {
dsl := map[string]any{
"components": map[string]any{
"begin": map[string]any{
"obj": map[string]any{
"component_name": "Begin",
"params": map[string]any{
"mode": "conversational",
"prologue": "Welcome from params",
},
},
},
},
}
got, err := ExtractPrologue(dsl)
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if got != "Welcome from params" {
t.Errorf("prologue = %q, want Welcome from params", got)
}
}
// TestExtractPrologue_NotFound pins that a missing begin component
// returns ErrComponentNotFound (the service layer turns this into
// empty-string fallback).
@@ -244,7 +267,7 @@ func TestExtractPrologue_NotFound(t *testing.T) {
// TestExtractMode_HappyPath pins the mode lookup path.
func TestExtractMode_HappyPath(t *testing.T) {
dsl := happyDSL()
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["mode"] = "Agent"
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)["mode"] = "Agent"
got, err := ExtractMode(dsl)
if err != nil {
t.Fatalf("err = %v, want nil", err)

View File

@@ -76,7 +76,7 @@ func (h *AgentHandler) GetComponentInputForm(c *gin.Context) {
}
// componentInputForm returns the input-form schema for a single component.
// It first tries the static input_form stored in the DSL; if the component
// It first tries the params.inputs stored in the DSL; if the component
// does not define one (e.g. Agent components that generate it dynamically),
// it instantiates the runtime component and calls its GetInputForm method.
// This mirrors Python's Canvas.get_component_input_form which invokes the
@@ -238,7 +238,7 @@ func mapDSLError(c *gin.Context, componentID string, err error) {
case errors.Is(err, dsl.ErrComponentNotFound):
common.ResponseWithCodeData(c, common.CodeDataError, nil, "component not found: "+componentID)
case errors.Is(err, dsl.ErrMissingInputForm):
common.ResponseWithCodeData(c, common.CodeDataError, nil, "component has no input_form: "+componentID)
common.ResponseWithCodeData(c, common.CodeDataError, nil, "component has no params.inputs: "+componentID)
case errors.Is(err, dsl.ErrMalformedDSL):
common.ResponseWithCodeData(c, common.CodeDataError, nil, "malformed dsl: "+err.Error())
default:

View File

@@ -49,8 +49,7 @@ func componentCtx(t *testing.T, method, path, body string) (*gin.Context, *httpt
}
// beginCanvas returns a UserCanvas whose DSL has a single Begin
// component with both an input_form and a params block. Used for
// happy-path tests.
// component with a params block. Used for happy-path tests.
func beginCanvas() *entity.UserCanvas {
return &entity.UserCanvas{
ID: "c1",
@@ -61,9 +60,9 @@ func beginCanvas() *entity.UserCanvas {
"component_name": "Begin",
"params": map[string]any{
"mode": "Manual",
},
"input_form": map[string]any{
"query": map[string]any{"type": "string"},
"inputs": map[string]any{
"query": map[string]any{"type": "string"},
},
},
},
},
@@ -205,8 +204,8 @@ func TestGetComponentInputForm_BrowserDynamicInputForm(t *testing.T) {
if prompts["name"] != "Prompts" {
t.Errorf("data.prompts.name = %v, want Prompts", prompts["name"])
}
if prompts["type"] != "text" {
t.Errorf("data.prompts.type = %v, want text", prompts["type"])
if prompts["type"] != "paragraph" {
t.Errorf("data.prompts.type = %v, want paragraph", prompts["type"])
}
uploadSources, ok := env.Data["upload_sources"].(map[string]interface{})
if !ok {
@@ -268,7 +267,7 @@ func TestGetComponentInputForm_NoInputForm(t *testing.T) {
"answer": map[string]any{
"obj": map[string]any{
"component_name": "Answer",
// no input_form
// no params.inputs
},
},
},
@@ -287,8 +286,8 @@ func TestGetComponentInputForm_NoInputForm(t *testing.T) {
if code != 102 {
t.Errorf("code = %d, want 102; msg=%q", code, msg)
}
if !strings.Contains(msg, "no input_form") {
t.Errorf("msg = %q, want 'no input_form'", msg)
if !strings.Contains(msg, "no params.inputs") {
t.Errorf("msg = %q, want 'no params.inputs'", msg)
}
}

View File

@@ -96,25 +96,11 @@ func (h *AuthHandler) BetaAuthMiddleware() gin.HandlerFunc {
c.Next()
return
}
// Fall back to beta API token (public bot access). The
// middleware also looks up the APIToken directly so the
// downstream handler can read its DialogID (the real
// agent_id) without re-parsing the Authorization header.
// Mirrors the python
// `APIToken.query(beta=token).dialog_id` lookup in
// bot_api.py:agent_bot_logs.
// Fall back to beta API token (public bot access).
if u, code, err := h.userService.GetUserByBetaAPIToken(ctx, auth); err == nil && code == common.CodeSuccess {
c.Set("user", u)
if tok, terr := h.userService.GetAPITokenByBeta(ctx, auth); terr == nil && tok != nil && tok.DialogID != nil {
// tok.DialogID is *string (nullable in the schema), but
// downstream handlers (GetAgentbotLogs, GetAgentLogs)
// read "agent_id" with agentID.(string) — they cannot
// type-assert a *string. Dereference and gate on nil so a
// row with a NULL dialog_id still surfaces the
// "not bound" sentinel rather than silently leaking the
// pointer (which would later fail the string assertion).
c.Set("agent_id", *tok.DialogID)
c.Set("api_token", tok)
}
c.Next()
return

View File

@@ -256,34 +256,33 @@ func (h *BotHandler) ChatbotCompletion(c *gin.Context) {
}
}
// GetAgentbotLogs GET /api/v1/agentbots/<shared_id>/logs/<message_id>
// GetAgentbotLogs GET /api/v1/agentbots/<agent_id>/logs/<message_id>
//
// Beta-token sibling of GetAgentLogs. The shared/embedded chat
// page's "Thinking" button hits this endpoint because the share
// flow authenticates with a beta APIToken (no session JWT) and
// the regular /api/v1/agents/<id>/logs/<msg> requires @login_required.
// Mirrors python bot_api.py:agent_bot_logs (PR #15238).
//
// The <shared_id> path segment is the value the client passed in
// the URL (typically the beta token in the share flow); the real
// agent_id used to build the Redis key
// (`<agent_id>-<message_id>-logs`) is read from the APIToken
// looked up by the beta middleware and stashed in the gin
// context as "agent_id". The endpoint never trusts the URL
// segment for the data lookup — using the middleware-resolved
// agent_id prevents a probe that swaps a victim's shared_id to
// read another agent's logs.
// Mirrors python bot_api.py:agent_bot_logs.
func (h *BotHandler) GetAgentbotLogs(c *gin.Context) {
if _, code, msg := GetUser(c); code != common.CodeSuccess {
common.ResponseWithCodeData(c, code, nil, msg)
return
}
agentID, _ := c.Get("agent_id")
agentIDStr, _ := agentID.(string)
agentIDStr := c.Param("agent_id")
if agentIDStr == "" {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "agent_id is required")
return
}
boundAgentID, _ := c.Get("agent_id")
boundAgentIDStr, _ := boundAgentID.(string)
if boundAgentIDStr == "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, "API token is not bound to an agent.")
return
}
if boundAgentIDStr != agentIDStr {
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, "API token is not authorized for this agent.")
return
}
messageID := c.Param("message_id")
if messageID == "" {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "message_id is required")

View File

@@ -815,6 +815,46 @@ func TestBotMiddleware_NonBearerRegularToken(t *testing.T) {
}
}
func TestBotMiddleware_BetaTokenBindsAgentID(t *testing.T) {
gin.SetMode(gin.TestMode)
agentID := "agent-bound"
stub := &stubUserTokenResolver{
getUserByBetaAPITokenFn: func(ctx context.Context, auth string) (*entity.User, common.ErrorCode, error) {
if auth != "beta-token" {
t.Errorf("GetUserByBetaAPIToken called with %q, want beta-token", auth)
}
return &entity.User{ID: "u-beta"}, common.CodeSuccess, nil
},
getAPITokenByBetaFn: func(ctx context.Context, auth string) (*entity.APIToken, error) {
if auth != "beta-token" {
t.Errorf("GetAPITokenByBeta called with %q, want beta-token", auth)
}
return &entity.APIToken{DialogID: &agentID}, nil
},
}
r := gin.New()
ah := &AuthHandler{userService: stub}
g := r.Group("/api/v1")
g.Use(ah.BetaAuthMiddleware())
var seenAgentID string
g.GET("/x", func(c *gin.Context) {
rawAgentID, _ := c.Get("agent_id")
seenAgentID, _ = rawAgentID.(string)
c.String(http.StatusOK, "ok")
})
req, _ := http.NewRequest(http.MethodGet, "/api/v1/x", nil)
req.Header.Set("Authorization", "beta-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
if seenAgentID != agentID {
t.Fatalf("agent_id = %q, want %q", seenAgentID, agentID)
}
}
// stubUserTokenResolver implements userTokenResolver for tests.
// Each call site sets only the methods it needs; unset methods
// return safe defaults (CodeUnauthorized so the middleware
@@ -968,14 +1008,13 @@ func TestDownloadAttachment_Unauth(t *testing.T) {
// resolve via GetUserByToken. Both branches must reject
// with 401.
g.Use(func(c *gin.Context) {
ctx := c.Request.Context()
auth := c.GetHeader("Authorization")
if auth == "" {
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, "Authorization required")
c.Abort()
return
}
if u, code, err := stub.GetUserByToken(ctx, auth); err != nil || code != common.CodeSuccess {
if u, code, err := stub.GetUserByToken(c.Request.Context(), auth); err != nil || code != common.CodeSuccess {
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, "Invalid auth credentials")
c.Abort()
return
@@ -1112,13 +1151,7 @@ func inlineRegisterAgentRoutes(g *gin.RouterGroup, h *AgentHandler) {
g.GET("/attachments/:attachment_id/download", h.DownloadAttachment)
}
// TestGetAgentbotLogs_RequiresAgentIDInContext guards PR #15238:
// the shared/embedded "Thinking" endpoint requires the beta
// middleware to have stashed the APIToken.DialogID as "agent_id"
// in the gin context. Without it, the handler cannot build the
// Redis key and must return the "API token is not bound to an
// agent." error — never read the URL's <shared_id> for the lookup.
func TestGetAgentbotLogs_RequiresAgentIDInContext(t *testing.T) {
func TestGetAgentbotLogs_MissingRouteAgentID(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1132,19 +1165,76 @@ func TestGetAgentbotLogs_RequiresAgentIDInContext(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != int(common.CodeArgumentError) {
t.Errorf("code = %d, want %d", resp.Code, common.CodeArgumentError)
}
if !strings.Contains(resp.Message, "agent_id") {
t.Errorf("message = %q, want it to mention 'agent_id'", resp.Message)
}
}
func TestGetAgentbotLogs_RequiresBoundAgentID(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET",
"/api/v1/agentbots/agent-a/logs/msg-1", nil)
c.Set("user", &entity.User{ID: "u1"})
c.Params = gin.Params{
{Key: "agent_id", Value: "agent-a"},
{Key: "message_id", Value: "msg-1"},
}
h := NewBotHandler(nil)
h.GetAgentbotLogs(c)
var resp struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d (CodeDataError)", resp.Code, common.CodeDataError)
t.Errorf("code = %d, want %d", resp.Code, common.CodeDataError)
}
if !strings.Contains(resp.Message, "not bound") {
t.Errorf("message = %q, want it to mention 'not bound'", resp.Message)
}
}
func TestGetAgentbotLogs_CrossAgentDenied(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET",
"/api/v1/agentbots/agent-b/logs/msg-1", nil)
c.Set("user", &entity.User{ID: "u1"})
c.Set("agent_id", "agent-a")
c.Params = gin.Params{
{Key: "agent_id", Value: "agent-b"},
{Key: "message_id", Value: "msg-1"},
}
h := NewBotHandler(nil)
h.GetAgentbotLogs(c)
var resp struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != int(common.CodeUnauthorized) {
t.Errorf("code = %d, want %d", resp.Code, common.CodeUnauthorized)
}
if !strings.Contains(resp.Message, "not authorized") {
t.Errorf("message = %q, want it to mention 'not authorized'", resp.Message)
}
}
// TestGetAgentbotLogs_MissingMessageID asserts the param contract:
// message_id is required (used to build the Redis key).
func TestGetAgentbotLogs_MissingMessageID(t *testing.T) {
@@ -1155,6 +1245,7 @@ func TestGetAgentbotLogs_MissingMessageID(t *testing.T) {
"/api/v1/agentbots/shared-x/logs/", nil)
c.Set("user", &entity.User{ID: "u1"})
c.Set("agent_id", "agent-real")
c.Params = gin.Params{{Key: "agent_id", Value: "agent-real"}}
// Gin's path param extraction returns "" for a missing
// segment so the handler must reject with CodeArgumentError.

View File

@@ -187,6 +187,78 @@ func TestBotService_AgentbotInputs_CrossTenantDenied(t *testing.T) {
}
}
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)
}
}
// 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"