Skip to content
Merged
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
7 changes: 4 additions & 3 deletions adapters/linear/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions adapters/slack/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +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, 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,
Expand Down
1 change: 0 additions & 1 deletion adapters/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,6 @@ func (a *Adapter) PostEphemeralMessage(ctx context.Context, thread chat.ThreadRe
User: actor.ID,
Text: messageFields.Text,
MarkdownText: messageFields.MarkdownText,
Comment thread
ThomasK33 marked this conversation as resolved.
Resolved
Mrkdwn: messageFields.Mrkdwn,
}
if !thread.Direct {
payload.ThreadTS = thread.Root
Expand Down
16 changes: 16 additions & 0 deletions adapters/slack/slack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -523,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 {
Expand Down
3 changes: 0 additions & 3 deletions adapters/slack/slackapi/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 35 additions & 15 deletions adapters/slack/slackapi/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package slackapi_test

import (
"encoding/json"
"reflect"
"slices"
"testing"

"github.com/coder/chat/adapters/slack/slackapi"
Expand Down Expand Up @@ -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)
}
}
}
11 changes: 6 additions & 5 deletions adapters/slack/slackapi/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ 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.
// 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 {
Expand Down
12 changes: 8 additions & 4 deletions docs/adr/0005-rate-limit-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Decision 4 and two Consequences made the same claim and now name what bounds each HTTP attempt.

## 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`.
Expand All @@ -26,9 +30,9 @@ 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.
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.

Expand Down Expand Up @@ -62,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.

Expand Down
5 changes: 4 additions & 1 deletion docs/how-to/interactive-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`.
Expand Down
Loading