From af9593c593865f839d7586204de64c9a75d53dd0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:14:13 +0000 Subject: [PATCH 1/9] docs(adapters/slack/slackapi): state the true default backoff bound With a Slack Retry-After, each sleep reaches MaxDelay (1s), so two sleeps total MaxElapsed (2s), not about 600ms. A local 429 server that always sends Retry-After: 1 measured 2.01s over three attempts. --- adapters/slack/slackapi/retry.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/adapters/slack/slackapi/retry.go b/adapters/slack/slackapi/retry.go index 37c4ecf..bd1df81 100644 --- a/adapters/slack/slackapi/retry.go +++ b/adapters/slack/slackapi/retry.go @@ -39,11 +39,13 @@ type RetryPolicy struct { MaxDelay time.Duration } -// Default retry bounds are deliberately conservative so the worst-case backoff -// (about 200ms + 400ms = 600ms over two retries, capped at MaxElapsed) stays well -// under Slack's 3-second ack window. A longer Retry-After is capped at MaxDelay. -// When the next delay would pass MaxElapsed or the context deadline, retry stops -// with a typed RateLimited rather than sleeping past the deadline. +// Default retry bounds are deliberately conservative. Three attempts allow two +// backoff sleeps, which total 200ms + 400ms = 600ms when Slack sends no +// Retry-After. A Retry-After raises each sleep up to MaxDelay (1s), so the total +// backoff can reach MaxElapsed (2s). That stays under Slack's 3-second ack +// window, but the HTTP attempts add to it (see RetryPolicy). When the next delay +// would pass MaxElapsed or the context deadline, retry stops with a typed +// RateLimited rather than sleeping past the deadline. func (p RetryPolicy) withDefaults() RetryPolicy { out := p if out.MaxAttempts <= 0 { From d840009140d48983e12e91525f65032410d4fc38 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:14:13 +0000 Subject: [PATCH 2/9] docs(adr): amend ADR 0005 to state the retry bounds only Decision 3 claimed that the deadline check keeps synchronous retry inside the ack window. It bounds only the backoff sleep. The decision now says that MaxElapsed bounds the backoff and that the caller's context and HTTP client bound each HTTP attempt, with a dated note that points to #132. --- docs/adr/0005-rate-limit-handling.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/adr/0005-rate-limit-handling.md b/docs/adr/0005-rate-limit-handling.md index 51c81f3..0bf077d 100644 --- a/docs/adr/0005-rate-limit-handling.md +++ b/docs/adr/0005-rate-limit-handling.md @@ -4,6 +4,10 @@ Accepted +Amended 2026-10-29 (issue #136): + +- Decision 3 claimed that the deadline check keeps synchronous retry inside the ack window. The deadline check and `MaxElapsed` bound only the backoff sleep, not the HTTP attempts, as [#132](https://github.com/coder/chat/pull/132) corrected in the package docs. Decision 3 now states the bounds alone. + ## Context The **Go Chat Runtime** posts outbound replies through a **Platform Adapter**. Every platform throttles its API: Slack Web API returns HTTP 429 with a `Retry-After` header (seconds); Linear GraphQL enforces request and complexity limits with documented reset hints; a future Teams adapter (ADR 0007) is throttled by the Bot Framework REST connector, which also uses `Retry-After`. @@ -26,7 +30,7 @@ Outbound rate-limit retry/backoff lives in the **Platform Adapter**. It owns the 2. **Honor the platform signal first.** The adapter uses the platform's `Retry-After` / reset hint when present; otherwise bounded exponential backoff with jitter. The platform signal overrides computed backoff but is still clamped to the bounds below. -3. **Two hard bounds, plus a deadline.** Retry is bounded by an attempt cap and a cumulative-backoff ceiling (a proposed per-adapter **Retry Policy**), and by the caller's `context.Context` deadline. The single load-bearing invariant: the adapter never sleeps past the caller's context deadline. If the next wait would exceed the deadline (or the ceiling), it does not sleep and returns the throttling error. This keeps synchronous retry inside the ack window. +3. **Two hard bounds, plus a deadline.** Retry is bounded by an attempt cap and a cumulative-backoff ceiling (a proposed per-adapter **Retry Policy**), and by the caller's `context.Context` deadline. The single load-bearing invariant: the adapter never sleeps past the caller's context deadline. If the next wait would exceed the deadline (or the ceiling), it does not sleep and returns the throttling error. Retry backoff is bounded by `MaxElapsed`. Individual HTTP attempts are bounded by the caller's context and HTTP client. 4. **Per-adapter Retry Policy, conservative by default.** The **Retry Policy** lives in each adapter's own options, not in **Runtime Options** — rate-limit behavior is platform-specific, and **Runtime Options** stays coordination-only (DedupeTTL, ThreadLockTTL, Concurrency, and the ADR 0002 Dispatch Mode). The default attempt cap and cumulative ceiling sit comfortably under the tightest relevant ack deadline, so the synchronous path is safe without tuning. Retry is opt-out (`MaxAttempts: 1` disables it), not opt-in. From 26ab6e865efb2615887837bb239b009eb1d0619d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:14:30 +0000 Subject: [PATCH 3/9] test(adapters/slack): pin mrkdwn false on the DM-fallback post A Plain Text ephemeral message that falls back to a DM goes through chat.postMessage, which documents mrkdwn. The test fails when the fallback stops sending "mrkdwn": false. --- adapters/slack/slack_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/adapters/slack/slack_test.go b/adapters/slack/slack_test.go index 24bab5f..8c0618c 100644 --- a/adapters/slack/slack_test.go +++ b/adapters/slack/slack_test.go @@ -472,10 +472,18 @@ func TestPostingTextMarkdownEphemeralAndExplicitFallback(t *testing.T) { if sent == nil || sent.ID == "" { t.Fatalf("fallback sent = %#v", sent) } + if _, err := thread.PostEphemeral(context.Background(), + chat.Actor{Adapter: "slack", Tenant: "T1", ID: "U1", BotKind: chat.BotHuman}, + chat.Text("*plain fallback*"), + chat.EphemeralOptions{FallbackToDM: true}, + ); err != nil { + t.Fatalf("plain text ephemeral fallback: %v", err) + } api.assertPost(t, 0, slackPost{Channel: "C1", ThreadTS: "111.000", Text: "plain reply", Mrkdwn: new(false)}) api.assertPost(t, 1, slackPost{Channel: "C1", ThreadTS: "111.000", MarkdownText: "**portable**"}) api.assertPost(t, 2, slackPost{Channel: "D-fallback", MarkdownText: "**private fallback**"}) + api.assertPost(t, 3, slackPost{Channel: "D-fallback", Text: "*plain fallback*", Mrkdwn: new(false)}) } func TestNativeEphemeralPosting(t *testing.T) { From aa6801c1e9d4874d8bc6e58e1ff659b5e58a4e28 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:14:54 +0000 Subject: [PATCH 4/9] test(adapters/slack): require no mrkdwn on chat.postEphemeral Slack documents mrkdwn for chat.postMessage but not for chat.postEphemeral. The adapter test posts a Plain Text ephemeral message and expects no mrkdwn field. The slackapi test sets every PostEphemeralRequest field and checks that each encoded argument is one that Slack documents. Both fail until the field goes. --- adapters/slack/slack_test.go | 8 +++++ adapters/slack/slackapi/chat_test.go | 50 +++++++++++++++++++--------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/adapters/slack/slack_test.go b/adapters/slack/slack_test.go index 8c0618c..08e0ba0 100644 --- a/adapters/slack/slack_test.go +++ b/adapters/slack/slack_test.go @@ -531,7 +531,15 @@ func TestNativeEphemeralPosting(t *testing.T) { if sent == nil || sent.ID != "998.000" { t.Fatalf("native ephemeral sent = %#v", sent) } + if _, err := thread.PostEphemeral(context.Background(), + chat.Actor{Adapter: "slack", Tenant: "T1", ID: "U1", BotKind: chat.BotHuman}, + chat.Text("*plain*"), + chat.EphemeralOptions{}, + ); err != nil { + t.Fatalf("plain text native ephemeral: %v", err) + } api.assertPost(t, 0, slackPost{Channel: "C1", ThreadTS: "111.000", User: "U1", MarkdownText: "**private**"}) + api.assertPost(t, 1, slackPost{Channel: "C1", ThreadTS: "111.000", User: "U1", Text: "*plain*"}) } func newSlackRuntime(t *testing.T, api *slackAPIServer, opts slack.Options) *chat.Chat { diff --git a/adapters/slack/slackapi/chat_test.go b/adapters/slack/slackapi/chat_test.go index a83b8a6..9b3227a 100644 --- a/adapters/slack/slackapi/chat_test.go +++ b/adapters/slack/slackapi/chat_test.go @@ -2,6 +2,8 @@ package slackapi_test import ( "encoding/json" + "reflect" + "slices" "testing" "github.com/coder/chat/adapters/slack/slackapi" @@ -186,25 +188,43 @@ func TestPostEphemeral(t *testing.T) { methodsCheckEqual(t, "response", resp, &slackapi.PostEphemeralResponse{MessageTS: "1700000000.000200"}) } -func TestPostEphemeralMrkdwn(t *testing.T) { +func TestPostEphemeralSendsOnlyDocumentedArguments(t *testing.T) { t.Parallel() + // The arguments that https://docs.slack.dev/reference/methods/chat.postEphemeral lists. + documented := []string{ + "as_user", "attachments", "blocks", "channel", "icon_emoji", "icon_url", "link_names", + "markdown_text", "metadata", "parse", "text", "thread_ts", "user", "username", + } + + var req slackapi.PostEphemeralRequest + fields := reflect.ValueOf(&req).Elem() + for i := range fields.NumField() { + field := fields.Field(i) + switch field.Kind() { + case reflect.String: + field.SetString("x") + case reflect.Slice: + field.Set(reflect.MakeSlice(field.Type(), 1, 1)) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("field %s has unhandled kind %s", fields.Type().Field(i).Name, field.Kind()) + } + } + srv := slackapitest.NewServer(t) srv.Respond("chat.postEphemeral", json.RawMessage(`{"ok":true,"message_ts":"1700000000.000200"}`)) - - _, err := methodsNewClient(srv).PostEphemeral(t.Context(), slackapi.PostEphemeralRequest{ - Channel: "C1", - User: "U1", - Text: "*not bold*", - Mrkdwn: new(false), - }) - if err != nil { + if _, err := methodsNewClient(srv).PostEphemeral(t.Context(), req); err != nil { t.Fatalf("PostEphemeral: %v", err) } - methodsCheckJSONBody(t, srv, "chat.postEphemeral", `{ - "channel": "C1", - "user": "U1", - "text": "*not bold*", - "mrkdwn": false - }`) + var body map[string]json.RawMessage + if err := json.Unmarshal(methodsOnlyCall(t, srv, "chat.postEphemeral", methodsJSONType).Body, &body); err != nil { + t.Fatalf("decode chat.postEphemeral body: %v", err) + } + for name := range body { + if !slices.Contains(documented, name) { + t.Errorf("chat.postEphemeral body has argument %q, which Slack does not document", name) + } + } } From 620824756cb93d6580b6b62cbbaf2a0a7001bfed Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:15:39 +0000 Subject: [PATCH 5/9] fix(adapters/slack): stop sending mrkdwn to chat.postEphemeral Slack lists no mrkdwn argument for chat.postEphemeral. Remove Mrkdwn from slackapi.PostEphemeralRequest and stop setting it in PostEphemeralMessage. The DM fallback still sends "mrkdwn": false through chat.postMessage. No caller or test relied on the field to send literal ephemeral text, so the text needs no escape. --- adapters/slack/slack.go | 1 - adapters/slack/slackapi/chat.go | 3 --- 2 files changed, 4 deletions(-) diff --git a/adapters/slack/slack.go b/adapters/slack/slack.go index 163185e..4ecb097 100644 --- a/adapters/slack/slack.go +++ b/adapters/slack/slack.go @@ -356,7 +356,6 @@ func (a *Adapter) PostEphemeralMessage(ctx context.Context, thread chat.ThreadRe User: actor.ID, Text: messageFields.Text, MarkdownText: messageFields.MarkdownText, - Mrkdwn: messageFields.Mrkdwn, } if !thread.Direct { payload.ThreadTS = thread.Root diff --git a/adapters/slack/slackapi/chat.go b/adapters/slack/slackapi/chat.go index b2a6292..fca7ea2 100644 --- a/adapters/slack/slackapi/chat.go +++ b/adapters/slack/slackapi/chat.go @@ -67,9 +67,6 @@ type PostEphemeralRequest struct { // MarkdownText is the message text in standard Markdown. It cannot be // combined with Text or Blocks and holds at most 12,000 characters. MarkdownText string `json:"markdown_text,omitempty"` - // Mrkdwn set to false turns off Slack mrkdwn parsing of Text, as for - // PostMessageRequest. Nil uses the Slack default. - Mrkdwn *bool `json:"mrkdwn,omitempty"` } // PostEphemeralResponse is the response of chat.postEphemeral. From 5c0c4d186945ad374fde08813e0564bbc6505bd6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:15:55 +0000 Subject: [PATCH 6/9] docs(adapters/slack): say why PostNative builds its own request body A refactor onto the typed PostMessage would decode the native payload into []slackapi.Block and can drop fields that Block does not model. docs/reference.md says that PostNative posts opaque Block Kit blocks. --- adapters/slack/interactive.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index 6d23c18..271aa95 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -305,6 +305,8 @@ func (a *Adapter) PostNative(ctx context.Context, thread chat.ThreadRef, content if err != nil { return nil, err } + // docs/reference.md says PostNative posts opaque Block Kit blocks, so the payload + // goes out as is. Decoding it into []slackapi.Block can drop fields Block does not model. payload := nativePostPayload{ Channel: thread.Channel, Blocks: content.Payload, From 82cff015772941eff2287d1164c936da8d961d30 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:18:02 +0000 Subject: [PATCH 7/9] docs(adapters/slack): tighten the retry and PostNative comments The PostNative comment now names the real limit: []slackapi.Block models only markdown, actions, and image blocks. The withDefaults comment drops a sentence that repeated the RetryPolicy doc. --- adapters/slack/interactive.go | 4 ++-- adapters/slack/slackapi/retry.go | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index 271aa95..ce5228a 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -305,8 +305,8 @@ func (a *Adapter) PostNative(ctx context.Context, thread chat.ThreadRef, content if err != nil { return nil, err } - // docs/reference.md says PostNative posts opaque Block Kit blocks, so the payload - // goes out as is. Decoding it into []slackapi.Block can drop fields Block does not model. + // docs/reference.md says PostNative posts opaque Block Kit blocks. The typed + // PostMessage takes []slackapi.Block, which models only markdown, actions, and image blocks. payload := nativePostPayload{ Channel: thread.Channel, Blocks: content.Payload, diff --git a/adapters/slack/slackapi/retry.go b/adapters/slack/slackapi/retry.go index bd1df81..910f416 100644 --- a/adapters/slack/slackapi/retry.go +++ b/adapters/slack/slackapi/retry.go @@ -39,13 +39,12 @@ type RetryPolicy struct { MaxDelay time.Duration } -// Default retry bounds are deliberately conservative. Three attempts allow two -// backoff sleeps, which total 200ms + 400ms = 600ms when Slack sends no -// Retry-After. A Retry-After raises each sleep up to MaxDelay (1s), so the total -// backoff can reach MaxElapsed (2s). That stays under Slack's 3-second ack -// window, but the HTTP attempts add to it (see RetryPolicy). When the next delay -// would pass MaxElapsed or the context deadline, retry stops with a typed -// RateLimited rather than sleeping past the deadline. +// The defaults keep the total backoff under Slack's 3-second ack window. Three +// attempts allow two sleeps, which total 200ms + 400ms = 600ms when Slack sends +// no Retry-After. A Retry-After raises each sleep up to MaxDelay (1s), so the +// total can reach MaxElapsed (2s). When the next delay would pass MaxElapsed or +// the context deadline, retry stops with a typed RateLimited rather than +// sleeping past the deadline. func (p RetryPolicy) withDefaults() RetryPolicy { out := p if out.MaxAttempts <= 0 { From ded01fbc011e5e45c8f25e1aa8b9335320ee7010 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:25:52 +0000 Subject: [PATCH 8/9] docs(adr): fix the other ack-window claims in ADR 0005 Decision 4 and two Consequences also said that the default policy keeps synchronous retry inside the ack deadline. They now say that the caller's context or HTTP client bounds each HTTP attempt. --- docs/adr/0005-rate-limit-handling.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/0005-rate-limit-handling.md b/docs/adr/0005-rate-limit-handling.md index 0bf077d..01556d7 100644 --- a/docs/adr/0005-rate-limit-handling.md +++ b/docs/adr/0005-rate-limit-handling.md @@ -6,7 +6,7 @@ Accepted Amended 2026-10-29 (issue #136): -- Decision 3 claimed that the deadline check keeps synchronous retry inside the ack window. The deadline check and `MaxElapsed` bound only the backoff sleep, not the HTTP attempts, as [#132](https://github.com/coder/chat/pull/132) corrected in the package docs. Decision 3 now states the bounds alone. +- Decision 3 claimed that the deadline check keeps synchronous retry inside the ack window. The deadline check and `MaxElapsed` bound only the backoff sleep, not the HTTP attempts, as [#132](https://github.com/coder/chat/pull/132) corrected in the package docs. Decision 3 now states the bounds alone. Decision 4 and two Consequences made the same claim and now name what bounds each HTTP attempt. ## Context @@ -32,7 +32,7 @@ Outbound rate-limit retry/backoff lives in the **Platform Adapter**. It owns the 3. **Two hard bounds, plus a deadline.** Retry is bounded by an attempt cap and a cumulative-backoff ceiling (a proposed per-adapter **Retry Policy**), and by the caller's `context.Context` deadline. The single load-bearing invariant: the adapter never sleeps past the caller's context deadline. If the next wait would exceed the deadline (or the ceiling), it does not sleep and returns the throttling error. Retry backoff is bounded by `MaxElapsed`. Individual HTTP attempts are bounded by the caller's context and HTTP client. -4. **Per-adapter Retry Policy, conservative by default.** The **Retry Policy** lives in each adapter's own options, not in **Runtime Options** — rate-limit behavior is platform-specific, and **Runtime Options** stays coordination-only (DedupeTTL, ThreadLockTTL, Concurrency, and the ADR 0002 Dispatch Mode). The default attempt cap and cumulative ceiling sit comfortably under the tightest relevant ack deadline, so the synchronous path is safe without tuning. Retry is opt-out (`MaxAttempts: 1` disables it), not opt-in. +4. **Per-adapter Retry Policy, conservative by default.** The **Retry Policy** lives in each adapter's own options, not in **Runtime Options** — rate-limit behavior is platform-specific, and **Runtime Options** stays coordination-only (DedupeTTL, ThreadLockTTL, Concurrency, and the ADR 0002 Dispatch Mode). The default attempt cap and cumulative ceiling keep the total backoff under the tightest relevant ack deadline without tuning. The caller's context or HTTP client still bounds each HTTP attempt. Retry is opt-out (`MaxAttempts: 1` disables it), not opt-in. 5. **Explicit, typed exhaustion.** When bounded retry is exhausted, or a single `Retry-After` exceeds the caller's deadline, the adapter returns a typed `RateLimited` error carrying the adapter name, last `Retry-After`, attempt count, and the raw platform response as a **Platform Escape Hatch**. Callers branch on it to defer, drop, or notify; they do not string-match a generic error. @@ -66,11 +66,11 @@ Throttling detection is adapter-owned: Slack maps 429 + `Retry-After` and the `r ## Consequences - Adapters are hardened by default: a transient Slack 429 or Linear GraphQL throttle no longer drops a reply, with no app code change. Under the default `DispatchSync` the bounded retry can only absorb throttles whose `Retry-After` fits inside the ack window; a longer throttle surfaces as a typed `RateLimited` for the caller to defer or drop. The full benefit — waiting out a multi-second throttle with the **Lock Lease** held — lands under `DispatchDeferred`. -- The ack deadline is protected by construction. Synchronous retry cannot outlive the **Dispatch Context**, so throttling does not cause platform redelivery storms or duplicate handler runs. +- Synchronous retry cannot outlive the **Dispatch Context**. When that context or the HTTP client ends each attempt before the ack deadline, throttling does not cause platform redelivery storms or duplicate handler runs. - The small **Adapter** interface is unchanged — retry is internal to existing outbound calls. The core stays a **semantic subset**; no global limiter or queue is added. - Callers gain an explicit `RateLimited` branch point: defer to a **Detached Work Context** (ADR 0002), drop, or notify. The raw response stays reachable via the **Platform Escape Hatch**. - Operators see throttling in **Runtime Observation** and through ADR 0010 **Observation Hook** counters, without reading platform dashboards. -- Linear's first **Agent Activity Thought** stays inside the ~10s **Agent Session Timing Contract** because the default policy's ceiling is below it (ADR 0008). +- Linear's default backoff ceiling is below the ~10s **Agent Session Timing Contract** (ADR 0008). The first **Agent Activity Thought** stays inside it when the caller's context or HTTP client also bounds each attempt. - Cost: a per-adapter **Retry Policy** is one more option surface, and each adapter must classify its own throttling responses. This is accepted because throttling semantics are genuinely platform-specific and do not belong in the runtime. - Apps that truly need to wait out long throttles must restructure to **Ack-Then-Work** rather than getting a free unbounded wait. This is intended: it keeps the synchronous path honest. From 37b4f916d8d71b8ad3e6435847c9d87a088a34f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:29:51 +0000 Subject: [PATCH 9/9] docs: correct the PostNative reason, the ephemeral Plain Text exception, and the Linear bound PostNative skips the typed PostMessage because Payload is an any that it must post unchanged, not because Block is closed. CONTEXT.md and the interactive-components how-to now note that a native ephemeral Plain Text post keeps Slack formatting. The Linear withDefaults comment now gives its real worst-case backoff of 2s. --- CONTEXT.md | 1 + adapters/linear/retry.go | 7 ++++--- adapters/slack/interactive.go | 5 +++-- docs/how-to/interactive-components.md | 5 ++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ec02230..1b7df04 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -478,6 +478,7 @@ _Avoid_: Full platform schema, strict external SDK model - An **Optional Capability** is detected through a small Go interface implemented by a **Platform Adapter**. - Absence of an **Optional Capability** returns an explicit unsupported-capability result. - **Plain Text** carries no formatting intent; adapters should disable platform markdown parsing for it while preserving benign platform presentation such as automatic URL linking. +- A native Slack ephemeral **Plain Text** post is the exception. Slack documents no switch that turns formatting off for `chat.postEphemeral`, so Slack formats that text. Slack `chat.postMessage` posts, the DM fallback included, still send `mrkdwn: false`. - **Portable Markdown** carries conservative CommonMark formatting intent, not platform-native syntax. - The **Portable Markdown Subset** is paragraphs, line breaks, emphasis, strong emphasis, inline code, fenced code blocks, block quotes, links, unordered lists, and ordered lists. Strikethrough is extension-tolerated and may degrade literally. - Tables, task lists, images, raw HTML, heading hierarchy, footnotes, definition lists, and embedded media are outside the **Portable Markdown Subset**. diff --git a/adapters/linear/retry.go b/adapters/linear/retry.go index 347cdb1..a763958 100644 --- a/adapters/linear/retry.go +++ b/adapters/linear/retry.go @@ -30,9 +30,10 @@ type RetryPolicy struct { MaxDelay time.Duration } -// Default retry bounds are deliberately conservative so the worst-case backoff -// (about 200ms + 400ms = 600ms over two retries, capped at MaxElapsed) stays -// well under the ~10s first-thought window. +// The defaults keep the total backoff well under the ~10s first-thought window. +// Three attempts allow two sleeps, which total 200ms + 400ms = 600ms when Linear +// sends no Retry-After. A Retry-After raises each sleep up to MaxDelay (1s), so +// the total can reach 2s, below MaxElapsed (3s). func (p RetryPolicy) withDefaults() RetryPolicy { out := p if out.MaxAttempts <= 0 { diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index ce5228a..ca6b37c 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -305,8 +305,9 @@ func (a *Adapter) PostNative(ctx context.Context, thread chat.ThreadRef, content if err != nil { return nil, err } - // docs/reference.md says PostNative posts opaque Block Kit blocks. The typed - // PostMessage takes []slackapi.Block, which models only markdown, actions, and image blocks. + // docs/reference.md says PostNative posts opaque Block Kit blocks, and Payload is + // an any. The typed PostMessage takes []slackapi.Block, so it would need a decode + // and re-encode of a payload that must go out unchanged. payload := nativePostPayload{ Channel: thread.Channel, Blocks: content.Payload, diff --git a/docs/how-to/interactive-components.md b/docs/how-to/interactive-components.md index 7e23b3c..661f7b1 100644 --- a/docs/how-to/interactive-components.md +++ b/docs/how-to/interactive-components.md @@ -112,7 +112,10 @@ within `DedupeTTL`. `ev.Interaction.Actor` carries the Slack user ID, not a display name; the payload does not include one. `chat.Text` posts with Slack formatting turned -off, so `<@USERID>` shows up literally. To credit the user in plain text, +off, so `<@USERID>` shows up literally. A native ephemeral `chat.Text` post is +the exception. Slack documents no switch that turns formatting off for +`chat.postEphemeral`, so Slack formats that text. The DM fallback still turns +formatting off. To credit the user in plain text, look up their display name through the Slack API. For a real, clickable mention, post Block Kit content with an `mrkdwn` text element containing `<@USERID>`.