diff --git a/internal/agent/component/agent.go b/internal/agent/component/agent.go index e2d18bad42..fd5edaa8c1 100644 --- a/internal/agent/component/agent.go +++ b/internal/agent/component/agent.go @@ -93,6 +93,8 @@ type AgentParam struct { BaseURL string } +const agentUserPromptSchemaDefault = "This is the order you need to send to the agent." + // AgentMeta declares the OpenAI-style function-call interface for the // Agent component. Mirrors ragflow Python's ToolMeta shape. type AgentMeta struct { @@ -394,6 +396,10 @@ func (c *AgentComponent) Name() string { return "Agent" } // the output map. func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) { p := mergeAgentParam(c.param, inputs) + hasRuntimeUserPrompt := false + if v, ok := stringFrom(inputs, "user_prompt"); ok { + hasRuntimeUserPrompt = !shouldFallbackToSysQuery(v) + } // v3.6.1: derive the driver and bare model name from the // composite llm_id when the Agent DSL didn't set `driver`. The @@ -418,6 +424,30 @@ func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map } p.APIKey, p.BaseURL = resolveTenantLLMConfig(ctx, p.Driver, p.ModelID, p.APIKey, p.BaseURL, originalModelID) + var state *runtime.CanvasState + if s, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && s != nil { + state = s + if resolved, rerr := runtime.ResolveTemplate(p.SystemPrompt, state); resolved != p.SystemPrompt || rerr == nil { + p.SystemPrompt = resolved + if rerr != nil { + common.Debug("agent: resolve system_prompt", zap.Error(rerr)) + } + } + if resolved, rerr := runtime.ResolveTemplate(p.UserPrompt, state); resolved != p.UserPrompt || rerr == nil { + p.UserPrompt = resolved + if rerr != nil { + common.Debug("agent: resolve user_prompt", zap.Error(rerr)) + } + } + } + if hasRuntimeUserPrompt { + p.UserPrompt = formatAgentRuntimePrompt(inputs, p.UserPrompt) + } else if shouldFallbackToSysQuery(p.UserPrompt) && state != nil { + if query, ok := stringFromState(state, "query"); ok { + p.UserPrompt = query + } + } + if p.ModelID == "" { return nil, &ParamError{Field: "model_id", Reason: "required"} } @@ -586,10 +616,7 @@ func buildAgentChatModel(p AgentParam) (*models.EinoChatModel, error) { if driver == "" { driver = "dummy" } - var baseURL map[string]string - if p.BaseURL != "" { - baseURL = map[string]string{"default": p.BaseURL} - } + baseURL := baseURLMapForDriver(driver, p.BaseURL) // urlSuffix: see chatURLSuffixFor in llm.go for the rationale. // The factory's NewModelDriver stores URLSuffix verbatim; the // driver then appends URLSuffix.Chat to baseURL to build the @@ -696,6 +723,95 @@ func extractToolCalls(msg *schema.Message) []map[string]any { return calls } +// promptMessagesFromParams extracts the Python DSL `prompts` list into +// the single system/user prompt shape supported by the Go ReAct runner. +func promptMessagesFromParams(params map[string]any) (systemPrompt, userPrompt string, ok bool) { + raw, exists := params["prompts"] + if !exists { + return "", "", false + } + switch v := raw.(type) { + case string: + return "", v, true + case []any: + var systems, users []string + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + content, ok := stringFrom(m, "content") + if !ok { + continue + } + role, _ := stringFrom(m, "role") + switch strings.ToLower(strings.TrimSpace(role)) { + case "system": + systems = append(systems, content) + case "user", "": + users = append(users, content) + } + } + if len(systems) == 0 && len(users) == 0 { + return "", "", false + } + return strings.Join(systems, "\n"), strings.Join(users, "\n"), true + case []map[string]any: + items := make([]any, 0, len(v)) + for _, item := range v { + items = append(items, item) + } + return promptMessagesFromParams(map[string]any{"prompts": items}) + } + return "", "", false +} + +func appendPromptText(base, extra string) string { + if strings.TrimSpace(extra) == "" { + return base + } + if strings.TrimSpace(base) == "" { + return extra + } + return base + "\n" + extra +} + +func hasNonEmptyString(inputs map[string]any, name string) bool { + v, ok := stringFrom(inputs, name) + return ok && strings.TrimSpace(v) != "" +} + +func shouldFallbackToSysQuery(prompt string) bool { + p := strings.TrimSpace(prompt) + return p == "" || p == agentUserPromptSchemaDefault +} + +func stringFromState(state *runtime.CanvasState, name string) (string, bool) { + if state == nil { + return "", false + } + v, ok := state.Sys[name].(string) + if !ok || strings.TrimSpace(v) == "" { + return "", false + } + return v, true +} + +func formatAgentRuntimePrompt(inputs map[string]any, userPrompt string) string { + var b strings.Builder + if reasoning, ok := stringFrom(inputs, "reasoning"); ok && reasoning != "" { + fmt.Fprintf(&b, "\nREASONING:\n%s\n", reasoning) + } + if contextText, ok := stringFrom(inputs, "context"); ok && contextText != "" { + fmt.Fprintf(&b, "\nCONTEXT:\n%s\n", contextText) + } + if b.Len() == 0 { + return userPrompt + } + fmt.Fprintf(&b, "\nQUERY:\n%s\n", userPrompt) + return b.String() +} + // mergeAgentParam layers raw inputs over the receiver's default param set. // // v1 aliases accepted alongside the v2 names: "llm_id" → "model_id", @@ -714,7 +830,13 @@ func mergeAgentParam(base AgentParam, inputs map[string]any) AgentParam { } else if v, ok := stringFrom(inputs, "sys_prompt"); ok { p.SystemPrompt = v } - if v, ok := stringFrom(inputs, "user_prompt"); ok { + if promptSystem, promptUser, ok := promptMessagesFromParams(inputs); ok { + p.SystemPrompt = appendPromptText(p.SystemPrompt, promptSystem) + if strings.TrimSpace(promptUser) != "" { + p.UserPrompt = promptUser + } + } + if v, ok := stringFrom(inputs, "user_prompt"); ok && !shouldFallbackToSysQuery(v) { p.UserPrompt = v } if v, ok := floatFrom(inputs, "top_p"); ok { @@ -807,7 +929,11 @@ func init() { } else if v, ok := stringFrom(params, "sys_prompt"); ok { p.SystemPrompt = v } - if v, ok := stringFrom(params, "user_prompt"); ok { + if promptSystem, promptUser, ok := promptMessagesFromParams(params); ok { + p.SystemPrompt = appendPromptText(p.SystemPrompt, promptSystem) + p.UserPrompt = promptUser + } + if v, ok := stringFrom(params, "user_prompt"); ok && p.UserPrompt == "" { p.UserPrompt = v } if v, ok := floatFrom(params, "top_p"); ok { diff --git a/internal/agent/component/agent_test.go b/internal/agent/component/agent_test.go index 4434205225..f801d89960 100644 --- a/internal/agent/component/agent_test.go +++ b/internal/agent/component/agent_test.go @@ -28,6 +28,7 @@ import ( "github.com/cloudwego/eino/flow/agent/react" "github.com/cloudwego/eino/schema" + "ragflow/internal/agent/runtime" agenttool "ragflow/internal/agent/tool" ) @@ -69,6 +70,89 @@ func TestAgent_NoToolsReAct(t *testing.T) { } } +func TestAgent_ResolvesUserPromptFromCanvasState(t *testing.T) { + var gotPrompt string + withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) { + gotPrompt = p.UserPrompt + return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil + }) + + state := runtime.NewCanvasState("run-1", "task-1") + state.Sys["query"] = "what is marigold" + ctx := runtime.WithState(context.Background(), state) + + c := NewAgentComponent(AgentParam{ + ModelID: "stub", + APIKey: "test-key", + UserPrompt: "Question: {sys.query}", + MaxRounds: 1, + }) + if _, err := c.Invoke(ctx, nil); err != nil { + t.Fatalf("Invoke: %v", err) + } + if gotPrompt != "Question: what is marigold" { + t.Fatalf("runner prompt = %q, want resolved sys.query", gotPrompt) + } +} + +func TestAgent_UsesPromptsListForSysQuery(t *testing.T) { + var gotPrompt string + withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) { + gotPrompt = p.UserPrompt + return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil + }) + + cmp, err := New("Agent", map[string]any{ + "model_id": "stub", + "api_key": "test-key", + "sys_prompt": "act as assistant", + "user_prompt": "This is the order you need to send to the agent.", + "prompts": []any{ + map[string]any{"role": "user", "content": "{sys.query}"}, + }, + }) + if err != nil { + t.Fatalf("New(Agent): %v", err) + } + + state := runtime.NewCanvasState("run-1", "task-1") + state.Sys["query"] = "用户真正的问题" + ctx := runtime.WithState(context.Background(), state) + + if _, err := cmp.Invoke(ctx, nil); err != nil { + t.Fatalf("Invoke: %v", err) + } + if gotPrompt != "用户真正的问题" { + t.Fatalf("runner prompt = %q, want sys.query from prompts list", gotPrompt) + } +} + +func TestAgent_FormatsRuntimePromptLikePython(t *testing.T) { + var gotPrompt string + withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) { + gotPrompt = p.UserPrompt + return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil + }) + + c := NewAgentComponent(AgentParam{ + ModelID: "stub", + APIKey: "test-key", + MaxRounds: 1, + }) + if _, err := c.Invoke(context.Background(), map[string]any{ + "user_prompt": "write answer", + "reasoning": "selected because it can answer", + "context": "known facts", + }); err != nil { + t.Fatalf("Invoke: %v", err) + } + + want := "\nREASONING:\nselected because it can answer\n\nCONTEXT:\nknown facts\n\nQUERY:\nwrite answer\n" + if gotPrompt != want { + t.Fatalf("runner prompt = %q, want %q", gotPrompt, want) + } +} + func TestAgent_ToolCallRound(t *testing.T) { var calls int withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) { diff --git a/internal/agent/component/llm.go b/internal/agent/component/llm.go index 46ccfe238a..3a9ad8c1de 100644 --- a/internal/agent/component/llm.go +++ b/internal/agent/component/llm.go @@ -218,14 +218,7 @@ func (e *einoChatInvoker) Invoke(ctx context.Context, req ChatInvokeRequest) (*C if driver == "" { driver = "dummy" } - // baseURL: drivers consult map["default"] as the canonical endpoint - // (see internal/entity/models/base_model.go:GetBaseURL). When the - // caller did not override, leave the driver default in place by - // passing nil — every driver seeds its own map at construction time. - var baseURL map[string]string - if req.BaseURL != "" { - baseURL = map[string]string{"default": req.BaseURL} - } + baseURL := baseURLMapForDriver(driver, req.BaseURL) // urlSuffix: each driver appends URLSuffix.Chat to baseURL to form // the chat-completions endpoint (e.g. "chat/completions" for // openai-compatible drivers, "v1/messages" for anthropic). The @@ -333,6 +326,25 @@ func chatURLSuffixFor(driver string) models.URLSuffix { } } +func baseURLMapForDriver(driver, override string) map[string]string { + if override != "" { + return map[string]string{"default": override} + } + pm := models.GetProviderManager() + if pm == nil { + return nil + } + provider := pm.FindProvider(driver) + if provider == nil || len(provider.URL) == 0 { + return nil + } + baseURL := make(map[string]string, len(provider.URL)) + for region, url := range provider.URL { + baseURL[region] = url + } + return baseURL +} + // NewLLMComponent builds an LLMComponent from raw params. func NewLLMComponent(p LLMParam) *LLMComponent { return &LLMComponent{param: p} diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 121b4b83d8..7ed78d1b37 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -1094,19 +1094,14 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) { c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") - // SSE wire format is the unified python envelope used by both - // /api/v1/agents/chat/completions and /api/v1/agentbots//completions. - // One frame per canvas event, all routed through - // service.WriteChatbotRunEvent so the two paths share one writer - // and one shape — see internal/service/bot_completion.go for the - // frame definition. The same unified envelope is used by the - // /api/v1/agents/{canvas_id}/run and /api/v1/agentbots//completions - // endpoints, all going through service.WriteChatbotRunEvent. The - // channel close is signalled by `data: [DONE]\n\n`. We do NOT emit - // an SSE `event:` line — the front-end's `use-send-message.ts` + // SSE wire format is the flat Python agent-canvas envelope: + // {event,message_id,task_id,session_id,created_at,data}. One + // frame is emitted per canvas event through service.WriteChatbotRunEvent. + // The channel close is signalled by `data: [DONE]\n\n`. We do NOT + // emit an SSE `event:` line — the front-end's use-send-message.ts // parser feeds each `data:` line directly into JSON.parse and - // breaks on the `e` of `event:` (browser console: "SyntaxError: - // Unexpected token 'e', \"event: mes\"…"). + // expects the event type in the JSON object's top-level `event` + // field. emitted := false for ev := range events { emitted = true diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index 2dc6731eb3..ec86f0b530 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -730,10 +730,9 @@ func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _ string, _ any) ( // TestAgentChatCompletions_StreamSetsContentType covers the SSE // path: the handler streams canvas.RunEvent frames as // `data: {...}\n\n` with a trailing `data: [DONE]\n\n` terminator. -// The frame shape is the unified python envelope -// {code:0, message:"", data:{answer, reference, audio_binary, id, -// session_id}} — the same shape /api/v1/agentbots//completions -// emits. See service.WriteChatbotRunEvent and WriteChatbotFrame. +// The frame shape is the Python agent-canvas envelope +// {event,message_id,task_id,session_id,data:{content}}. See +// service.WriteChatbotRunEvent. // // The stubChatRunner emits one `message` frame and one `done` frame // so the test verifies the body contains both the framed event and @@ -749,7 +748,7 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) { c.Set("user_id", "u1") runner := &stubChatRunner{events: []canvas.RunEvent{ - {Type: "message", Data: `{"answer":"hi back","reference":[]}`}, + {Type: "message", MessageID: "msg-1", TaskID: "task-1", SessionID: "sess-1", Data: `{"content":"hi back","reference":[]}`}, {Type: "done", Data: ""}, }} h := &AgentHandler{chatRunner: runner} @@ -759,12 +758,12 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) { t.Errorf("Content-Type = %q, want text/event-stream", got) } body := w.Body.String() - // Body must contain the unified python envelope (`code/data.answer`) - // and the [DONE] terminator. The iframe SDK JSON.parse()s `answer` - // to extract the inner fields, so the embedded JSON is double-encoded - // (escaped quotes inside the outer `"answer"` string). - if !strings.Contains(body, "\"code\":0") || !strings.Contains(body, `"answer":"{\"answer\":\"hi back\",\"reference\":[]}"`) { - t.Errorf("body should contain unified python envelope with answer, got %q", body) + if !strings.Contains(body, `"event":"message"`) || + !strings.Contains(body, `"message_id":"msg-1"`) || + !strings.Contains(body, `"task_id":"task-1"`) || + !strings.Contains(body, `"session_id":"sess-1"`) || + !strings.Contains(body, `"content":"hi back"`) { + t.Errorf("body should contain flat agent event with content, got %q", body) } if !strings.HasSuffix(body, "data: [DONE]\n\n") { t.Errorf("body should end with [DONE] terminator, got %q", body) @@ -775,8 +774,7 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) { // scenario the user actually hit: `openai-compatible: false` with no // `stream` field on the body. The handler must still invoke the // canvas runner and stream the result as SSE — the SSE envelope is -// the unified python shape shared with -// /api/v1/agentbots//completions regardless of the stream flag. +// the flat Python agent-canvas shape regardless of the stream flag. func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -788,7 +786,7 @@ func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) { c.Set("user_id", "u1") runner := &stubChatRunner{events: []canvas.RunEvent{ - {Type: "message", Data: `{"answer":"hello back","reference":[]}`}, + {Type: "message", MessageID: "msg-2", TaskID: "task-2", SessionID: "sess-2", Data: `{"content":"hello back","reference":[]}`}, {Type: "done", Data: ""}, }} h := &AgentHandler{chatRunner: runner} @@ -798,8 +796,10 @@ func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) { t.Errorf("Content-Type = %q, want text/event-stream (default branch must stream)", got) } body := w.Body.String() - if !strings.Contains(body, "\"code\":0") || !strings.Contains(body, `"answer":"{\"answer\":\"hello back\",\"reference\":[]}"`) { - t.Errorf("body should contain unified python envelope with answer, got %q", body) + if !strings.Contains(body, `"event":"message"`) || + !strings.Contains(body, `"message_id":"msg-2"`) || + !strings.Contains(body, `"content":"hello back"`) { + t.Errorf("body should contain flat agent event with content, got %q", body) } if !strings.HasSuffix(body, "data: [DONE]\n\n") { t.Errorf("body should end with [DONE] terminator, got %q", body) diff --git a/internal/service/bot_completion.go b/internal/service/bot_completion.go index e61172ec66..647aba838c 100644 --- a/internal/service/bot_completion.go +++ b/internal/service/bot_completion.go @@ -154,30 +154,22 @@ func WriteDoneFrame(w http.ResponseWriter) error { return nil } -// WriteChatbotRunEvent translates one canvas.RunEvent into the -// unified python-shaped chat-completion envelope (same shape as -// WriteChatbotFrame). This unifies the SSE format across: +// WriteChatbotRunEvent translates one canvas.RunEvent into the flat +// Python agent-canvas SSE envelope: // -// - /api/v1/agents/chat/completions (was: writeChatCompletionSSE) -// - /api/v1/agentbots//completions (was: WriteChatbotFrame per-event) +// data: {"event":"message","message_id":"...","task_id":"...", +// "session_id":"...","created_at":123,"data":{"content":"..."}}\n\n +// +// This is intentionally different from WriteChatbotFrame's legacy +// chatbot `{code,data:{answer:"..."}}` shape. The agent React page's +// use-send-message.ts parser appends each parsed object directly to +// answerList and expects top-level `event` / `message_id`, plus a +// typed `data` payload. If RunEvent frames are double-wrapped in +// data.answer, the browser receives bytes but cannot render the +// assistant message or correlate the current Log panel. // // The "done" event type emits `data: [DONE]\n\n` (no envelope), -// matching the OpenAI-style terminator and the existing -// AgentbotCompletion wire. -// -// For non-done events, ev.Data is placed verbatim into the `answer` -// field — callers pass canvas-runner output that is itself a JSON -// string (e.g. `{"answer":"hi back","reference":[]}`); the iframe -// SDK then JSON.parse()s the `answer` string to extract the inner -// fields. This matches the existing AgentbotCompletion behaviour. -// -// The event type is forwarded as the `event` field of the envelope -// (PR #14589) so the front-end can distinguish interactive -// `user_inputs` / `workflow_finished` events from plain `message` -// streams and render the UserFillUp form vs the assistant text. -// Without this field the form UI never appears because the -// iframe SDK has no way to know the canvas paused for human -// input. +// matching the Python agent API terminator. // // Returns the write error so callers can short-circuit; both nil // and io.ErrClosedPipe are tolerated because the client may have @@ -193,13 +185,65 @@ func WriteChatbotRunEvent(w http.ResponseWriter, ev canvas.RunEvent) error { } return nil } - f := ChatbotSSEFrame{ - Event: ev.Type, - Data: ev.Data, - Reference: map[string]any{}, - SessionID: ev.SessionID, + + var data any = map[string]any{} + if ev.Data != "" { + if err := json.Unmarshal([]byte(ev.Data), &data); err != nil { + data = ev.Data + } } - return WriteChatbotFrame(w, f) + if ev.Type == "error" { + msg := "an internal error occurred" + if m, ok := data.(map[string]any); ok { + if s, _ := m["message"].(string); s != "" { + msg = s + } + } + payload := map[string]any{ + "code": 500, + "message": msg, + "data": false, + } + return writeSSEJSON(w, payload) + } + + payload := map[string]any{ + "data": data, + "created_at": ev.CreatedAt, + } + if ev.Type != "" { + payload["event"] = ev.Type + } + if ev.MessageID != "" { + payload["message_id"] = ev.MessageID + } + if ev.TaskID != "" { + payload["task_id"] = ev.TaskID + } + if ev.SessionID != "" { + payload["session_id"] = ev.SessionID + } + return writeSSEJSON(w, payload) +} + +func writeSSEJSON(w http.ResponseWriter, payload map[string]any) error { + b, err := runtime.SafeJSONMarshal(payload) + if err != nil { + return err + } + if _, err := w.Write([]byte("data:")); err != nil { + return err + } + if _, err := w.Write(b); err != nil { + return err + } + if _, err := w.Write([]byte("\n\n")); err != nil { + return err + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + return nil } // AgentbotSSEFrame mirrors ChatbotSSEFrame for the agentbot diff --git a/internal/service/bot_completion_history_test.go b/internal/service/bot_completion_history_test.go index 744c776d4b..a0989c9e5d 100644 --- a/internal/service/bot_completion_history_test.go +++ b/internal/service/bot_completion_history_test.go @@ -249,6 +249,8 @@ func TestWriteChatbotRunEvent_UserInputsEvent(t *testing.T) { rec := &recordingResponseWriter{header: http.Header{}} if err := WriteChatbotRunEvent(rec, canvas.RunEvent{ Type: "user_inputs", + MessageID: "msg-1", + TaskID: "task-1", Data: `{"components":[{"id":"email","type":"text","required":true}]}`, SessionID: "sess-1", }); err != nil { @@ -258,9 +260,18 @@ func TestWriteChatbotRunEvent_UserInputsEvent(t *testing.T) { if !strings.Contains(body, `"event":"user_inputs"`) { t.Errorf("body missing event=user_inputs: %s", body) } + if !strings.Contains(body, `"message_id":"msg-1"`) { + t.Errorf("body missing message_id: %s", body) + } + if !strings.Contains(body, `"task_id":"task-1"`) { + t.Errorf("body missing task_id: %s", body) + } if !strings.Contains(body, `"session_id":"sess-1"`) { t.Errorf("body missing session_id: %s", body) } + if strings.Contains(body, `"answer":"`) { + t.Errorf("body should not wrap run events in data.answer: %s", body) + } } // TestWriteChatbotRunEvent_WorkflowFinishedEvent covers the second @@ -270,7 +281,7 @@ func TestWriteChatbotRunEvent_WorkflowFinishedEvent(t *testing.T) { rec := &recordingResponseWriter{header: http.Header{}} if err := WriteChatbotRunEvent(rec, canvas.RunEvent{ Type: "workflow_finished", - Data: `{"answer":"done"}`, + Data: `{"outputs":"done"}`, SessionID: "sess-2", }); err != nil { t.Fatalf("WriteChatbotRunEvent: %v", err) @@ -279,6 +290,9 @@ func TestWriteChatbotRunEvent_WorkflowFinishedEvent(t *testing.T) { if !strings.Contains(body, `"event":"workflow_finished"`) { t.Errorf("body missing event=workflow_finished: %s", body) } + if !strings.Contains(body, `"outputs":"done"`) { + t.Errorf("body missing workflow output payload: %s", body) + } } // TestWriteChatbotRunEvent_MessageEventCarriesEvent ensures the @@ -290,7 +304,8 @@ func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) { rec := &recordingResponseWriter{header: http.Header{}} if err := WriteChatbotRunEvent(rec, canvas.RunEvent{ Type: "message", - Data: `{"answer":"hi"}`, + MessageID: "msg-3", + Data: `{"content":"hi"}`, SessionID: "sess-3", }); err != nil { t.Fatalf("WriteChatbotRunEvent: %v", err) @@ -299,6 +314,12 @@ func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) { if !strings.Contains(body, `"event":"message"`) { t.Errorf("message frame should carry event=message: %s", body) } + 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) + } } // recordingResponseWriter is a minimal http.ResponseWriter stub