From 5d8dfa610c518ad115266802de9e278e92489100 Mon Sep 17 00:00:00 2001 From: Haruko386 Date: Thu, 23 Jul 2026 19:13:45 +0800 Subject: [PATCH] fix: unable to get prologue in agent bot (#17299) ### Summary As title --- internal/agent/dsl/errors.go | 6 +- internal/agent/dsl/extract.go | 47 ++++---- internal/agent/dsl/extract_test.go | 37 ++++-- internal/handler/agent_component.go | 4 +- internal/handler/agent_component_test.go | 19 ++- internal/handler/auth.go | 16 +-- internal/handler/bot.go | 27 ++--- internal/handler/bot_test.go | 111 ++++++++++++++++-- .../service/bot_completion_history_test.go | 72 ++++++++++++ 9 files changed, 256 insertions(+), 83 deletions(-) diff --git a/internal/agent/dsl/errors.go b/internal/agent/dsl/errors.go index ac09861b98..bed37d1a2f 100644 --- a/internal/agent/dsl/errors.go +++ b/internal/agent/dsl/errors.go @@ -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 diff --git a/internal/agent/dsl/extract.go b/internal/agent/dsl/extract.go index f0c6a5254b..6358dc3ff5 100644 --- a/internal/agent/dsl/extract.go +++ b/internal/agent/dsl/extract.go @@ -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"][]["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"][]["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"][]["obj"]["mode"]. +// ExtractMode mirrors python Canvas.get_mode. In raw DSL terms this is +// dsl["components"][]["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 } diff --git a/internal/agent/dsl/extract_test.go b/internal/agent/dsl/extract_test.go index db66d7519f..8f353e503e 100644 --- a/internal/agent/dsl/extract_test.go +++ b/internal/agent/dsl/extract_test.go @@ -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) diff --git a/internal/handler/agent_component.go b/internal/handler/agent_component.go index d3115221e5..9cb0ae16fa 100644 --- a/internal/handler/agent_component.go +++ b/internal/handler/agent_component.go @@ -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: diff --git a/internal/handler/agent_component_test.go b/internal/handler/agent_component_test.go index 1b0bd65ab0..7980b63fc5 100644 --- a/internal/handler/agent_component_test.go +++ b/internal/handler/agent_component_test.go @@ -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) } } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 0f41486573..904a4abbd7 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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 diff --git a/internal/handler/bot.go b/internal/handler/bot.go index 09dabf5a0d..5eeb07c969 100644 --- a/internal/handler/bot.go +++ b/internal/handler/bot.go @@ -256,34 +256,33 @@ func (h *BotHandler) ChatbotCompletion(c *gin.Context) { } } -// GetAgentbotLogs GET /api/v1/agentbots//logs/ +// GetAgentbotLogs GET /api/v1/agentbots//logs/ // // 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//logs/ requires @login_required. -// Mirrors python bot_api.py:agent_bot_logs (PR #15238). -// -// The 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 -// (`--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") diff --git a/internal/handler/bot_test.go b/internal/handler/bot_test.go index c0664e1c6e..98fc6eaefa 100644 --- a/internal/handler/bot_test.go +++ b/internal/handler/bot_test.go @@ -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 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. diff --git a/internal/service/bot_completion_history_test.go b/internal/service/bot_completion_history_test.go index a8db570656..684d8d46cf 100644 --- a/internal/service/bot_completion_history_test.go +++ b/internal/service/bot_completion_history_test.go @@ -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"