feat[Go]: port agent webhook trigger, agent file upload/download, component input-form + debug endpoints from Python (#16403)

port agent webhook trigger, agent file upload/download, component
input-form + debug endpoints from Python
- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Zhichang Yu
2026-06-27 14:07:22 +08:00
committed by yzc
parent f58fae5fb7
commit 477f2fcebd
26 changed files with 4530 additions and 188 deletions

View File

@@ -82,6 +82,16 @@ func (b *BeginComponent) Invoke(ctx context.Context, inputs map[string]any) (map
state.Sys["user_id"] = uid
}
// Webhook payload injection. The webhook HTTP handler sets
// root["webhook_payload"] (see service/agent.go RunAgentWithWebhook)
// which BuildWorkflow forwards into inputs. Surfacing it on
// state.Sys lets downstream components read sys.webhook_payload the
// same way they read sys.query / sys.user_id. The chat path never
// sets this key, so existing tests stay green.
if payload, ok := inputs["webhook_payload"].(map[string]any); ok && len(payload) > 0 {
state.Sys["webhook_payload"] = payload
}
// Passthrough: a shallow copy keeps the caller's map un-aliased.
out := make(map[string]any, len(inputs))
mapsCopy(out, inputs)
@@ -106,18 +116,20 @@ func (b *BeginComponent) Stream(ctx context.Context, inputs map[string]any) (<-c
// strings live on the struct / method above.
func (b *BeginComponent) Inputs() map[string]string {
return map[string]string{
"query": "User query string (the chat input).",
"user_id": "Optional user/tenant identifier.",
"inputs": "Optional free-form inputs map; passthrough only.",
"query": "User query string (the chat input).",
"user_id": "Optional user/tenant identifier.",
"webhook_payload": "Optional structured webhook request (set by the webhook HTTP handler; absent on chat flows).",
"inputs": "Optional free-form inputs map; passthrough only.",
}
}
// Outputs returns the same keys as Inputs (Begin is a passthrough).
func (b *BeginComponent) Outputs() map[string]string {
return map[string]string{
"query": "Query string (passthrough).",
"user_id": "User id, if provided (passthrough).",
"inputs": "Raw inputs map (passthrough).",
"query": "Query string (passthrough).",
"user_id": "User id, if provided (passthrough).",
"webhook_payload": "Webhook request payload, if provided (passthrough; also written to state.Sys[webhook_payload]).",
"inputs": "Raw inputs map (passthrough).",
}
}

View File

@@ -86,3 +86,77 @@ func TestBegin_PassesThroughInputs(t *testing.T) {
func withStateForTest(ctx context.Context, s *canvas.CanvasState) context.Context {
return canvas.WithState(ctx, s)
}
// TestBegin_InjectsWebhookPayload pins the contract added for the
// webhook HTTP handler: when inputs["webhook_payload"] is present, Begin
// must surface it on state.Sys["webhook_payload"] so downstream
// components (Retrieval, Agent, etc.) can read sys.webhook_payload the
// same way they read sys.query / sys.user_id.
//
// Mirrors python: agent/canvas.py (Begin component) reading
// `webhook_payload` from inputs and writing to state.Sys in the webhook
// branch.
func TestBegin_InjectsWebhookPayload(t *testing.T) {
c, _ := NewBeginComponent(nil)
state := canvas.NewCanvasState("run-3", "task-3")
ctx := canvas.WithState(context.Background(), state)
payload := map[string]any{
"query": map[string]any{"q": "hello"},
"headers": map[string]any{"x-token": "abc"},
"body": map[string]any{"k": "v"},
}
inputs := map[string]any{
"query": "",
"webhook_payload": payload,
}
out, err := c.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
got, ok := state.Sys["webhook_payload"].(map[string]any)
if !ok {
t.Fatalf("state.Sys[webhook_payload] missing or wrong type: %T", state.Sys["webhook_payload"])
}
if !reflect.DeepEqual(got, payload) {
t.Errorf("state.Sys[webhook_payload] mismatch:\n got %v\n want %v", got, payload)
}
// Passthrough preserved.
if outPayload, _ := out["webhook_payload"].(map[string]any); !reflect.DeepEqual(outPayload, payload) {
t.Errorf("outputs[webhook_payload] mismatch:\n got %v\n want %v", outPayload, payload)
}
}
// TestBegin_AbsentWebhookPayload confirms that the chat path (no
// webhook_payload key in inputs) leaves state.Sys["webhook_payload"]
// unset — adding the new branch must NOT pollute existing callers.
func TestBegin_AbsentWebhookPayload(t *testing.T) {
c, _ := NewBeginComponent(nil)
state := canvas.NewCanvasState("run-4", "task-4")
ctx := canvas.WithState(context.Background(), state)
if _, err := c.Invoke(ctx, map[string]any{"query": "plain chat"}); err != nil {
t.Fatalf("Invoke: %v", err)
}
if _, ok := state.Sys["webhook_payload"]; ok {
t.Errorf("state.Sys[webhook_payload] should not be set when inputs lack it; got %v", state.Sys["webhook_payload"])
}
}
// TestBegin_EmptyWebhookPayload confirms that an explicitly empty map
// is treated as "not present" — matching the python `if payload:` guard.
func TestBegin_EmptyWebhookPayload(t *testing.T) {
c, _ := NewBeginComponent(nil)
state := canvas.NewCanvasState("run-5", "task-5")
ctx := canvas.WithState(context.Background(), state)
if _, err := c.Invoke(ctx, map[string]any{
"query": "",
"webhook_payload": map[string]any{},
}); err != nil {
t.Fatalf("Invoke: %v", err)
}
if _, ok := state.Sys["webhook_payload"]; ok {
t.Errorf("state.Sys[webhook_payload] should not be set for empty payload; got %v", state.Sys["webhook_payload"])
}
}