From b84e5692a211ab319f4f280a3800becbef770653 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 15:27:15 +0000 Subject: [PATCH] feat(agentic): add content platform compatibility surfaces Co-Authored-By: Virgil --- pkg/agentic/content.go | 580 ++++++++++++++++++++++++++++ pkg/agentic/content_example_test.go | 18 + pkg/agentic/content_test.go | 204 ++++++++++ pkg/agentic/prep.go | 15 + pkg/agentic/prep_test.go | 22 ++ 5 files changed, 839 insertions(+) create mode 100644 pkg/agentic/content.go create mode 100644 pkg/agentic/content_example_test.go create mode 100644 pkg/agentic/content_test.go diff --git a/pkg/agentic/content.go b/pkg/agentic/content.go new file mode 100644 index 0000000..a18e25d --- /dev/null +++ b/pkg/agentic/content.go @@ -0,0 +1,580 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// result := agentic.ContentResult{Provider: "claude", Model: "claude-3.7-sonnet", Content: "Draft ready"} +type ContentResult struct { + ID string `json:"id,omitempty"` + BatchID string `json:"batch_id,omitempty"` + Prompt string `json:"prompt,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Content string `json:"content,omitempty"` + Status string `json:"status,omitempty"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +// brief := agentic.ContentBrief{ID: "brief_1", Slug: "host-link", Title: "LinkHost", Category: "product"} +type ContentBrief struct { + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + Product string `json:"product,omitempty"` + Category string `json:"category,omitempty"` + Brief string `json:"brief,omitempty"` + Summary string `json:"summary,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// input := agentic.ContentGenerateInput{Prompt: "Draft a release note", Provider: "claude"} +type ContentGenerateInput struct { + Prompt string `json:"prompt"` + Provider string `json:"provider,omitempty"` + Config map[string]any `json:"config,omitempty"` +} + +// input := agentic.ContentBatchGenerateInput{BatchID: "batch_123", Provider: "gemini"} +type ContentBatchGenerateInput struct { + BatchID string `json:"batch_id"` + Provider string `json:"provider,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +// input := agentic.ContentBriefCreateInput{Title: "LinkHost brief", Product: "LinkHost"} +type ContentBriefCreateInput struct { + Title string `json:"title,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Product string `json:"product,omitempty"` + Category string `json:"category,omitempty"` + Brief string `json:"brief,omitempty"` + Summary string `json:"summary,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Context map[string]any `json:"context,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +// input := agentic.ContentBriefGetInput{BriefID: "host-link"} +type ContentBriefGetInput struct { + BriefID string `json:"brief_id"` +} + +// input := agentic.ContentBriefListInput{Category: "product", Limit: 10} +type ContentBriefListInput struct { + Category string `json:"category,omitempty"` + Product string `json:"product,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// input := agentic.ContentStatusInput{BatchID: "batch_123"} +type ContentStatusInput struct { + BatchID string `json:"batch_id"` +} + +// input := agentic.ContentUsageStatsInput{Provider: "claude", Period: "week"} +type ContentUsageStatsInput struct { + Provider string `json:"provider,omitempty"` + Period string `json:"period,omitempty"` + Since string `json:"since,omitempty"` + Until string `json:"until,omitempty"` +} + +// input := agentic.ContentFromPlanInput{PlanSlug: "release-notes", Provider: "openai"} +type ContentFromPlanInput struct { + PlanSlug string `json:"plan_slug"` + Provider string `json:"provider,omitempty"` + Prompt string `json:"prompt,omitempty"` + Template string `json:"template,omitempty"` + Config map[string]any `json:"config,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +// out := agentic.ContentGenerateOutput{Success: true, Result: agentic.ContentResult{Provider: "claude"}} +type ContentGenerateOutput struct { + Success bool `json:"success"` + Result ContentResult `json:"result"` +} + +// out := agentic.ContentBatchOutput{Success: true, Batch: map[string]any{"batch_id": "batch_123"}} +type ContentBatchOutput struct { + Success bool `json:"success"` + Batch map[string]any `json:"batch"` +} + +// out := agentic.ContentBriefOutput{Success: true, Brief: agentic.ContentBrief{Slug: "host-link"}} +type ContentBriefOutput struct { + Success bool `json:"success"` + Brief ContentBrief `json:"brief"` +} + +// out := agentic.ContentBriefListOutput{Success: true, Total: 1, Briefs: []agentic.ContentBrief{{Slug: "host-link"}}} +type ContentBriefListOutput struct { + Success bool `json:"success"` + Total int `json:"total"` + Briefs []ContentBrief `json:"briefs"` +} + +// out := agentic.ContentStatusOutput{Success: true, Status: map[string]any{"status": "running"}} +type ContentStatusOutput struct { + Success bool `json:"success"` + Status map[string]any `json:"status"` +} + +// out := agentic.ContentUsageStatsOutput{Success: true, Usage: map[string]any{"calls": 4}} +type ContentUsageStatsOutput struct { + Success bool `json:"success"` + Usage map[string]any `json:"usage"` +} + +// out := agentic.ContentFromPlanOutput{Success: true, Result: agentic.ContentResult{BatchID: "batch_123"}} +type ContentFromPlanOutput struct { + Success bool `json:"success"` + Result ContentResult `json:"result"` +} + +// result := c.Action("content.generate").Run(ctx, core.NewOptions(core.Option{Key: "prompt", Value: "Draft a release note"})) +func (s *PrepSubsystem) handleContentGenerate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentGenerate(ctx, nil, ContentGenerateInput{ + Prompt: optionStringValue(options, "prompt"), + Provider: optionStringValue(options, "provider"), + Config: optionAnyMapValue(options, "config"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.batch.generate").Run(ctx, core.NewOptions(core.Option{Key: "batch_id", Value: "batch_123"})) +func (s *PrepSubsystem) handleContentBatchGenerate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentBatchGenerate(ctx, nil, ContentBatchGenerateInput{ + BatchID: optionStringValue(options, "batch_id", "batch-id", "_arg"), + Provider: optionStringValue(options, "provider"), + DryRun: optionBoolValue(options, "dry_run", "dry-run"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.brief.create").Run(ctx, core.NewOptions(core.Option{Key: "title", Value: "LinkHost brief"})) +func (s *PrepSubsystem) handleContentBriefCreate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentBriefCreate(ctx, nil, ContentBriefCreateInput{ + Title: optionStringValue(options, "title"), + Name: optionStringValue(options, "name"), + Slug: optionStringValue(options, "slug"), + Product: optionStringValue(options, "product"), + Category: optionStringValue(options, "category"), + Brief: optionStringValue(options, "brief"), + Summary: optionStringValue(options, "summary"), + Metadata: optionAnyMapValue(options, "metadata"), + Context: optionAnyMapValue(options, "context"), + Payload: optionAnyMapValue(options, "payload"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.brief.get").Run(ctx, core.NewOptions(core.Option{Key: "brief_id", Value: "host-link"})) +func (s *PrepSubsystem) handleContentBriefGet(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentBriefGet(ctx, nil, ContentBriefGetInput{ + BriefID: optionStringValue(options, "brief_id", "brief-id", "id", "slug", "_arg"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.brief.list").Run(ctx, core.NewOptions(core.Option{Key: "category", Value: "product"})) +func (s *PrepSubsystem) handleContentBriefList(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentBriefList(ctx, nil, ContentBriefListInput{ + Category: optionStringValue(options, "category"), + Product: optionStringValue(options, "product"), + Limit: optionIntValue(options, "limit"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.status").Run(ctx, core.NewOptions(core.Option{Key: "batch_id", Value: "batch_123"})) +func (s *PrepSubsystem) handleContentStatus(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentStatus(ctx, nil, ContentStatusInput{ + BatchID: optionStringValue(options, "batch_id", "batch-id", "_arg"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.usage.stats").Run(ctx, core.NewOptions(core.Option{Key: "provider", Value: "claude"})) +func (s *PrepSubsystem) handleContentUsageStats(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentUsageStats(ctx, nil, ContentUsageStatsInput{ + Provider: optionStringValue(options, "provider"), + Period: optionStringValue(options, "period"), + Since: optionStringValue(options, "since"), + Until: optionStringValue(options, "until"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("content.from.plan").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "release-notes"})) +func (s *PrepSubsystem) handleContentFromPlan(ctx context.Context, options core.Options) core.Result { + _, output, err := s.contentFromPlan(ctx, nil, ContentFromPlanInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug", "_arg"), + Provider: optionStringValue(options, "provider"), + Prompt: optionStringValue(options, "prompt"), + Template: optionStringValue(options, "template"), + Config: optionAnyMapValue(options, "config"), + Payload: optionAnyMapValue(options, "payload"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerContentTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "content_generate", + Description: "Generate content using the platform AI provider abstraction.", + }, s.contentGenerate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_batch_generate", + Description: "Generate content for a stored batch specification.", + }, s.contentBatchGenerate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_brief_create", + Description: "Create a reusable content brief for later generation work.", + }, s.contentBriefCreate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_brief_get", + Description: "Read a reusable content brief by ID or slug.", + }, s.contentBriefGet) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_brief_list", + Description: "List reusable content briefs with optional category and product filters.", + }, s.contentBriefList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_status", + Description: "Read batch content generation status by batch ID.", + }, s.contentStatus) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_usage_stats", + Description: "Read AI usage statistics for the content pipeline.", + }, s.contentUsageStats) + + mcp.AddTool(server, &mcp.Tool{ + Name: "content_from_plan", + Description: "Generate content using stored plan context and an optional provider override.", + }, s.contentFromPlan) +} + +func (s *PrepSubsystem) contentGenerate(ctx context.Context, _ *mcp.CallToolRequest, input ContentGenerateInput) (*mcp.CallToolResult, ContentGenerateOutput, error) { + if core.Trim(input.Prompt) == "" { + return nil, ContentGenerateOutput{}, core.E("contentGenerate", "prompt is required", nil) + } + + body := map[string]any{ + "prompt": input.Prompt, + } + if input.Provider != "" { + body["provider"] = input.Provider + } + if len(input.Config) > 0 { + body["config"] = input.Config + } + + result := s.platformPayload(ctx, "content.generate", "POST", "/v1/content/generate", body) + if !result.OK { + return nil, ContentGenerateOutput{}, resultErrorValue("content.generate", result) + } + + return nil, ContentGenerateOutput{ + Success: true, + Result: parseContentResult(payloadResourceMap(result.Value.(map[string]any), "result", "content", "generation")), + }, nil +} + +func (s *PrepSubsystem) contentBatchGenerate(ctx context.Context, _ *mcp.CallToolRequest, input ContentBatchGenerateInput) (*mcp.CallToolResult, ContentBatchOutput, error) { + if core.Trim(input.BatchID) == "" { + return nil, ContentBatchOutput{}, core.E("contentBatchGenerate", "batch_id is required", nil) + } + + body := map[string]any{ + "batch_id": input.BatchID, + } + if input.Provider != "" { + body["provider"] = input.Provider + } + if input.DryRun { + body["dry_run"] = true + } + + result := s.platformPayload(ctx, "content.batch.generate", "POST", "/v1/content/batch/generate", body) + if !result.OK { + return nil, ContentBatchOutput{}, resultErrorValue("content.batch.generate", result) + } + + return nil, ContentBatchOutput{ + Success: true, + Batch: payloadResourceMap(result.Value.(map[string]any), "batch", "result", "status"), + }, nil +} + +func (s *PrepSubsystem) contentBriefCreate(ctx context.Context, _ *mcp.CallToolRequest, input ContentBriefCreateInput) (*mcp.CallToolResult, ContentBriefOutput, error) { + body := map[string]any{} + if input.Title != "" { + body["title"] = input.Title + } + if input.Name != "" { + body["name"] = input.Name + } + if input.Slug != "" { + body["slug"] = input.Slug + } + if input.Product != "" { + body["product"] = input.Product + } + if input.Category != "" { + body["category"] = input.Category + } + if input.Brief != "" { + body["brief"] = input.Brief + } + if input.Summary != "" { + body["summary"] = input.Summary + } + if len(input.Metadata) > 0 { + body["metadata"] = input.Metadata + } + if len(input.Context) > 0 { + body["context"] = input.Context + } + body = mergeContentPayload(body, input.Payload) + if len(body) == 0 { + return nil, ContentBriefOutput{}, core.E("contentBriefCreate", "content brief data is required", nil) + } + + result := s.platformPayload(ctx, "content.brief.create", "POST", "/v1/content/briefs", body) + if !result.OK { + return nil, ContentBriefOutput{}, resultErrorValue("content.brief.create", result) + } + + return nil, ContentBriefOutput{ + Success: true, + Brief: parseContentBrief(payloadResourceMap(result.Value.(map[string]any), "brief", "item")), + }, nil +} + +func (s *PrepSubsystem) contentBriefGet(ctx context.Context, _ *mcp.CallToolRequest, input ContentBriefGetInput) (*mcp.CallToolResult, ContentBriefOutput, error) { + if core.Trim(input.BriefID) == "" { + return nil, ContentBriefOutput{}, core.E("contentBriefGet", "brief_id is required", nil) + } + + result := s.platformPayload(ctx, "content.brief.get", "GET", core.Concat("/v1/content/briefs/", input.BriefID), nil) + if !result.OK { + return nil, ContentBriefOutput{}, resultErrorValue("content.brief.get", result) + } + + return nil, ContentBriefOutput{ + Success: true, + Brief: parseContentBrief(payloadResourceMap(result.Value.(map[string]any), "brief", "item")), + }, nil +} + +func (s *PrepSubsystem) contentBriefList(ctx context.Context, _ *mcp.CallToolRequest, input ContentBriefListInput) (*mcp.CallToolResult, ContentBriefListOutput, error) { + path := "/v1/content/briefs" + path = appendQueryParam(path, "category", input.Category) + path = appendQueryParam(path, "product", input.Product) + if input.Limit > 0 { + path = appendQueryParam(path, "limit", core.Sprint(input.Limit)) + } + + result := s.platformPayload(ctx, "content.brief.list", "GET", path, nil) + if !result.OK { + return nil, ContentBriefListOutput{}, resultErrorValue("content.brief.list", result) + } + + return nil, parseContentBriefListOutput(result.Value.(map[string]any)), nil +} + +func (s *PrepSubsystem) contentStatus(ctx context.Context, _ *mcp.CallToolRequest, input ContentStatusInput) (*mcp.CallToolResult, ContentStatusOutput, error) { + if core.Trim(input.BatchID) == "" { + return nil, ContentStatusOutput{}, core.E("contentStatus", "batch_id is required", nil) + } + + result := s.platformPayload(ctx, "content.status", "GET", core.Concat("/v1/content/status/", input.BatchID), nil) + if !result.OK { + return nil, ContentStatusOutput{}, resultErrorValue("content.status", result) + } + + return nil, ContentStatusOutput{ + Success: true, + Status: payloadResourceMap(result.Value.(map[string]any), "status", "batch"), + }, nil +} + +func (s *PrepSubsystem) contentUsageStats(ctx context.Context, _ *mcp.CallToolRequest, input ContentUsageStatsInput) (*mcp.CallToolResult, ContentUsageStatsOutput, error) { + path := "/v1/content/usage/stats" + path = appendQueryParam(path, "provider", input.Provider) + path = appendQueryParam(path, "period", input.Period) + path = appendQueryParam(path, "since", input.Since) + path = appendQueryParam(path, "until", input.Until) + + result := s.platformPayload(ctx, "content.usage.stats", "GET", path, nil) + if !result.OK { + return nil, ContentUsageStatsOutput{}, resultErrorValue("content.usage.stats", result) + } + + return nil, ContentUsageStatsOutput{ + Success: true, + Usage: payloadResourceMap(result.Value.(map[string]any), "usage", "stats"), + }, nil +} + +func (s *PrepSubsystem) contentFromPlan(ctx context.Context, _ *mcp.CallToolRequest, input ContentFromPlanInput) (*mcp.CallToolResult, ContentFromPlanOutput, error) { + if core.Trim(input.PlanSlug) == "" { + return nil, ContentFromPlanOutput{}, core.E("contentFromPlan", "plan_slug is required", nil) + } + + body := map[string]any{ + "plan_slug": input.PlanSlug, + } + if input.Provider != "" { + body["provider"] = input.Provider + } + if input.Prompt != "" { + body["prompt"] = input.Prompt + } + if input.Template != "" { + body["template"] = input.Template + } + if len(input.Config) > 0 { + body["config"] = input.Config + } + body = mergeContentPayload(body, input.Payload) + + result := s.platformPayload(ctx, "content.from.plan", "POST", "/v1/content/from-plan", body) + if !result.OK { + return nil, ContentFromPlanOutput{}, resultErrorValue("content.from.plan", result) + } + + return nil, ContentFromPlanOutput{ + Success: true, + Result: parseContentResult(payloadResourceMap(result.Value.(map[string]any), "result", "content", "generation")), + }, nil +} + +func mergeContentPayload(target, extra map[string]any) map[string]any { + if len(target) == 0 { + target = map[string]any{} + } + for key, value := range extra { + if value != nil { + target[key] = value + } + } + return target +} + +func parseContentResult(values map[string]any) ContentResult { + result := ContentResult{ + ID: contentMapStringValue(values, "id"), + BatchID: contentMapStringValue(values, "batch_id", "batchId"), + Prompt: contentMapStringValue(values, "prompt"), + Provider: contentMapStringValue(values, "provider"), + Model: contentMapStringValue(values, "model", "model_name"), + Content: contentMapStringValue(values, "content", "text", "output"), + Status: contentMapStringValue(values, "status"), + InputTokens: mapIntValue(values, "input_tokens", "inputTokens"), + OutputTokens: mapIntValue(values, "output_tokens", "outputTokens"), + DurationMS: mapIntValue(values, "duration_ms", "durationMs", "duration"), + StopReason: contentMapStringValue(values, "stop_reason", "stopReason"), + Metadata: anyMapValue(values["metadata"]), + Raw: anyMapValue(values["raw"]), + } + if len(result.Raw) == 0 && len(values) > 0 { + result.Raw = values + } + return result +} + +func parseContentBrief(values map[string]any) ContentBrief { + return ContentBrief{ + ID: contentMapStringValue(values, "id"), + Slug: contentMapStringValue(values, "slug"), + Name: contentMapStringValue(values, "name"), + Title: contentMapStringValue(values, "title"), + Product: contentMapStringValue(values, "product"), + Category: contentMapStringValue(values, "category"), + Brief: contentMapStringValue(values, "brief", "content", "body"), + Summary: contentMapStringValue(values, "summary", "description"), + Metadata: anyMapValue(values["metadata"]), + CreatedAt: contentMapStringValue(values, "created_at"), + UpdatedAt: contentMapStringValue(values, "updated_at"), + } +} + +func parseContentBriefListOutput(payload map[string]any) ContentBriefListOutput { + values := payloadDataSlice(payload, "briefs", "items") + briefs := make([]ContentBrief, 0, len(values)) + for _, value := range values { + briefs = append(briefs, parseContentBrief(value)) + } + + total := mapIntValue(payload, "total", "count") + if total == 0 { + total = mapIntValue(payloadDataMap(payload), "total", "count") + } + if total == 0 { + total = len(briefs) + } + + return ContentBriefListOutput{ + Success: true, + Total: total, + Briefs: briefs, + } +} + +func contentMapStringValue(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key]; ok { + if text := stringValue(value); text != "" { + return text + } + } + } + return "" +} diff --git a/pkg/agentic/content_example_test.go b/pkg/agentic/content_example_test.go new file mode 100644 index 0000000..057c3ed --- /dev/null +++ b/pkg/agentic/content_example_test.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import "fmt" + +func Example_parseContentResult() { + result := parseContentResult(map[string]any{ + "batch_id": "batch_123", + "provider": "claude", + "model": "claude-3.7-sonnet", + "content": "Draft ready", + "output_tokens": 64, + }) + + fmt.Println(result.BatchID, result.Provider, result.Model, result.OutputTokens) + // Output: batch_123 claude claude-3.7-sonnet 64 +} diff --git a/pkg/agentic/content_test.go b/pkg/agentic/content_test.go new file mode 100644 index 0000000..cd0574c --- /dev/null +++ b/pkg/agentic/content_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContent_HandleContentGenerate_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/content/generate", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "Draft a release note", payload["prompt"]) + require.Equal(t, "claude", payload["provider"]) + + config, ok := payload["config"].(map[string]any) + require.True(t, ok) + require.Equal(t, float64(4000), config["max_tokens"]) + + _, _ = w.Write([]byte(`{"data":{"id":"gen_1","provider":"claude","model":"claude-3.7-sonnet","content":"Release notes draft","input_tokens":12,"output_tokens":48,"duration_ms":321}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentGenerate(context.Background(), core.NewOptions( + core.Option{Key: "prompt", Value: "Draft a release note"}, + core.Option{Key: "provider", Value: "claude"}, + core.Option{Key: "config", Value: `{"max_tokens":4000}`}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(ContentGenerateOutput) + require.True(t, ok) + assert.Equal(t, "gen_1", output.Result.ID) + assert.Equal(t, "claude", output.Result.Provider) + assert.Equal(t, "claude-3.7-sonnet", output.Result.Model) + assert.Equal(t, "Release notes draft", output.Result.Content) + assert.Equal(t, 48, output.Result.OutputTokens) +} + +func TestContent_HandleContentGenerate_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleContentGenerate(context.Background(), core.NewOptions()) + assert.False(t, result.OK) +} + +func TestContent_HandleContentGenerate_Ugly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentGenerate(context.Background(), core.NewOptions( + core.Option{Key: "prompt", Value: "Draft a release note"}, + )) + assert.False(t, result.OK) +} + +func TestContent_HandleContentBriefCreate_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/content/briefs", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "LinkHost brief", payload["title"]) + require.Equal(t, "LinkHost", payload["product"]) + + _, _ = w.Write([]byte(`{"data":{"brief":{"id":"brief_1","slug":"host-link","title":"LinkHost brief","product":"LinkHost","category":"product","brief":"Core context"}}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentBriefCreate(context.Background(), core.NewOptions( + core.Option{Key: "title", Value: "LinkHost brief"}, + core.Option{Key: "product", Value: "LinkHost"}, + core.Option{Key: "category", Value: "product"}, + core.Option{Key: "brief", Value: "Core context"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(ContentBriefOutput) + require.True(t, ok) + assert.Equal(t, "brief_1", output.Brief.ID) + assert.Equal(t, "host-link", output.Brief.Slug) + assert.Equal(t, "LinkHost", output.Brief.Product) +} + +func TestContent_HandleContentBriefList_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/content/briefs", r.URL.Path) + require.Equal(t, "product", r.URL.Query().Get("category")) + require.Equal(t, "5", r.URL.Query().Get("limit")) + _, _ = w.Write([]byte(`{"data":{"briefs":[{"id":"brief_1","slug":"host-link","title":"LinkHost brief","category":"product"}],"total":1}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentBriefList(context.Background(), core.NewOptions( + core.Option{Key: "category", Value: "product"}, + core.Option{Key: "limit", Value: 5}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(ContentBriefListOutput) + require.True(t, ok) + assert.Equal(t, 1, output.Total) + require.Len(t, output.Briefs, 1) + assert.Equal(t, "host-link", output.Briefs[0].Slug) +} + +func TestContent_HandleContentStatus_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/content/status/batch_123", r.URL.Path) + _, _ = w.Write([]byte(`{"data":{"status":"running","batch_id":"batch_123","queued":2}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentStatus(context.Background(), core.NewOptions( + core.Option{Key: "batch_id", Value: "batch_123"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(ContentStatusOutput) + require.True(t, ok) + assert.Equal(t, "running", stringValue(output.Status["status"])) + assert.Equal(t, "batch_123", stringValue(output.Status["batch_id"])) +} + +func TestContent_HandleContentUsageStats_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/content/usage/stats", r.URL.Path) + require.Equal(t, "claude", r.URL.Query().Get("provider")) + require.Equal(t, "week", r.URL.Query().Get("period")) + _, _ = w.Write([]byte(`{"data":{"calls":4,"tokens":1200}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentUsageStats(context.Background(), core.NewOptions( + core.Option{Key: "provider", Value: "claude"}, + core.Option{Key: "period", Value: "week"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(ContentUsageStatsOutput) + require.True(t, ok) + assert.Equal(t, 4, intValue(output.Usage["calls"])) + assert.Equal(t, 1200, intValue(output.Usage["tokens"])) +} + +func TestContent_HandleContentFromPlan_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/content/from-plan", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "release-notes", payload["plan_slug"]) + require.Equal(t, "openai", payload["provider"]) + + _, _ = w.Write([]byte(`{"data":{"result":{"batch_id":"batch_123","provider":"openai","model":"gpt-5.4","content":"Plan-driven draft","status":"completed"}}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleContentFromPlan(context.Background(), core.NewOptions( + core.Option{Key: "plan_slug", Value: "release-notes"}, + core.Option{Key: "provider", Value: "openai"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(ContentFromPlanOutput) + require.True(t, ok) + assert.Equal(t, "batch_123", output.Result.BatchID) + assert.Equal(t, "completed", output.Result.Status) + assert.Equal(t, "Plan-driven draft", output.Result.Content) +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index c91cc20..54ff727 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -216,6 +216,20 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("sprint.list", s.handleSprintList).Description = "List tracked platform sprints with optional filters" c.Action("sprint.update", s.handleSprintUpdate).Description = "Update a tracked platform sprint by slug" c.Action("sprint.archive", s.handleSprintArchive).Description = "Archive a tracked platform sprint by slug" + c.Action("content.generate", s.handleContentGenerate).Description = "Generate content using the platform content pipeline" + c.Action("content.batch.generate", s.handleContentBatchGenerate).Description = "Start or continue batch content generation" + c.Action("content.batch_generate", s.handleContentBatchGenerate).Description = "Start or continue batch content generation" + c.Action("content.brief.create", s.handleContentBriefCreate).Description = "Create a reusable content brief" + c.Action("content.brief_create", s.handleContentBriefCreate).Description = "Create a reusable content brief" + c.Action("content.brief.get", s.handleContentBriefGet).Description = "Read a content brief by ID or slug" + c.Action("content.brief_get", s.handleContentBriefGet).Description = "Read a content brief by ID or slug" + c.Action("content.brief.list", s.handleContentBriefList).Description = "List content briefs with optional filters" + c.Action("content.brief_list", s.handleContentBriefList).Description = "List content briefs with optional filters" + c.Action("content.status", s.handleContentStatus).Description = "Read batch content generation status" + c.Action("content.usage.stats", s.handleContentUsageStats).Description = "Read content provider usage statistics" + c.Action("content.usage_stats", s.handleContentUsageStats).Description = "Read content provider usage statistics" + c.Action("content.from.plan", s.handleContentFromPlan).Description = "Generate content from plan context" + c.Action("content.from_plan", s.handleContentFromPlan).Description = "Generate content from plan context" c.Action("agentic.prompt", s.handlePrompt).Description = "Read a system prompt by slug" c.Action("agentic.task", s.handleTask).Description = "Read a task plan by slug" @@ -326,6 +340,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { s.registerTemplateTools(server) s.registerIssueTools(server) s.registerSprintTools(server) + s.registerContentTools(server) mcp.AddTool(server, &mcp.Tool{ Name: "agentic_scan", diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index d2c7fe4..c8632f4 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -490,6 +490,28 @@ func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { assert.True(t, c.Action("sprint.archive").Exists()) } +func TestPrep_OnStartup_Good_RegistersContentActions(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("CORE_AGENT_DISPATCH", "") + + c := core.New(core.WithOption("name", "test")) + s := NewPrep() + s.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) + + require.True(t, s.OnStartup(context.Background()).OK) + assert.True(t, c.Action("content.generate").Exists()) + assert.True(t, c.Action("content.batch.generate").Exists()) + assert.True(t, c.Action("content.batch_generate").Exists()) + assert.True(t, c.Action("content.brief.create").Exists()) + assert.True(t, c.Action("content.brief.get").Exists()) + assert.True(t, c.Action("content.brief.list").Exists()) + assert.True(t, c.Action("content.status").Exists()) + assert.True(t, c.Action("content.usage.stats").Exists()) + assert.True(t, c.Action("content.usage_stats").Exists()) + assert.True(t, c.Action("content.from.plan").Exists()) + assert.True(t, c.Action("content.from_plan").Exists()) +} + func TestPrep_OnStartup_Good_RegistersPlatformActionAliases(t *testing.T) { t.Setenv("CORE_WORKSPACE", t.TempDir()) t.Setenv("CORE_AGENT_DISPATCH", "")