From 055a9de854ca6ac880c84e7e0eba4f8da342f999 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 9 Oct 2026 19:13:55 -0400 Subject: [PATCH] Add agent-authored Slack Block Kit components to respond_to_user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let the agent compose presentational Block Kit layout in Slack replies via a new `blocks` field on respond_to_user, gated by a "components" channel capability. A channel kind opts in by implementing the new ComponentFormatter and ComponentValidator interfaces (channel-neutral seam, Slack-native content): respond_to_user injects the kind authoring instructions into the blocks schema, validates synchronously in the runner before publish — returning a precise, model-facing error instead of a post-time degrade — and carries the validated blocks on OutboundUserMessagePayload.Components. The Slack sender renders them as the message body with the text field as the notification/degrade fallback, and the info-leakage egress gate measures block text via ComponentsPlainText. Supported read-only blocks: header, section (no accessory), divider, context, rich_text, markdown, table, data_visualization, card (no actions), container, plan, task_card. Interactive blocks/elements, images (deferred), and @channel/@here/@everyone broadcasts are rejected with precise errors. Chart and container payload shapes are validated to Slack requirements verified against the live API (container needs a title; charts need per-type segments/series + axis_config). Upgrade slack-go v0.23.1 -> v0.30.1 for typed support of the newer blocks, adapting the variadic GetUserInfoContext signature change. --- go.mod | 4 +- go.sum | 8 +- pkg/agent/tool/meta/respond.go | 82 +++- .../tool/meta/respond_components_test.go | 125 +++++ pkg/channels/channelevents/envelope.go | 8 + .../channelevents/envelope_components_test.go | 29 ++ pkg/channels/channelkinds/components.go | 71 +++ pkg/channels/channelkinds/components_test.go | 50 ++ .../slack/channel_history_test.go | 2 +- pkg/channels/channelkinds/slack/components.go | 440 ++++++++++++++++++ .../slack/components_capability_test.go | 49 ++ .../channelkinds/slack/components_test.go | 155 ++++++ .../channelkinds/slack/fakeslack/client.go | 2 +- pkg/channels/channelkinds/slack/history.go | 2 +- .../slack/history_email_trust_test.go | 2 +- .../channelkinds/slack/history_scopes_test.go | 2 +- .../channelkinds/slack/history_test.go | 2 +- pkg/channels/channelkinds/slack/kind.go | 1 + pkg/channels/channelkinds/slack/listener.go | 2 +- .../channelkinds/slack/listener_client.go | 2 +- .../channelkinds/slack/mentiontext.go | 2 +- .../channelkinds/slack/messagecontent.go | 70 +++ .../channelkinds/slack/relsync_kind.go | 2 +- pkg/channels/channelkinds/slack/sender.go | 66 ++- .../channelkinds/slack/sender_chunk_test.go | 2 +- .../slack/sender_components_test.go | 98 ++++ .../channelkinds/slack/sender_test.go | 2 +- .../slack/sender_tool_session_test.go | 2 +- .../channelkinds/slack/show_settings_test.go | 10 +- .../channelkinds/slack/testhooks_test.go | 2 +- 30 files changed, 1238 insertions(+), 56 deletions(-) create mode 100644 pkg/agent/tool/meta/respond_components_test.go create mode 100644 pkg/channels/channelevents/envelope_components_test.go create mode 100644 pkg/channels/channelkinds/components.go create mode 100644 pkg/channels/channelkinds/components_test.go create mode 100644 pkg/channels/channelkinds/slack/components.go create mode 100644 pkg/channels/channelkinds/slack/components_capability_test.go create mode 100644 pkg/channels/channelkinds/slack/components_test.go create mode 100644 pkg/channels/channelkinds/slack/sender_components_test.go diff --git a/go.mod b/go.mod index 040a14bb..030cb501 100644 --- a/go.mod +++ b/go.mod @@ -50,9 +50,9 @@ require ( github.com/ory/dockertest/v3 v3.12.0 github.com/rs/zerolog v1.34.0 github.com/sigstore/sigstore v1.10.8 - github.com/slack-go/slack v0.23.1 + github.com/slack-go/slack v0.30.1 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 github.com/tdewolff/parse/v2 v2.8.13 github.com/tsawler/tabula v1.6.14 github.com/warpstreamlabs/bento v1.17.0 diff --git a/go.sum b/go.sum index 4cd4ac3e..9fc38f76 100644 --- a/go.sum +++ b/go.sum @@ -860,8 +860,8 @@ github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4 github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80= -github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs= +github.com/slack-go/slack v0.30.1 h1:q6FjZssqts6hJ+DRWCnnO2YVUh+E3/UNrC0EIO2xci8= +github.com/slack-go/slack v0.30.1/go.mod h1:radSmEe8GEgE+aK980ZafMR0AQhiZjADptN5k/gOw34= github.com/snowflakedb/gosnowflake v1.18.1 h1:Nb4AWSnSBWe1UKpKTwCZxjYhYo1JH7GgKhO3wW1kR10= github.com/snowflakedb/gosnowflake v1.18.1/go.mod h1:7D4+cLepOWrerVsH+tevW3zdMJ5/WrEN7ZceAC6xBv0= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -893,8 +893,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick= diff --git a/pkg/agent/tool/meta/respond.go b/pkg/agent/tool/meta/respond.go index cef46960..e6d247de 100644 --- a/pkg/agent/tool/meta/respond.go +++ b/pkg/agent/tool/meta/respond.go @@ -88,10 +88,16 @@ func New(cfg RespondConfig) tool.Tool { } type respondTool struct { - cfg RespondConfig - schema json.RawMessage - desc string - hasAsset bool + cfg RespondConfig + schema json.RawMessage + desc string + hasAsset bool + hasComponents bool + // components validates and projects agent-authored `blocks`. Resolved from + // the outbound kind at construction; nil when the kind advertises no + // "components" capability, or (a wiring bug) advertises it without a + // validator — Execute fails closed on the latter. + components channelkinds.ComponentValidator } func newRespondTool(cfg RespondConfig) *respondTool { @@ -109,27 +115,33 @@ func newRespondTool(cfg RespondConfig) *respondTool { } } + // WHAT a reply may carry and HOW it is authored are the KIND's facts, not + // this tool's: the rules land in model-facing prompt text the runner + // presents as channel-specific and authoritative, so asking the registry + // keeps that promise for every registered kind. An unregistered name yields + // a nil Kind — TextFormattingInstructionsFor answers with the generic + // CommonMark line, and ComponentValidatorFor answers with nil. + kind, _ := chregistry.Get(cfg.ChannelKind) + props := map[string]any{} required := []string{"text"} textDesc := "The message text." if hasMarkdown { - // WHICH markup dialect this surface speaks is the KIND's fact, not this - // tool's: the rules land in model-facing prompt text the runner presents - // as channel-specific and authoritative, so asking the registry keeps - // that promise for every registered kind. An unregistered name yields a - // nil Kind, which TextFormattingInstructionsFor answers with the generic - // CommonMark line. - kind, _ := chregistry.Get(cfg.ChannelKind) textDesc += " " + channelkinds.TextFormattingInstructionsFor(kind) } else { textDesc += " Plain text only — markdown will be sent literally." } props["text"] = map[string]any{"type": "string", "description": textDesc} + var components channelkinds.ComponentValidator if hasComponents { + components = channelkinds.ComponentValidatorFor(kind) + desc := "Optional structured UI components in the channel-kind's native format, rendered as the message body with `text` as the notification preview and fallback. " + + channelkinds.ComponentFormattingInstructionsFor(kind) props["blocks"] = map[string]any{ "type": "array", - "description": "Optional structured components in the channel-kind's native format.", + "description": desc, + "items": map[string]any{"type": "object"}, } } @@ -156,6 +168,9 @@ func newRespondTool(cfg RespondConfig) *respondTool { } else { descParts = " Plain text only on this channel." } + if hasComponents { + descParts += " Structured `blocks` components are supported." + } desc := fmt.Sprintf( "Send a reply to the user via the %s channel.%s Call this whenever you have a message to surface. "+ "After your reply is complete, call await_user_message to yield until the user responds, "+ @@ -163,7 +178,7 @@ func newRespondTool(cfg RespondConfig) *respondTool { cfg.ChannelKind, descParts, ) - return &respondTool{cfg: cfg, schema: schemaBytes, desc: desc, hasAsset: hasAsset} + return &respondTool{cfg: cfg, schema: schemaBytes, desc: desc, hasAsset: hasAsset, hasComponents: hasComponents, components: components} } func (t *respondTool) Name() string { return "respond_to_user" } @@ -194,6 +209,10 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t // Attached are artifact handles to attach; each is gated on the channel's // asset:* capability. Attached []string `json:"attached,omitempty"` + // Blocks are agent-authored structured components in the channel-kind's + // native format, gated on the channel's "components" capability and + // validated by the kind before publish. + Blocks json.RawMessage `json:"blocks,omitempty"` } if res, ok := tool.ParseArgs(args, &in, t.Name(), `{"text": "the message body"}`); !ok { return res, nil @@ -202,6 +221,25 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t return tool.Result{Content: "respond_to_user: `text` is required and must be non-empty. Pass the full message body you want the user to see, e.g. {\"text\": \"Here are the results: …\"}. If your previous reply was truncated, you may need to shorten it before retrying.", IsError: true, Trusted: true}, nil } + // Validate agent-authored components BEFORE anything is published: a + // rejection returns IsError with a detailed, correctable message and leaves + // no envelope on the wire, exactly like the attachment-resolution guard + // below. Only the kind knows its component format, so the kind's validator + // (resolved from the outbound binding at construction) is the authority. + if len(in.Blocks) > 0 { + if !t.hasComponents { + return tool.Result{Content: "respond_to_user: `blocks` (structured components) are not supported on this channel. Put your reply in `text` instead.", IsError: true, Trusted: true}, nil + } + if t.components == nil { + // Advertised "components" but no validator: a wiring bug, not the + // model's fault. Fail closed rather than publish unvalidated blocks. + return tool.Result{Trusted: true}, fmt.Errorf("respond_to_user: channel kind %q advertises components but supplies no validator", t.cfg.ChannelKind) + } + if err := t.components.ValidateComponents(in.Blocks); err != nil { + return tool.Result{Content: err.Error(), IsError: true, Trusted: true}, nil + } + } + // Resolve attached artifact handles BEFORE publishing — any failure // returns IsError without putting the envelope on the wire so the // model can correct and retry. @@ -233,7 +271,19 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t // internal config (approver subject, SpiceDB endpoint) that must not reach // channel members. if t.cfg.LeakageGate != nil { - if err := t.cfg.LeakageGate(ctx, sess, in.Text, attachments); err != nil { + // The gate measures TEXT. Components carry their own text, so feed the + // gate the block content too (projected to plain text by the kind) — + // otherwise an agent could route blockable content through `blocks` and + // bypass the gate, a fail-open egress path. Appended to the TAGGED + // in.Text the gate already runs on; the block text is untagged and so + // falls to the gate's coarse check, same as an attachment does. + gateText := in.Text + if len(in.Blocks) > 0 && t.components != nil { + if projected := t.components.ComponentsPlainText(in.Blocks); projected != "" { + gateText = in.Text + "\n\n" + projected + } + } + if err := t.cfg.LeakageGate(ctx, sess, gateText, attachments); err != nil { logger := log.FromContext(ctx) pub := func(subject string, data []byte) error { return publishWithRetry(ctx, t.cfg.NATSPublish, subject, data) @@ -309,13 +359,13 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t toolUseID = ids.ToolUseID } logger.Info("respond_to_user: publishing user_message envelope", - "session", sess.Namespace+"/"+sess.Name, "toolUseID", toolUseID, "textLen", len(outText), "attachments", len(attachments)) + "session", sess.Namespace+"/"+sess.Name, "toolUseID", toolUseID, "textLen", len(outText), "attachments", len(attachments), "components", len(in.Blocks) > 0) publish := func(subject string, data []byte) error { return publishWithRetry(ctx, t.cfg.NATSPublish, subject, data) } if err := t.cfg.EnvelopeSigner.PublishOut(publish, sess.Namespace, sess.Name, channelevents.KindUserMessage, - channelevents.OutboundUserMessagePayload{Text: outText, Attachments: attachments}, + channelevents.OutboundUserMessagePayload{Text: outText, Attachments: attachments, Components: in.Blocks}, ); err != nil { return tool.Result{Content: fmt.Sprintf("publish failed: %v", err), IsError: true, Trusted: true}, nil } diff --git a/pkg/agent/tool/meta/respond_components_test.go b/pkg/agent/tool/meta/respond_components_test.go new file mode 100644 index 00000000..0c031a13 --- /dev/null +++ b/pkg/agent/tool/meta/respond_components_test.go @@ -0,0 +1,125 @@ +package meta + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/agent/tool" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + // Registers the slack kind so ChannelKind "slack" resolves its real + // ComponentFormatter/ComponentValidator — the same wiring the runner uses. + _ "github.com/authzed/openagentprimitives/pkg/channels/channelkinds/slack" + "github.com/authzed/openagentprimitives/pkg/memory" +) + +func respondCtx() context.Context { + return memory.WithSystemApproval(context.Background(), "test") +} + +func TestRespondSchemaIncludesBlocksWithComponents(t *testing.T) { + tt := newRespondTool(RespondConfig{ + Capabilities: []string{"text", "markdown", "components"}, + ChannelKind: "slack", + }) + var schema map[string]any + require.NoError(t, json.Unmarshal(tt.InputSchema(), &schema)) + props, _ := schema["properties"].(map[string]any) + + blocks, ok := props["blocks"].(map[string]any) + require.True(t, ok, "blocks property present when 'components' capability is set") + desc, _ := blocks["description"].(string) + assert.Contains(t, desc, "rich_text", "blocks description carries the kind's authoring instructions, not the old stub text") + assert.Contains(t, tt.Description(), "blocks", "tool description advertises components") +} + +func TestRespondComponentsNotSupportedChannel_IsErrorNoPublish(t *testing.T) { + calls := 0 + tt := newRespondTool(RespondConfig{ + Capabilities: []string{"text"}, // no "components" + ChannelKind: "fake", + NATSPublish: func(context.Context, string, []byte) error { calls++; return nil }, + NATSSubjectPrefix: "ap.session.default.foo", + }) + res, err := tt.Execute(respondCtx(), + json.RawMessage(`{"text":"hi","blocks":[{"type":"divider"}]}`), + &tool.SessionContext{Namespace: "default", Name: "foo"}) + require.NoError(t, err) + assert.True(t, res.IsError, "blocks on a channel without the components capability must be an error") + assert.Contains(t, res.Content, "not supported") + assert.Equal(t, 0, calls, "nothing may be published when components are unsupported") +} + +func TestRespondComponentsValidationFailure_IsErrorNoPublish(t *testing.T) { + calls := 0 + tt := newRespondTool(RespondConfig{ + Capabilities: []string{"text", "components"}, + ChannelKind: "slack", + NATSPublish: func(context.Context, string, []byte) error { calls++; return nil }, + NATSSubjectPrefix: "ap.session.default.foo", + }) + // An actions block is rejected by the slack validator (interactive). + args := `{"text":"hi","blocks":[{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Go"},"action_id":"a"}]}]}` + res, err := tt.Execute(respondCtx(), json.RawMessage(args), + &tool.SessionContext{Namespace: "default", Name: "foo"}) + require.NoError(t, err) + assert.True(t, res.IsError, "invalid components must surface IsError") + assert.Contains(t, res.Content, "interactive", "the detailed validator error must reach the model") + assert.Equal(t, 0, calls, "no envelope may be published when validation fails") +} + +func TestRespondComponentsSuccess_CarriesComponentsOnPayload(t *testing.T) { + var published [][]byte + tt := newRespondTool(RespondConfig{ + Capabilities: []string{"text", "markdown", "components"}, + ChannelKind: "slack", + NATSPublish: func(_ context.Context, _ string, p []byte) error { published = append(published, p); return nil }, + NATSSubjectPrefix: "ap.session.default.foo", + }) + blocksJSON := `[{"type":"section","text":{"type":"mrkdwn","text":"*hi*"}}]` + args := `{"text":"summary","blocks":` + blocksJSON + `}` + res, err := tt.Execute(respondCtx(), json.RawMessage(args), + &tool.SessionContext{Namespace: "default", Name: "foo"}) + require.NoError(t, err) + require.False(t, res.IsError, "valid components must publish; got: %s", res.Content) + require.Len(t, published, 1) + + var env channelevents.Envelope + require.NoError(t, json.Unmarshal(published[0], &env)) + var pl channelevents.OutboundUserMessagePayload + require.NoError(t, json.Unmarshal(env.Payload, &pl)) + assert.Equal(t, "summary", pl.Text, "text is carried as the fallback") + assert.JSONEq(t, blocksJSON, string(pl.Components), "validated components are carried on the payload") +} + +// The leakage gate measures text; without feeding it the block content, an agent +// could route blockable content through blocks and bypass the gate. This proves +// the gate sees the block text even when the reply text is innocuous. +func TestRespondComponentsLeakageGateSeesBlockText(t *testing.T) { + var sawGateText string + tt := newRespondTool(RespondConfig{ + Capabilities: []string{"text", "components"}, + ChannelKind: "slack", + NATSPublish: func(context.Context, string, []byte) error { return nil }, + NATSSubjectPrefix: "ap.session.default.foo", + LeakageGate: func(_ context.Context, _ *tool.SessionContext, text string, _ []channelevents.AttachmentRef) error { + sawGateText = text + if strings.Contains(text, "SECRET") { + return errors.New("blocked by audience check") + } + return nil + }, + }) + // "SECRET" appears only in the block, not in the reply text. + args := `{"text":"all clear","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"the SECRET value is 42"}}]}` + res, err := tt.Execute(respondCtx(), json.RawMessage(args), + &tool.SessionContext{Namespace: "default", Name: "foo"}) + require.NoError(t, err) + assert.Contains(t, sawGateText, "SECRET", "the gate must measure block text, not just the reply text") + assert.True(t, res.IsError, "the gate blocked on content carried only in blocks") +} diff --git a/pkg/channels/channelevents/envelope.go b/pkg/channels/channelevents/envelope.go index 787bc319..eb1b2d5b 100644 --- a/pkg/channels/channelevents/envelope.go +++ b/pkg/channels/channelevents/envelope.go @@ -239,6 +239,14 @@ type OutboundUserMessagePayload struct { // time via the operator's HTTP /artifact/... endpoint and never travel // via NATS. Empty means a text-only reply. Attachments []AttachmentRef `json:"attachments,omitempty"` + // Components is agent-authored structured UI in the OUTBOUND kind's native + // format (Slack Block Kit today), already validated by that kind's + // ComponentValidator before this payload was published. The kind's Sender + // renders it as the message body with Text as the notification/degrade + // fallback; a kind that does not support components ignores it. Empty means + // a text-only (or text+attachments) reply. Opaque to channelsd — format is + // a kind concern, never an envelope one. + Components json.RawMessage `json:"components,omitempty"` } // AttachmentRef references an ArtifactRender CR whose status.outputRef diff --git a/pkg/channels/channelevents/envelope_components_test.go b/pkg/channels/channelevents/envelope_components_test.go new file mode 100644 index 00000000..bf78807c --- /dev/null +++ b/pkg/channels/channelevents/envelope_components_test.go @@ -0,0 +1,29 @@ +package channelevents + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOutboundUserMessagePayloadComponents(t *testing.T) { + t.Run("present when set: round-trips", func(t *testing.T) { + p := OutboundUserMessagePayload{Text: "hi", Components: json.RawMessage(`[{"type":"divider"}]`)} + b, err := json.Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(b), `"components"`) + + var back OutboundUserMessagePayload + require.NoError(t, json.Unmarshal(b, &back)) + assert.Equal(t, "hi", back.Text) + assert.JSONEq(t, `[{"type":"divider"}]`, string(back.Components)) + }) + + t.Run("omitted when empty", func(t *testing.T) { + b, err := json.Marshal(OutboundUserMessagePayload{Text: "hi"}) + require.NoError(t, err) + assert.NotContains(t, string(b), "components") + }) +} diff --git a/pkg/channels/channelkinds/components.go b/pkg/channels/channelkinds/components.go new file mode 100644 index 00000000..2dc528d3 --- /dev/null +++ b/pkg/channels/channelkinds/components.go @@ -0,0 +1,71 @@ +// How a channel kind's surface renders structured UI components — the optional +// seam that lets an agent author native layout (Slack Block Kit today) through +// respond_to_user, parallel to TextFormatter for the plain-text dialect. +package channelkinds + +import "encoding/json" + +// ComponentFormatter is the OPTIONAL interface a Kind implements to declare that +// its surface can render structured UI components and to supply the model-facing +// authoring instructions for them. +// +// The returned string is MODEL-FACING PROMPT TEXT, appended to the `blocks` +// property description of respond_to_user's input schema exactly as +// TextFormatter's instructions are appended to `text`. +// +// Unlike TextFormatter — which every registered kind must implement, because +// every surface renders text — this is genuinely optional per kind: components +// are an opt-in capability, advertised by the "components" token in +// Capabilities(). There is therefore no default and no registry-wide sweep. +type ComponentFormatter interface { + ComponentFormattingInstructions() string +} + +// ComponentValidator is the OPTIONAL interface a Kind implements to validate +// agent-authored components before they are published, and to project them to +// plain text so egress gates can measure their content. +// +// respond_to_user calls ValidateComponents SYNCHRONOUSLY in the runner, so a +// rejection returns to the model as an error it can correct and retry, rather +// than failing later at render time. The returned error is model-facing: name +// the offending block and say how to fix it. A byte-identical input must always +// validate the same way — no wall-clock, no randomness. +// +// ComponentsPlainText exists so the info-leakage egress gate measures the TEXT +// carried in components, not only the reply's `text` field: without it, an +// agent could route blockable content through `blocks` and bypass the gate — a +// fail-open path. It is best-effort: on input that will not parse it returns "" +// (ValidateComponents is the authority on rejection; this is only for +// measurement). +type ComponentValidator interface { + ValidateComponents(raw json.RawMessage) error + ComponentsPlainText(raw json.RawMessage) string +} + +// ComponentFormattingInstructionsFor returns k's component authoring +// instructions, or "" when k is nil (an unregistered kind name) or does not +// implement ComponentFormatter. +// +// There is deliberately no non-empty default: a kind that advertises the +// "components" capability is expected to implement ComponentFormatter, and an +// empty string from a misconfigured one is honest, where a fabricated default +// would describe a dialect the surface does not speak. +func ComponentFormattingInstructionsFor(k Kind) string { + cf, ok := k.(ComponentFormatter) + if !ok || cf == nil { + return "" + } + return cf.ComponentFormattingInstructions() +} + +// ComponentValidatorFor returns k's ComponentValidator, or nil when k is nil or +// does not implement it. The caller treats a nil validator on a +// components-advertising channel as fail-closed (reject), not as "skip +// validation". +func ComponentValidatorFor(k Kind) ComponentValidator { + cv, ok := k.(ComponentValidator) + if !ok || cv == nil { + return nil + } + return cv +} diff --git a/pkg/channels/channelkinds/components_test.go b/pkg/channels/channelkinds/components_test.go new file mode 100644 index 00000000..5cc4289e --- /dev/null +++ b/pkg/channels/channelkinds/components_test.go @@ -0,0 +1,50 @@ +package channelkinds + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// componentKind embeds the Kind interface (nil — its methods are never called) +// and adds the optional component seam, so the lookup helpers can be exercised +// without constructing a full Kind or importing a concrete channel package. +type componentKind struct { + Kind +} + +func (componentKind) ComponentFormattingInstructions() string { return "author blocks like so" } + +func (componentKind) ValidateComponents(raw json.RawMessage) error { + if string(raw) == "bad" { + return errors.New("bad components") + } + return nil +} + +func (componentKind) ComponentsPlainText(raw json.RawMessage) string { + return "flattened:" + string(raw) +} + +// plainKind (a bare struct{ Kind }, declared in identity_helpers_test.go) stands +// in for a kind implementing neither component interface. + +func TestComponentFormattingInstructionsFor(t *testing.T) { + assert.Equal(t, "", ComponentFormattingInstructionsFor(nil), "nil kind has no instructions") + assert.Equal(t, "", ComponentFormattingInstructionsFor(plainKind{}), "non-component kind has no instructions") + assert.Equal(t, "author blocks like so", ComponentFormattingInstructionsFor(componentKind{}), "component kind returns its instructions") +} + +func TestComponentValidatorFor(t *testing.T) { + assert.Nil(t, ComponentValidatorFor(nil), "nil kind has no validator") + assert.Nil(t, ComponentValidatorFor(plainKind{}), "non-component kind has no validator") + + v := ComponentValidatorFor(componentKind{}) + require.NotNil(t, v, "component kind exposes a validator") + assert.NoError(t, v.ValidateComponents(json.RawMessage(`ok`))) + assert.Error(t, v.ValidateComponents(json.RawMessage(`bad`))) + assert.Equal(t, "flattened:ok", v.ComponentsPlainText(json.RawMessage(`ok`)), "validator projects components to text for egress measurement") +} diff --git a/pkg/channels/channelkinds/slack/channel_history_test.go b/pkg/channels/channelkinds/slack/channel_history_test.go index 9a852f35..5f714ad2 100644 --- a/pkg/channels/channelkinds/slack/channel_history_test.go +++ b/pkg/channels/channelkinds/slack/channel_history_test.go @@ -21,7 +21,7 @@ type fakeHistClient struct { func (f *fakeHistClient) GetConversationRepliesContext(context.Context, *slackapi.GetConversationRepliesParameters) ([]slackapi.Message, bool, string, error) { return nil, false, "", nil } -func (f *fakeHistClient) GetUserInfoContext(_ context.Context, id string) (*slackapi.User, error) { +func (f *fakeHistClient) GetUserInfoContext(_ context.Context, id string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { return &slackapi.User{Name: "u" + id, RealName: "User " + id, Profile: slackapi.UserProfile{Email: id + "@x.com"}}, nil } func (f *fakeHistClient) GetConversationHistoryContext(_ context.Context, p *slackapi.GetConversationHistoryParameters) (*slackapi.GetConversationHistoryResponse, error) { diff --git a/pkg/channels/channelkinds/slack/components.go b/pkg/channels/channelkinds/slack/components.go new file mode 100644 index 00000000..0973adea --- /dev/null +++ b/pkg/channels/channelkinds/slack/components.go @@ -0,0 +1,440 @@ +package slack + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + slackapi "github.com/slack-go/slack" +) + +// Slack's documented Block Kit limits for the presentational blocks this +// channel accepts from an agent. Enforcing them in the runner — before the +// envelope is published — turns a would-be post-time rejection (which degrades +// to plain text) into a precise, correctable error the model sees on the +// respond_to_user call. +const ( + maxBlocksPerMessage = 50 + maxSectionTextLen = 3000 + maxHeaderTextLen = 150 + maxContextElements = 10 + maxSectionFields = 10 + maxFieldTextLen = 2000 + maxMarkdownTextLen = 12000 + maxDataVizTitleLen = 50 + maxContainerChildren = 10 +) + +// componentFormattingInstructions is the model-facing authoring guide for the +// `blocks` field, injected into respond_to_user the same way the mrkdwn dialect +// instructions are injected into `text`. It names the allowlist, the limits, +// and the prohibitions so the model stays inside what ValidateComponents +// accepts. +const componentFormattingInstructions = `This channel can render structured Block Kit layout. ` + + `To use it, pass a ` + "`blocks`" + ` array of Slack Block Kit blocks alongside your text. ` + + `Supported (read-only) blocks: ` + + `"header" (plain_text, ≤150 chars); ` + + `"section" (a mrkdwn "text" object and/or up to 10 "fields"; no accessory); ` + + `"divider"; ` + + `"context" (text/mrkdwn elements, ≤10); ` + + `"rich_text" (sections, bullet/ordered lists, quotes, preformatted/code); ` + + `"markdown" (a standard-markdown block, ≤12000 chars); ` + + `"table" (rows of rich_text cells); ` + + `"data_visualization" (a chart; REQUIRES a "title" ≤50 chars and a "chart" object. ` + + `pie: {"type":"pie","segments":[{"label":"Organic","value":60}]}. ` + + `bar/area/line: {"type":"bar","series":[{"name":"Signups","data":[{"label":"Mon","value":10}]}],"axis_config":{"categories":["Mon","Tue"]}} — note each series uses "data" (not "data_points") and axis_config.categories is required; ≤2 charts per message); ` + + `"plan" and "task_card" (task lists); ` + + `"card" (title/subtitle/body/subtext — NO action buttons, NO images yet); ` + + `"container" (groups 1–10 read-only child blocks in "child_blocks"; REQUIRES a "title" or "rich_text_title"). ` + + `Section/context text uses the same Slack mrkdwn dialect as the text field (*bold*, _italic_, ` + "`code`" + `, ) — not CommonMark; the "markdown" block is the exception and takes standard markdown. ` + + `Limits: at most 50 blocks; section text ≤3000 characters; split longer content across multiple blocks. ` + + `Not supported: interactive elements (buttons, select menus, date pickers, inputs, action/input/context_actions blocks, data_table, carousel) — this surface is presentation-only; ` + + `images and icons (coming soon); and @channel/@here/@everyone broadcast mentions. ` + + `Always also fill the text field with a concise plain summary: it is the notification preview and the fallback shown if the blocks cannot be rendered.` + +// ComponentFormattingInstructions implements channelkinds.ComponentFormatter. +func (*Kind) ComponentFormattingInstructions() string { return componentFormattingInstructions } + +// ValidateComponents implements channelkinds.ComponentValidator. +func (*Kind) ValidateComponents(raw json.RawMessage) error { return validateComponents(raw) } + +// ComponentsPlainText implements channelkinds.ComponentValidator. It projects +// the Block Kit payload to the same readable text the inbound path derives from +// blocks (flattenBlocks), so the info-leakage egress gate measures what the +// blocks say. Best-effort: unparseable input yields "" — ValidateComponents, +// not this, is the authority on rejecting bad input. +func (*Kind) ComponentsPlainText(raw json.RawMessage) string { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '[' { + return "" + } + var blocks slackapi.Blocks + if err := json.Unmarshal(trimmed, &blocks); err != nil { + return "" + } + return flattenBlocks(blocks.BlockSet) +} + +// validateComponents checks agent-authored Block Kit JSON against the +// presentation-only contract: an allowlist of read-only layout blocks, Slack's +// structural limits, no broadcast mentions, and — for now — no images. +// Interactive blocks and elements are rejected so the feature never mints a +// click that would have to route back into the session. The returned error is +// model-facing: it names the offending block and says how to fix it. +func validateComponents(raw json.RawMessage) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return errors.New("respond_to_user: `blocks` must be a non-empty JSON array of Block Kit blocks") + } + if trimmed[0] != '[' { + return errors.New("respond_to_user: `blocks` must be a JSON array of Block Kit blocks, e.g. [{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"…\"}}]") + } + + var blocks slackapi.Blocks + if err := json.Unmarshal(trimmed, &blocks); err != nil { + return fmt.Errorf("respond_to_user: `blocks` could not be parsed as Block Kit JSON: %v", err) + } + set := blocks.BlockSet + if len(set) == 0 { + return errors.New("respond_to_user: `blocks` must contain at least one block") + } + if len(set) > maxBlocksPerMessage { + return fmt.Errorf("respond_to_user: too many blocks (%d); Slack renders at most %d blocks per message", len(set), maxBlocksPerMessage) + } + + for i, b := range set { + if err := validateBlock(fmt.Sprintf("block %d", i), b); err != nil { + return err + } + } + return nil +} + +func blockErrf(loc, typ, format string, args ...any) error { + return fmt.Errorf("respond_to_user: %s (%s): "+format, append([]any{loc, typ}, args...)...) +} + +// validateBlock validates one block (loc is a human path like "block 2" or +// "block 2 child 1"). Read-only layout blocks pass; interactive, image, and +// unknown blocks are rejected with a model-facing reason. +func validateBlock(loc string, b slackapi.Block) error { + switch blk := b.(type) { + case *slackapi.HeaderBlock: + if blk.Text != nil { + if n := len(blk.Text.Text); n > maxHeaderTextLen { + return blockErrf(loc, "header", "text is %d characters; Slack limits header text to %d", n, maxHeaderTextLen) + } + if err := checkBroadcast(loc, "header", blk.Text.Text); err != nil { + return err + } + } + return nil + case *slackapi.SectionBlock: + return validateSection(loc, blk) + case *slackapi.DividerBlock: + return nil + case *slackapi.ContextBlock: + return validateContext(loc, blk) + case *slackapi.RichTextBlock: + return validateRichText(loc, blk) + case *slackapi.MarkdownBlock: + if n := len(blk.Text); n > maxMarkdownTextLen { + return blockErrf(loc, "markdown", "text is %d characters; Slack limits a markdown block to %d", n, maxMarkdownTextLen) + } + return checkBroadcast(loc, "markdown", blk.Text) + case *slackapi.TableBlock: + return validateTable(loc, blk) + case *slackapi.DataVisualizationBlock: + return validateDataViz(loc, blk) + case *slackapi.CardBlock: + return validateCard(loc, blk) + case *slackapi.ContainerBlock: + return validateContainer(loc, blk) + case *slackapi.PlanBlock: + if err := checkBroadcast(loc, "plan", blk.Title); err != nil { + return err + } + for j := range blk.Tasks { + if err := validateTaskCard(fmt.Sprintf("%s task %d", loc, j), &blk.Tasks[j]); err != nil { + return err + } + } + return nil + case *slackapi.TaskCardBlock: + return validateTaskCard(loc, blk) + case *slackapi.AlertBlock: + return blockErrf(loc, "alert", "alert blocks are only valid in modals, not in messages; use a section (with *bold* and an emoji) or a markdown block for an inline callout") + case *slackapi.ImageBlock: + return blockErrf(loc, "image", "image blocks are not yet supported on this channel; omit them for now") + case *slackapi.ActionBlock, *slackapi.InputBlock, *slackapi.ContextActionsBlock: + return blockErrf(loc, string(b.BlockType()), "interactive components (buttons, menus, inputs) are not supported; this channel accepts presentation-only layout") + case *slackapi.DataTableBlock: + return blockErrf(loc, "data_table", "the data_table block is interactive; use a read-only \"table\" block instead") + case *slackapi.CarouselBlock: + return blockErrf(loc, "carousel", "the carousel block is interactive (its cards carry actions); it is not supported on this channel") + default: + return blockErrf(loc, string(b.BlockType()), "unsupported block type; allowed: header, section, divider, context, rich_text, markdown, table, data_visualization, plan, task_card, card, container") + } +} + +func validateSection(loc string, s *slackapi.SectionBlock) error { + // A section accessory is either an image (slice 2) or an interactive element + // (never): reject any accessory rather than enumerate the ways it's unsupported. + if s.Accessory != nil { + return blockErrf(loc, "section", "accessories are not supported on this channel; use plain section text and fields") + } + if s.Text != nil { + if n := len(s.Text.Text); n > maxSectionTextLen { + return blockErrf(loc, "section", "text is %d characters; Slack limits section text to %d — split it across multiple section blocks", n, maxSectionTextLen) + } + if err := checkBroadcast(loc, "section", s.Text.Text); err != nil { + return err + } + } + if len(s.Fields) > maxSectionFields { + return blockErrf(loc, "section", "has %d fields; Slack limits a section to %d fields", len(s.Fields), maxSectionFields) + } + for _, f := range s.Fields { + if f == nil { + continue + } + if n := len(f.Text); n > maxFieldTextLen { + return blockErrf(loc, "section", "a field is %d characters; Slack limits each field to %d", n, maxFieldTextLen) + } + if err := checkBroadcast(loc, "section", f.Text); err != nil { + return err + } + } + return nil +} + +func validateContext(loc string, c *slackapi.ContextBlock) error { + els := c.ContextElements.Elements + if len(els) > maxContextElements { + return blockErrf(loc, "context", "has %d elements; Slack limits a context block to %d elements", len(els), maxContextElements) + } + for _, el := range els { + text, ok := el.(*slackapi.TextBlockObject) + if !ok { + // The only non-text context element is an image, deferred to slice 2. + return blockErrf(loc, "context", "image elements are not yet supported on this channel; use text-only context") + } + if err := checkBroadcast(loc, "context", text.Text); err != nil { + return err + } + } + return nil +} + +func validateRichText(loc string, rt *slackapi.RichTextBlock) error { + if richTextElementsHaveBroadcast(rt.Elements) { + return blockErrf(loc, "rich_text", "broadcast mentions (@channel/@here/@everyone) are not allowed in agent-authored components") + } + return nil +} + +func richTextElementsHaveBroadcast(els []slackapi.RichTextElement) bool { + for _, el := range els { + if richTextHasBroadcast(el) { + return true + } + } + return false +} + +func validateTable(loc string, t *slackapi.TableBlock) error { + for r, row := range t.Rows { + for c, cell := range row { + cloc := fmt.Sprintf("%s table r%dc%d", loc, r, c) + switch cc := cell.(type) { + case nil: + // empty cell (Slack sends null) — nothing to validate. + case *slackapi.TableRichTextCell: + if richTextElementsHaveBroadcast(cc.Elements) { + return blockErrf(cloc, "table", "broadcast mentions (@channel/@here/@everyone) are not allowed in agent-authored components") + } + case *slackapi.TableRawTextCell: + if err := checkBroadcast(cloc, "table", cc.Text); err != nil { + return err + } + case *slackapi.TableRawNumberCell: + return blockErrf(cloc, "table", "raw_number cells are rejected by chat.postMessage; use a raw_text cell for a number in a message") + default: + return blockErrf(cloc, "table", "unsupported table cell type") + } + } + } + return nil +} + +// validateDataViz enforces the data_visualization schema Slack's chat.postMessage +// requires (verified empirically against the live API): a non-empty title ≤50 +// chars, a chart of a known type, and the per-type data shape — pie needs +// segments, bar/area/line need series plus axis_config.categories. Enforcing it +// here turns a would-be post-time invalid_blocks (which degrades the whole reply +// to plain text) into a precise error the agent can correct. +func validateDataViz(loc string, dv *slackapi.DataVisualizationBlock) error { + if strings.TrimSpace(dv.Title) == "" { + return blockErrf(loc, "data_visualization", "requires a non-empty \"title\" (≤%d chars)", maxDataVizTitleLen) + } + if n := len([]rune(dv.Title)); n > maxDataVizTitleLen { + return blockErrf(loc, "data_visualization", "title is %d characters; Slack limits it to %d", n, maxDataVizTitleLen) + } + if err := checkBroadcast(loc, "data_visualization", dv.Title); err != nil { + return err + } + switch c := dv.Chart.(type) { + case *slackapi.DataVisualizationPieChart: + if len(c.Segments) == 0 { + return blockErrf(loc, "data_visualization", "pie chart requires a non-empty \"segments\" array of {label,value}") + } + case *slackapi.DataVisualizationBarChart: + return validateVizSeries(loc, "bar", c.Series, c.AxisConfig) + case *slackapi.DataVisualizationAreaChart: + return validateVizSeries(loc, "area", c.Series, c.AxisConfig) + case *slackapi.DataVisualizationLineChart: + return validateVizSeries(loc, "line", c.Series, c.AxisConfig) + default: + return blockErrf(loc, "data_visualization", "requires a \"chart\" object whose \"type\" is one of pie, bar, area, line") + } + return nil +} + +func validateVizSeries(loc, kind string, series []slackapi.DataVisualizationDataSeries, axis slackapi.DataVisualizationAxisConfig) error { + if len(series) == 0 { + return blockErrf(loc, "data_visualization", "%s chart requires a non-empty \"series\" array; each series is {\"name\":…,\"data\":[{\"label\":…,\"value\":…}]}", kind) + } + if len(axis.Categories) == 0 { + return blockErrf(loc, "data_visualization", "%s chart requires \"axis_config\":{\"categories\":[…]} naming the x-axis categories", kind) + } + return nil +} + +func validateContainer(loc string, c *slackapi.ContainerBlock) error { + // Slack rejects a titleless container: "must define either `title`, or + // `rich_text_title`" (verified against the live API). + if c.Title == nil && c.RichTextTitle == nil { + return blockErrf(loc, "container", "requires a \"title\" (plain_text/mrkdwn) or a \"rich_text_title\"") + } + if c.Icon != nil { + return blockErrf(loc, "container", "container icons are not yet supported on this channel; omit them for now") + } + if c.Title != nil { + if err := checkBroadcast(loc, "container", c.Title.Text); err != nil { + return err + } + } + if c.Subtitle != nil { + if err := checkBroadcast(loc, "container", c.Subtitle.Text); err != nil { + return err + } + } + if c.RichTextTitle != nil { + if err := validateRichText(loc, c.RichTextTitle); err != nil { + return err + } + } + n := len(c.ChildBlocks.BlockSet) + if n == 0 { + return blockErrf(loc, "container", "requires at least one block in \"child_blocks\"") + } + if n > maxContainerChildren { + return blockErrf(loc, "container", "has %d child blocks; Slack limits a container to %d", n, maxContainerChildren) + } + for j, child := range c.ChildBlocks.BlockSet { + if err := validateBlock(fmt.Sprintf("%s child %d", loc, j), child); err != nil { + return err + } + } + return nil +} + +func validateCard(loc string, c *slackapi.CardBlock) error { + if c.Actions != nil { + return blockErrf(loc, "card", "card action buttons are interactive and not supported; use a card with only title/subtitle/body/subtext") + } + if c.HeroImage != nil || c.Icon != nil || c.SlackIcon != nil { + return blockErrf(loc, "card", "card images and icons are not yet supported on this channel; omit them for now") + } + for _, t := range []*slackapi.TextBlockObject{c.Title, c.Subtitle, c.Body, c.Subtext} { + if t == nil { + continue + } + if err := checkBroadcast(loc, "card", t.Text); err != nil { + return err + } + } + return nil +} + +func validateTaskCard(loc string, tc *slackapi.TaskCardBlock) error { + if err := checkBroadcast(loc, "task_card", tc.Title); err != nil { + return err + } + if tc.Details != nil { + if err := validateRichText(loc, tc.Details); err != nil { + return err + } + } + if tc.Output != nil { + if err := validateRichText(loc, tc.Output); err != nil { + return err + } + } + for _, s := range tc.Sources { + if err := checkBroadcast(loc, "task_card", s.Text); err != nil { + return err + } + } + return nil +} + +// richTextHasBroadcast walks the rich-text element tree looking for a broadcast +// element (the rich_text encoding of @channel/@here/@everyone). +func richTextHasBroadcast(el slackapi.RichTextElement) bool { + switch e := el.(type) { + case *slackapi.RichTextSection: + return sectionElemsHaveBroadcast(e.Elements) + case *slackapi.RichTextQuote: + return sectionElemsHaveBroadcast(e.Elements) + case *slackapi.RichTextPreformatted: + return sectionElemsHaveBroadcast(e.Elements) + case *slackapi.RichTextList: + for _, sub := range e.Elements { + if richTextHasBroadcast(sub) { + return true + } + } + } + return false +} + +func sectionElemsHaveBroadcast(els []slackapi.RichTextSectionElement) bool { + for _, el := range els { + if _, ok := el.(*slackapi.RichTextSectionBroadcastElement); ok { + return true + } + } + return false +} + +func checkBroadcast(loc, typ, text string) error { + if containsBroadcastMention(text) { + return blockErrf(loc, typ, "broadcast mentions (@channel/@here/@everyone) are not allowed in agent-authored components") + } + return nil +} + +// containsBroadcastMention reports whether text carries a Slack broadcast span. +// Slack writes these as , , or (optionally with a +// |label), so matching the opening token catches every form. +func containsBroadcastMention(text string) bool { + for _, tok := range []string{": rejected broadcast", json: `[{"type":"section","text":{"type":"mrkdwn","text":"hey look"}}]`, wantErr: "broadcast"}, + {name: "context : rejected broadcast", json: `[{"type":"context","elements":[{"type":"mrkdwn","text":" ping"}]}]`, wantErr: "broadcast"}, + {name: "section field : rejected broadcast", json: `[{"type":"section","fields":[{"type":"mrkdwn","text":""}]}]`, wantErr: "broadcast"}, + {name: "rich_text broadcast element: rejected", json: `[{"type":"rich_text","elements":[{"type":"rich_text_section","elements":[{"type":"broadcast","range":"channel"}]}]}]`, wantErr: "broadcast"}, + {name: "markdown : rejected broadcast", json: `[{"type":"markdown","text":"ping now"}]`, wantErr: "broadcast"}, + {name: "table raw_text cell : rejected broadcast", json: `[{"type":"table","rows":[[{"type":"raw_text","text":" look"}]]}]`, wantErr: "broadcast"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateComponents([]byte(tc.json)) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/pkg/channels/channelkinds/slack/fakeslack/client.go b/pkg/channels/channelkinds/slack/fakeslack/client.go index 323a8a8f..7950bf52 100644 --- a/pkg/channels/channelkinds/slack/fakeslack/client.go +++ b/pkg/channels/channelkinds/slack/fakeslack/client.go @@ -173,7 +173,7 @@ func (c *Client) SeedUser(u *slackapi.User) { c.usersEmail[u.Profile.Email] = u } } -func (c *Client) GetUserInfoContext(_ context.Context, id string) (*slackapi.User, error) { +func (c *Client) GetUserInfoContext(_ context.Context, id string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { c.mu.Lock() defer c.mu.Unlock() if u, ok := c.users[id]; ok { diff --git a/pkg/channels/channelkinds/slack/history.go b/pkg/channels/channelkinds/slack/history.go index 718724a3..e8880679 100644 --- a/pkg/channels/channelkinds/slack/history.go +++ b/pkg/channels/channelkinds/slack/history.go @@ -18,7 +18,7 @@ import ( // that (outbound-message) surface. type historyClient interface { GetConversationRepliesContext(ctx context.Context, params *slackapi.GetConversationRepliesParameters) ([]slackapi.Message, bool, string, error) - GetUserInfoContext(ctx context.Context, user string) (*slackapi.User, error) + GetUserInfoContext(ctx context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) GetConversationHistoryContext(ctx context.Context, params *slackapi.GetConversationHistoryParameters) (*slackapi.GetConversationHistoryResponse, error) GetBotInfoContext(ctx context.Context, params slackapi.GetBotInfoParameters) (*slackapi.Bot, error) } diff --git a/pkg/channels/channelkinds/slack/history_email_trust_test.go b/pkg/channels/channelkinds/slack/history_email_trust_test.go index 241fa2ce..2d5d432f 100644 --- a/pkg/channels/channelkinds/slack/history_email_trust_test.go +++ b/pkg/channels/channelkinds/slack/history_email_trust_test.go @@ -12,7 +12,7 @@ import ( // trustFakeClient answers users.info from a fixed table. type trustFakeClient struct{ users map[string]*slackapi.User } -func (c trustFakeClient) GetUserInfoContext(_ context.Context, id string) (*slackapi.User, error) { +func (c trustFakeClient) GetUserInfoContext(_ context.Context, id string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { return c.users[id], nil } diff --git a/pkg/channels/channelkinds/slack/history_scopes_test.go b/pkg/channels/channelkinds/slack/history_scopes_test.go index 7c28354f..2e33b961 100644 --- a/pkg/channels/channelkinds/slack/history_scopes_test.go +++ b/pkg/channels/channelkinds/slack/history_scopes_test.go @@ -102,7 +102,7 @@ func (c *recordingHistoryClient) AuthTestContext(_ context.Context) (*slackapi.A return &slackapi.AuthTestResponse{TeamID: histTestTeam}, nil } -func (c *recordingHistoryClient) GetUserInfoContext(_ context.Context, id string) (*slackapi.User, error) { +func (c *recordingHistoryClient) GetUserInfoContext(_ context.Context, id string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { c.calls = append(c.calls, "users.info") u := &slackapi.User{ID: id, RealName: "Member One", TeamID: histTestTeam} u.Profile.Email = "member.one@example.test" diff --git a/pkg/channels/channelkinds/slack/history_test.go b/pkg/channels/channelkinds/slack/history_test.go index 34dd5904..c5e7e059 100644 --- a/pkg/channels/channelkinds/slack/history_test.go +++ b/pkg/channels/channelkinds/slack/history_test.go @@ -43,7 +43,7 @@ func (f *fakeHistoryClient) GetConversationRepliesContext(_ context.Context, p * return f.replies, f.hasMore, f.cursor, f.repErr } -func (f *fakeHistoryClient) GetUserInfoContext(_ context.Context, user string) (*slackapi.User, error) { +func (f *fakeHistoryClient) GetUserInfoContext(_ context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { if f.userErr != nil { return nil, f.userErr } diff --git a/pkg/channels/channelkinds/slack/kind.go b/pkg/channels/channelkinds/slack/kind.go index 4f929e6d..290a647d 100644 --- a/pkg/channels/channelkinds/slack/kind.go +++ b/pkg/channels/channelkinds/slack/kind.go @@ -266,6 +266,7 @@ func (*Kind) Capabilities() []string { "text", "markdown", "plan", + "components", "asset:text/html", "asset:image/png", "asset:image/jpeg", diff --git a/pkg/channels/channelkinds/slack/listener.go b/pkg/channels/channelkinds/slack/listener.go index aeaac7a4..1ced0360 100644 --- a/pkg/channels/channelkinds/slack/listener.go +++ b/pkg/channels/channelkinds/slack/listener.go @@ -2035,7 +2035,7 @@ func (a *concreteClientAdapter) GetUserByEmailContext(ctx context.Context, email return a.c.GetUserByEmailContext(ctx, email) } -func (a *concreteClientAdapter) GetUserInfoContext(ctx context.Context, user string) (*slackapi.User, error) { +func (a *concreteClientAdapter) GetUserInfoContext(ctx context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { return a.c.GetUserInfoContext(ctx, user) } diff --git a/pkg/channels/channelkinds/slack/listener_client.go b/pkg/channels/channelkinds/slack/listener_client.go index 7299acf4..2d979edf 100644 --- a/pkg/channels/channelkinds/slack/listener_client.go +++ b/pkg/channels/channelkinds/slack/listener_client.go @@ -32,7 +32,7 @@ type listenerAPIClient interface { PostEphemeralContext(ctx context.Context, channelID, userID string, options ...slackapi.MsgOption) (string, error) // GetUserInfoContext resolves a Slack user's profile (email, team, name) // for identity resolution and the App Home tab. - GetUserInfoContext(ctx context.Context, user string) (*slackapi.User, error) + GetUserInfoContext(ctx context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) // OpenViewContext opens a modal (Show Details, Show settings, etc.). OpenViewContext(ctx context.Context, triggerID string, view slackapi.ModalViewRequest) (*slackapi.ViewResponse, error) // OpenView is the non-context views.open, used by the "Restart from diff --git a/pkg/channels/channelkinds/slack/mentiontext.go b/pkg/channels/channelkinds/slack/mentiontext.go index eb76e6cd..93ab6f47 100644 --- a/pkg/channels/channelkinds/slack/mentiontext.go +++ b/pkg/channels/channelkinds/slack/mentiontext.go @@ -44,7 +44,7 @@ const mentionLookupBudget = 24 // userNamer is the users.info half of slackClient, named separately so the // helpers below state the ONE capability they need. type userNamer interface { - GetUserInfoContext(ctx context.Context, user string) (*slackapi.User, error) + GetUserInfoContext(ctx context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) } // renderSlackEntities rewrites Slack's wire encoding for mentions, channel diff --git a/pkg/channels/channelkinds/slack/messagecontent.go b/pkg/channels/channelkinds/slack/messagecontent.go index d722d0a3..e7828d68 100644 --- a/pkg/channels/channelkinds/slack/messagecontent.go +++ b/pkg/channels/channelkinds/slack/messagecontent.go @@ -138,6 +138,76 @@ func flattenBlocks(blocks []slackapi.Block) string { } case *slackapi.RichTextBlock: add(flattenRichText(blk.Elements)) + case *slackapi.MarkdownBlock: + add(blk.Text) + case *slackapi.TableBlock: + for _, row := range blk.Rows { + for _, cell := range row { + add(flattenTableCell(cell)) + } + } + case *slackapi.DataVisualizationBlock: + add(blk.Title) + case *slackapi.CardBlock: + for _, t := range []*slackapi.TextBlockObject{blk.Title, blk.Subtitle, blk.Body, blk.Subtext} { + if t != nil { + add(t.Text) + } + } + case *slackapi.ContainerBlock: + if blk.Title != nil { + add(blk.Title.Text) + } + if blk.Subtitle != nil { + add(blk.Subtitle.Text) + } + if blk.RichTextTitle != nil { + add(flattenRichText(blk.RichTextTitle.Elements)) + } + add(flattenBlocks(blk.ChildBlocks.BlockSet)) + case *slackapi.PlanBlock: + add(blk.Title) + for i := range blk.Tasks { + add(flattenTaskCard(&blk.Tasks[i])) + } + case *slackapi.TaskCardBlock: + add(flattenTaskCard(blk)) + } + } + return strings.Join(parts, "\n") +} + +// flattenTableCell renders a table cell's readable text. raw_number cells carry +// no text unless a display override is set; rich_text and raw_text cells carry +// their content. +func flattenTableCell(cell slackapi.TableCell) string { + switch c := cell.(type) { + case *slackapi.TableRichTextCell: + return flattenRichText(c.Elements) + case *slackapi.TableRawTextCell: + return c.Text + case *slackapi.TableRawNumberCell: + return c.Text + } + return "" +} + +// flattenTaskCard renders a task card's readable text (title, details, output, +// and source labels) for transcript/egress projection. +func flattenTaskCard(tc *slackapi.TaskCardBlock) string { + var parts []string + if tc.Title != "" { + parts = append(parts, tc.Title) + } + if tc.Details != nil { + parts = append(parts, flattenRichText(tc.Details.Elements)) + } + if tc.Output != nil { + parts = append(parts, flattenRichText(tc.Output.Elements)) + } + for _, s := range tc.Sources { + if s.Text != "" { + parts = append(parts, s.Text) } } return strings.Join(parts, "\n") diff --git a/pkg/channels/channelkinds/slack/relsync_kind.go b/pkg/channels/channelkinds/slack/relsync_kind.go index 2248abdb..c153b1ab 100644 --- a/pkg/channels/channelkinds/slack/relsync_kind.go +++ b/pkg/channels/channelkinds/slack/relsync_kind.go @@ -115,7 +115,7 @@ type slackDirectoryClient interface { GetConversationsContext(ctx context.Context, params *slackapi.GetConversationsParameters) (channels []slackapi.Channel, nextCursor string, err error) GetConversationInfoContext(ctx context.Context, input *slackapi.GetConversationInfoInput) (*slackapi.Channel, error) GetUsersInConversationContext(ctx context.Context, params *slackapi.GetUsersInConversationParameters) ([]string, string, error) - GetUserInfoContext(ctx context.Context, user string) (*slackapi.User, error) + GetUserInfoContext(ctx context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) AuthTestContext(ctx context.Context) (*slackapi.AuthTestResponse, error) } diff --git a/pkg/channels/channelkinds/slack/sender.go b/pkg/channels/channelkinds/slack/sender.go index 079504c9..40361a8f 100644 --- a/pkg/channels/channelkinds/slack/sender.go +++ b/pkg/channels/channelkinds/slack/sender.go @@ -59,7 +59,7 @@ type slackClient interface { // GetUserInfoContext resolves a user id to a profile. The sender needs the // direction the other two lookups do not: quoted message text carries ids, // not emails, and an id is what a reader cannot read (see userNamer). - GetUserInfoContext(ctx context.Context, user string) (*slackapi.User, error) + GetUserInfoContext(ctx context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) // OpenViewContext opens a Slack modal via views.open. Used by the // approval-flow's Show Details button to surface the full args / // MCP description / op-session refs out-of-thread. @@ -347,7 +347,7 @@ func (s *slackSender) Send(ctx context.Context, sess channelkinds.SessionInfo, e // thereafter via the App Home "Agent settings" button. sessRef := sess.Namespace + "/" + sess.Name includeSettings := s.settingsSeen == nil || !s.settingsSeen.saw(sessRef) - blocks := s.buildUserMessageBlocks(ctx, sess, pl.Text, sessRef, includeSettings, logger) + blocks := s.buildUserMessageBlocks(ctx, sess, pl.Text, pl.Components, sessRef, includeSettings, logger) opts := []slackapi.MsgOption{ slackapi.MsgOptionText(pl.Text, false), // plain-text fallback for push notifications slackapi.MsgOptionBlocks(blocks...), @@ -430,24 +430,33 @@ func (s *slackSender) Send(ctx context.Context, sess channelkinds.SessionInfo, e func (s *slackSender) buildUserMessageBlocks( ctx context.Context, sess channelkinds.SessionInfo, - text, sessRef string, + text string, + components json.RawMessage, + sessRef string, includeSettings bool, logger interface{ Info(string, ...any) }, ) []slackapi.Block { - // A section block's mrkdwn is capped at 3000 chars; a single unbounded - // block makes chat.postMessage reject the whole reply with invalid_blocks. - // Split the reply across as many section blocks as it needs, each under the - // limit, preserving all content. + // Base body: agent-authored components when present (validated by the slack + // kind's ComponentValidator in the runner before publish), otherwise the + // reply text split into section blocks. When components ARE the body, text + // is only the notification/degrade fallback — not a duplicated section. If + // components somehow fail to parse here (they were validated upstream), fall + // back to text sections rather than drop the reply. var blocks []slackapi.Block - for _, chunk := range chunkForSlackSection(text, slackSectionMaxRunes) { - blocks = append(blocks, slackapi.NewSectionBlock( - slackapi.NewTextBlockObject("mrkdwn", chunk, false, false), - nil, nil, - )) + if len(components) == 0 { + blocks = append(blocks, textSectionBlocks(text)...) + } else if agent := parseComponentBlocks(components); len(agent) > 0 { + blocks = append(blocks, agent...) + } else { + logger.Info("slack: user_message components did not parse into blocks; falling back to text sections", + "session", sess.Name) + blocks = append(blocks, textSectionBlocks(text)...) } // Muted clamp-warning context line — best-effort; never blocks the send. - if s.deps.K8sClient != nil { + // Guarded by block headroom so a full (≤50-block) agent-authored body is + // not pushed over Slack's per-message cap by a system-appended extra. + if s.deps.K8sClient != nil && len(blocks) < slackMaxBlocksPerMessage { var as spiceboxv1alpha1.AgentSession if err := s.deps.K8sClient.Get(ctx, types.NamespacedName{ Namespace: sess.Namespace, Name: sess.Name, @@ -467,8 +476,8 @@ func (s *slackSender) buildUserMessageBlocks( // Show settings button — first agent message of the session only; // settings stay reachable anytime via the App Home "Agent settings" - // button. - if includeSettings { + // button. Headroom-guarded, as with the clamp line above. + if includeSettings && len(blocks) < slackMaxBlocksPerMessage { blocks = append(blocks, slackapi.NewActionBlock("show_settings_actions", settingsButton(sessRef), )) @@ -477,6 +486,33 @@ func (s *slackSender) buildUserMessageBlocks( return blocks } +// textSectionBlocks splits text into mrkdwn section blocks, each under Slack's +// 3000-char section cap so chat.postMessage never rejects the reply with +// invalid_blocks. It is the body when no components are present, and the +// fallback when components fail to parse. +func textSectionBlocks(text string) []slackapi.Block { + var out []slackapi.Block + for _, chunk := range chunkForSlackSection(text, slackSectionMaxRunes) { + out = append(out, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", chunk, false, false), + nil, nil, + )) + } + return out +} + +// parseComponentBlocks unmarshals agent-authored Block Kit JSON into slack-go +// blocks. Returns nil on any failure — the caller falls back to text so a reply +// is never dropped. The payload was already validated by the slack kind's +// ComponentValidator in the runner; this is the render-time counterpart. +func parseComponentBlocks(components json.RawMessage) []slackapi.Block { + var parsed slackapi.Blocks + if err := json.Unmarshal(components, &parsed); err != nil { + return nil + } + return parsed.BlockSet +} + // forgetWatchdog drops the watchdog's tracking for this session — used after // a final user_message has been delivered (the user has their answer, no // silence alarm needed). Safe with nil Deps.TouchSetStatus / nil hooks. diff --git a/pkg/channels/channelkinds/slack/sender_chunk_test.go b/pkg/channels/channelkinds/slack/sender_chunk_test.go index 740d11e6..99839fb5 100644 --- a/pkg/channels/channelkinds/slack/sender_chunk_test.go +++ b/pkg/channels/channelkinds/slack/sender_chunk_test.go @@ -111,7 +111,7 @@ func TestBuildUserMessageBlocks_LongReply_SplitsIntoSectionBlocksUnderLimit(t *t long := strings.Repeat("Option: lead with trust, close with the RAG demo link. ", 120) require.Greater(t, len([]rune(long)), 3000, "fixture must exceed the section limit") - blocks := s.buildUserMessageBlocks(context.Background(), sess, long, "default/foo", true, noopInfoLogger{}) + blocks := s.buildUserMessageBlocks(context.Background(), sess, long, nil, "default/foo", true, noopInfoLogger{}) var sections []*slackapi.SectionBlock var actions int diff --git a/pkg/channels/channelkinds/slack/sender_components_test.go b/pkg/channels/channelkinds/slack/sender_components_test.go new file mode 100644 index 00000000..18cf9bdc --- /dev/null +++ b/pkg/channels/channelkinds/slack/sender_components_test.go @@ -0,0 +1,98 @@ +package slack + +import ( + "context" + "encoding/json" + "testing" + + slackapi "github.com/slack-go/slack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelkinds" +) + +func userMessageEnvelopeWithComponents(t *testing.T, text string, components json.RawMessage) channelevents.Envelope { + t.Helper() + pl, err := json.Marshal(channelevents.OutboundUserMessagePayload{Text: text, Components: components}) + require.NoError(t, err, "marshal payload") + return channelevents.Envelope{ + Version: 1, + Kind: channelevents.KindUserMessage, + Session: channelevents.SessionRef{Namespace: "default", Name: "foo"}, + Payload: pl, + } +} + +// Agent-authored components become the message body; the text field is the +// notification fallback, not a duplicated body section. +func TestBuildUserMessageBlocks_ComponentsAreTheBody(t *testing.T) { + s := newSender(&fakeSlackClient{}) + sess := channelkinds.SessionInfo{Namespace: "default", Name: "foo"} + components := json.RawMessage(`[{"type":"header","text":{"type":"plain_text","text":"Results"}},{"type":"section","text":{"type":"mrkdwn","text":"*all good*"}}]`) + + blocks := s.buildUserMessageBlocks(context.Background(), sess, "plain summary", components, "default/foo", false, noopInfoLogger{}) + + var headers, sections int + for _, b := range blocks { + switch b.(type) { + case *slackapi.HeaderBlock: + headers++ + case *slackapi.SectionBlock: + sections++ + } + } + assert.Equal(t, 1, headers, "the agent's header block must render") + assert.GreaterOrEqual(t, sections, 1, "the agent's section block must render") + assert.NotContains(t, concatBlockText(blocks), "plain summary", + "the text field is the notification fallback, not a body section, when components are present") + assert.Contains(t, concatBlockText(blocks), "Results", "the agent's header content must be present") +} + +// Defense in depth: if components somehow fail to parse at render time (they +// were validated in the runner), the content is never dropped — the sender +// falls back to the text field as a section. +func TestBuildUserMessageBlocks_UnparseableComponentsFallBackToText(t *testing.T) { + s := newSender(&fakeSlackClient{}) + sess := channelkinds.SessionInfo{Namespace: "default", Name: "foo"} + + blocks := s.buildUserMessageBlocks(context.Background(), sess, "the summary text", json.RawMessage(`not valid json`), "default/foo", false, noopInfoLogger{}) + assert.Contains(t, concatBlockText(blocks), "the summary text", + "unparseable components must fall back to the text section so nothing is lost") +} + +// Absent components, behavior is unchanged: the text renders as section blocks. +func TestBuildUserMessageBlocks_NoComponentsUsesText(t *testing.T) { + s := newSender(&fakeSlackClient{}) + sess := channelkinds.SessionInfo{Namespace: "default", Name: "foo"} + + blocks := s.buildUserMessageBlocks(context.Background(), sess, "hello there", nil, "default/foo", false, noopInfoLogger{}) + assert.Contains(t, concatBlockText(blocks), "hello there", "text renders as a section when no components are present") +} + +// End to end through Send: the post carries Block Kit (Text + Blocks options) +// and the text is the notification fallback. (Block *content* is asserted by +// TestBuildUserMessageBlocks_ComponentsAreTheBody; slack-go does not expose the +// resolved blocks through a MsgOption, so the wire test asserts presence via the +// option shape — the same way the degrade test distinguishes block vs plain-text +// posts.) +func TestSenderUserMessage_ComponentsOnWire(t *testing.T) { + c := &fakeSlackClient{} + s := newSender(c) + sess := channelkinds.SessionInfo{ + Namespace: "default", Name: "foo", + Channel: &spiceboxv1alpha1.ChannelBinding{ + External: map[string]string{"channel_id": "C01ABCDEF"}, + }, + } + components := json.RawMessage(`[{"type":"header","text":{"type":"plain_text","text":"Quarterly Results"}}]`) + _, err := s.Send(context.Background(), sess, userMessageEnvelopeWithComponents(t, "summary fallback", components)) + require.NoError(t, err) + require.Len(t, c.postMessageCalls, 1, "one post") + + opts := c.postMessageCalls[0].options + assert.Len(t, opts, 2, "a components reply posts Text + Blocks (no thread_ts), not a degraded plain-text post") + assert.Equal(t, "summary fallback", renderTextFromOpts(t, opts), "text is the notification fallback") +} diff --git a/pkg/channels/channelkinds/slack/sender_test.go b/pkg/channels/channelkinds/slack/sender_test.go index 2010d958..69a7b09b 100644 --- a/pkg/channels/channelkinds/slack/sender_test.go +++ b/pkg/channels/channelkinds/slack/sender_test.go @@ -1930,7 +1930,7 @@ func TestSenderTurnProgress_MalformedPayloadReturnsError(t *testing.T) { // GetUserInfoContext is the id→profile direction renderSlackEntities needs. // Seeded via userInfo; an unseeded id returns users_not_found, the same shape // the real API gives for an id from another workspace. -func (f *fakeSlackClient) GetUserInfoContext(_ context.Context, id string) (*slackapi.User, error) { +func (f *fakeSlackClient) GetUserInfoContext(_ context.Context, id string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { f.mu.Lock() defer f.mu.Unlock() f.userInfoCalls = append(f.userInfoCalls, id) diff --git a/pkg/channels/channelkinds/slack/sender_tool_session_test.go b/pkg/channels/channelkinds/slack/sender_tool_session_test.go index 8fba4eda..ff352457 100644 --- a/pkg/channels/channelkinds/slack/sender_tool_session_test.go +++ b/pkg/channels/channelkinds/slack/sender_tool_session_test.go @@ -309,6 +309,6 @@ func TestToolSessionSender_OrdinarySession_ReportsTheBubbleAsRoot(t *testing.T) s.refs.get("tc-plain").stop() // stop the test goroutine's timer } -func (c *recordingClient) GetUserInfoContext(context.Context, string) (*slackapi.User, error) { +func (c *recordingClient) GetUserInfoContext(context.Context, string, ...slackapi.GetUserInfoOption) (*slackapi.User, error) { return nil, nil } diff --git a/pkg/channels/channelkinds/slack/show_settings_test.go b/pkg/channels/channelkinds/slack/show_settings_test.go index f36e5c39..50e91645 100644 --- a/pkg/channels/channelkinds/slack/show_settings_test.go +++ b/pkg/channels/channelkinds/slack/show_settings_test.go @@ -261,10 +261,10 @@ func TestBuildUserMessageBlocks_SettingsButton_FirstMessageOnly(t *testing.T) { } // no K8sClient: clamp line skipped sess := sessionInfoForTest("default", "my-session") - first := blocksJSON(t, s.buildUserMessageBlocks(noopCtx(t), sess, "hello", "default/my-session", true, noopLogger{})) + first := blocksJSON(t, s.buildUserMessageBlocks(noopCtx(t), sess, "hello", nil, "default/my-session", true, noopLogger{})) assert.Contains(t, first, showSettingsActionID, "first message carries the button") - later := blocksJSON(t, s.buildUserMessageBlocks(noopCtx(t), sess, "again", "default/my-session", false, noopLogger{})) + later := blocksJSON(t, s.buildUserMessageBlocks(noopCtx(t), sess, "again", nil, "default/my-session", false, noopLogger{})) assert.NotContains(t, later, showSettingsActionID, "subsequent messages must not carry the button") } @@ -399,7 +399,7 @@ func TestBuildUserMessageBlocks_ClampContextBlock_Present(t *testing.T) { statusCache: make(map[string]cachedStatus), } sess := sessionInfoForTest("default", "my-session") - blocks := s.buildUserMessageBlocks(noopCtx(t), sess, "Hello!", "default/my-session", true, noopLogger{}) + blocks := s.buildUserMessageBlocks(noopCtx(t), sess, "Hello!", nil, "default/my-session", true, noopLogger{}) txt := blocksText(blocks) assert.Contains(t, txt, "Budget capped by policy", @@ -423,7 +423,7 @@ func TestBuildUserMessageBlocks_ClampContextBlock_Absent(t *testing.T) { statusCache: make(map[string]cachedStatus), } sess := sessionInfoForTest("default", "my-session") - blocks := s.buildUserMessageBlocks(noopCtx(t), sess, "Hello!", "default/my-session", true, noopLogger{}) + blocks := s.buildUserMessageBlocks(noopCtx(t), sess, "Hello!", nil, "default/my-session", true, noopLogger{}) txt := blocksText(blocks) assert.NotContains(t, txt, "Budget capped by policy", @@ -441,7 +441,7 @@ func TestBuildUserMessageBlocks_ClampLine_BestEffort_OnGetError(t *testing.T) { } sess := sessionInfoForTest("default", "nonexistent-session") // Must not panic or return an error. - blocks := s.buildUserMessageBlocks(noopCtx(t), sess, "Hello!", "default/nonexistent-session", true, noopLogger{}) + blocks := s.buildUserMessageBlocks(noopCtx(t), sess, "Hello!", nil, "default/nonexistent-session", true, noopLogger{}) // Button still present; no clamp line. assert.True(t, hasActionBlockWithID(blocks, showSettingsActionID)) diff --git a/pkg/channels/channelkinds/slack/testhooks_test.go b/pkg/channels/channelkinds/slack/testhooks_test.go index 57774d03..09be70ad 100644 --- a/pkg/channels/channelkinds/slack/testhooks_test.go +++ b/pkg/channels/channelkinds/slack/testhooks_test.go @@ -35,7 +35,7 @@ func (*stubClient) PostMessageContext(_ context.Context, channelID string, _ ... func (*stubClient) PostEphemeralContext(context.Context, string, string, ...slackapi.MsgOption) (string, error) { return "", nil } -func (*stubClient) GetUserInfoContext(_ context.Context, user string) (*slackapi.User, error) { +func (*stubClient) GetUserInfoContext(_ context.Context, user string, _ ...slackapi.GetUserInfoOption) (*slackapi.User, error) { return &slackapi.User{ID: user}, nil } func (*stubClient) OpenViewContext(context.Context, string, slackapi.ModalViewRequest) (*slackapi.ViewResponse, error) {