Skip to content

Commit dd8df1b

Browse files
mattcampclaude
andauthored
feat: quiet Slack notifications with in-place message updates (#30)
* feat: quiet Slack notifications with in-place message updates Redesign notifications to reduce channel noise: 1. The original "started working on" message is updated in-place on state changes (retrying, switching engine, completed, failed) via Slack's chat.update API. No new messages in the main channel. 2. Completion details (summary, MR link, cost) go in the thread only — reply_broadcast is now false, so the main channel stays clean. 3. Retries and engine fallbacks reuse the existing notification thread and inject SLACK_THREAD_TS, instead of calling runNotifyStart again and posting duplicate "started working on" messages. 4. Terminal failures update the original message with ❌ status. Notifications.Channel interface bumped to v3 with new UpdateMessage method. Discord and Telegram implementations are no-op. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update knownInterfaceVersions for notifications v3 The plugin host still expected version 1 for notification plugins, which would reject external plugins declaring interface_version=3. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df4bcc3 commit dd8df1b

8 files changed

Lines changed: 178 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **Quieter Slack notifications**: the original "started working on" message is
13+
now updated in-place with status changes (retrying, completed, failed) instead
14+
of posting new messages. Completion details go in the thread only — no more
15+
broadcast to the main channel. Retries and fallbacks reuse the existing thread
16+
instead of posting duplicate start messages. Notifications interface bumped to
17+
version 3 with new `UpdateMessage` method (`chat.update` for Slack).
18+
1019
### Fixed
1120

1221
- **Review poller acting on bot summary comments**: the classifier now only

internal/controller/controller.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,7 @@ func (r *Reconciler) handleJobComplete(ctx context.Context, tr *taskrun.TaskRun)
10371037
}
10381038

10391039
if hasTicket {
1040+
// Post detailed completion to the thread (no broadcast).
10401041
for _, n := range r.notifiers {
10411042
if err := n.NotifyComplete(ctx, cachedTicket, result, tr.NotificationThreadRef); err != nil {
10421043
r.logger.ErrorContext(ctx, "completion notification failed",
@@ -1045,6 +1046,18 @@ func (r *Reconciler) handleJobComplete(ctx context.Context, tr *taskrun.TaskRun)
10451046
)
10461047
}
10471048
}
1049+
// Update the original message in the main channel with final status.
1050+
statusEmoji := "\u2705"
1051+
statusText := "completed"
1052+
if !result.Success {
1053+
statusEmoji = "\u274C"
1054+
statusText = "failed"
1055+
}
1056+
statusLine := fmt.Sprintf("%s *Osmia %s:* %s", statusEmoji, statusText, cachedTicket.Title)
1057+
if result.MergeRequestURL != "" {
1058+
statusLine += fmt.Sprintf(" — <%s|View MR>", result.MergeRequestURL)
1059+
}
1060+
r.updateNotificationStatus(ctx, tr.NotificationThreadRef, statusLine)
10481061
}
10491062

10501063
// Extract knowledge from the completed task into episodic memory.
@@ -1512,6 +1525,10 @@ func (r *Reconciler) handleJobFailed(ctx context.Context, tr *taskrun.TaskRun, r
15121525
}
15131526
}
15141527

1528+
// Update the original notification message with failure status.
1529+
r.updateNotificationStatus(ctx, tr.NotificationThreadRef,
1530+
fmt.Sprintf("\u274C *Osmia failed:* %s", tr.TicketID))
1531+
15151532
// Extract knowledge from the failed task into episodic memory.
15161533
if r.memoryExtractor != nil {
15171534
go r.extractMemory(ctx, tr)
@@ -1558,6 +1575,11 @@ func (r *Reconciler) launchFallbackJob(ctx context.Context, tr *taskrun.TaskRun,
15581575

15591576
engineCfg := r.baseEngineConfig(engineName)
15601577

1578+
// Reuse the existing notification thread and update the original message.
1579+
injectThreadRef(&engineCfg, tr.NotificationThreadRef)
1580+
r.updateNotificationStatus(ctx, tr.NotificationThreadRef,
1581+
fmt.Sprintf("\U0001F504 *Switching engine:* %s", engineName))
1582+
15611583
if err := r.prepareSession(ctx, tr.ID); err != nil {
15621584
r.logger.ErrorContext(ctx, "failed to prepare session storage for fallback job",
15631585
"task_run_id", tr.ID,
@@ -1892,6 +1914,17 @@ func (r *Reconciler) resolvePreMergeApproval(ctx context.Context, tr *taskrun.Ta
18921914
)
18931915
}
18941916
}
1917+
statusEmoji := "\u2705"
1918+
statusText := "completed"
1919+
if !result.Success {
1920+
statusEmoji = "\u274C"
1921+
statusText = "failed"
1922+
}
1923+
statusLine := fmt.Sprintf("%s *Osmia %s:* %s", statusEmoji, statusText, cachedTicket.Title)
1924+
if result.MergeRequestURL != "" {
1925+
statusLine += fmt.Sprintf(" — <%s|View MR>", result.MergeRequestURL)
1926+
}
1927+
r.updateNotificationStatus(ctx, tr.NotificationThreadRef, statusLine)
18951928
}
18961929

18971930
if r.memoryExtractor != nil {
@@ -2532,6 +2565,23 @@ func (r *Reconciler) runNotifyStart(ctx context.Context, ticket ticketing.Ticket
25322565
return threadRef
25332566
}
25342567

2568+
// updateNotificationStatus updates the original notification message in-place
2569+
// with a brief status line. This keeps the main channel clean — only the
2570+
// original message changes, no new messages are posted.
2571+
func (r *Reconciler) updateNotificationStatus(ctx context.Context, threadRef string, status string) {
2572+
if threadRef == "" {
2573+
return
2574+
}
2575+
for _, n := range r.notifiers {
2576+
if err := n.UpdateMessage(ctx, threadRef, status); err != nil {
2577+
r.logger.WarnContext(ctx, "failed to update notification status",
2578+
"channel", n.Name(),
2579+
"error", err,
2580+
)
2581+
}
2582+
}
2583+
}
2584+
25352585
// injectThreadRef adds SLACK_THREAD_TS to the engine config's Env map so that
25362586
// agent pods can post threaded Slack messages via the MCP server.
25372587
func injectThreadRef(cfg *engine.EngineConfig, threadRef string) {
@@ -2699,6 +2749,11 @@ func (r *Reconciler) launchRetryJob(ctx context.Context, tr *taskrun.TaskRun, pr
26992749

27002750
engineCfg := r.baseEngineConfig(tr.CurrentEngine)
27012751

2752+
// Reuse the existing notification thread and update the original message.
2753+
injectThreadRef(&engineCfg, tr.NotificationThreadRef)
2754+
r.updateNotificationStatus(ctx, tr.NotificationThreadRef,
2755+
fmt.Sprintf("\U0001F504 *Retrying:* %s", task.Title))
2756+
27022757
if err := r.prepareSession(ctx, tr.ID); err != nil {
27032758
r.logger.ErrorContext(ctx, "failed to prepare session storage for retry job",
27042759
"task_run_id", tr.ID,
@@ -3702,6 +3757,17 @@ func (r *Reconciler) handleJudgeComplete(ctx context.Context, tr *taskrun.TaskRu
37023757
)
37033758
}
37043759
}
3760+
statusEmoji := "\u2705"
3761+
statusText := "completed"
3762+
if !result.Success {
3763+
statusEmoji = "\u274C"
3764+
statusText = "failed"
3765+
}
3766+
statusLine := fmt.Sprintf("%s *Osmia %s:* %s", statusEmoji, statusText, cachedTicket.Title)
3767+
if result.MergeRequestURL != "" {
3768+
statusLine += fmt.Sprintf(" — <%s|View MR>", result.MergeRequestURL)
3769+
}
3770+
r.updateNotificationStatus(ctx, tournamentThreadRef, statusLine)
37053771
}
37063772

37073773
// Extract memory from the winning candidate's task run.

pkg/plugin/host.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func NewHost(healthCfg HealthConfig, logger *slog.Logger) *Host {
9494
// are refused at load time.
9595
var knownInterfaceVersions = map[PluginType]int{
9696
PluginTypeTicketing: 1,
97-
PluginTypeNotifications: 1,
97+
PluginTypeNotifications: 3,
9898
PluginTypeApproval: 1,
9999
PluginTypeSecrets: 1,
100100
PluginTypeReview: 1,

pkg/plugin/notifications/discord/discord.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ func (d *DiscordChannel) NotifyComplete(ctx context.Context, ticket ticketing.Ti
145145
return d.sendEmbed(ctx, embed)
146146
}
147147

148+
// UpdateMessage is a no-op — Discord webhook messages cannot be updated.
149+
func (d *DiscordChannel) UpdateMessage(_ context.Context, _ string, _ string) error {
150+
return nil
151+
}
152+
148153
// Name returns the unique identifier for this notification channel.
149154
func (d *DiscordChannel) Name() string {
150155
return channelName

pkg/plugin/notifications/notifications.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
)
1313

1414
// InterfaceVersion is the current version of the NotificationChannel interface.
15-
const InterfaceVersion = 2
15+
const InterfaceVersion = 3
1616

1717
// Channel is the interface that notification backends must implement.
1818
// All methods are fire-and-forget — errors are logged but do not block
@@ -36,6 +36,11 @@ type Channel interface {
3636
// reply (and, where supported, broadcast to the channel) in the identified thread.
3737
NotifyComplete(ctx context.Context, ticket ticketing.Ticket, result engine.TaskResult, threadRef string) error
3838

39+
// UpdateMessage replaces the content of a previously posted message
40+
// identified by messageRef (e.g. a Slack message timestamp). Backends
41+
// that do not support updates should return nil (no-op).
42+
UpdateMessage(ctx context.Context, messageRef string, text string) error
43+
3944
// Name returns the unique identifier for this channel (e.g. "slack", "teams").
4045
Name() string
4146

pkg/plugin/notifications/slack/slack.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,29 @@ func (s *SlackChannel) NotifyComplete(ctx context.Context, ticket ticketing.Tick
199199
})
200200
}
201201

202-
// Broadcast to the channel when posting as a thread reply so that the
203-
// completion message is visible to anyone not following the thread.
204-
replyBroadcast := threadRef != ""
205-
_, err := s.postMessage(ctx, summary, blocks, threadRef, replyBroadcast)
202+
// Post completion details in the thread only — the controller updates the
203+
// original message in the main channel separately via UpdateMessage.
204+
_, err := s.postMessage(ctx, summary, blocks, threadRef, false)
206205
return err
207206
}
208207

208+
// UpdateMessage replaces the content of a previously posted message.
209+
// messageRef must be the Slack message timestamp returned by NotifyStart.
210+
func (s *SlackChannel) UpdateMessage(ctx context.Context, messageRef string, text string) error {
211+
if messageRef == "" {
212+
return nil
213+
}
214+
215+
blocks := []slackBlock{
216+
{
217+
Type: "section",
218+
Text: &slackText{Type: "mrkdwn", Text: text},
219+
},
220+
}
221+
222+
return s.updateMessage(ctx, messageRef, text, blocks)
223+
}
224+
209225
// Name returns the unique identifier for this notification channel.
210226
func (s *SlackChannel) Name() string {
211227
return channelName
@@ -274,3 +290,66 @@ func (s *SlackChannel) postMessage(ctx context.Context, fallbackText string, blo
274290

275291
return slackResp.TS, nil
276292
}
293+
294+
// slackUpdateMessage is the payload sent to chat.update.
295+
type slackUpdateMessage struct {
296+
Channel string `json:"channel"`
297+
TS string `json:"ts"`
298+
Text string `json:"text"`
299+
Blocks []slackBlock `json:"blocks"`
300+
}
301+
302+
// updateMessage replaces the content of a previously posted message via
303+
// chat.update. Requires the same chat:write scope as chat.postMessage.
304+
func (s *SlackChannel) updateMessage(ctx context.Context, ts string, fallbackText string, blocks []slackBlock) error {
305+
msg := slackUpdateMessage{
306+
Channel: s.channelID,
307+
TS: ts,
308+
Text: fallbackText,
309+
Blocks: blocks,
310+
}
311+
312+
body, err := json.Marshal(msg)
313+
if err != nil {
314+
return fmt.Errorf("marshalling slack update: %w", err)
315+
}
316+
317+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL+"/chat.update", bytes.NewReader(body))
318+
if err != nil {
319+
return fmt.Errorf("creating slack update request: %w", err)
320+
}
321+
322+
req.Header.Set("Content-Type", "application/json; charset=utf-8")
323+
req.Header.Set("Authorization", "Bearer "+s.token)
324+
325+
resp, err := s.httpClient.Do(req)
326+
if err != nil {
327+
return fmt.Errorf("sending slack update: %w", err)
328+
}
329+
defer resp.Body.Close()
330+
331+
respBody, err := io.ReadAll(resp.Body)
332+
if err != nil {
333+
return fmt.Errorf("reading slack update response: %w", err)
334+
}
335+
336+
if resp.StatusCode != http.StatusOK {
337+
return fmt.Errorf("slack API returned status %d: %s", resp.StatusCode, string(respBody))
338+
}
339+
340+
var slackResp slackResponse
341+
if err := json.Unmarshal(respBody, &slackResp); err != nil {
342+
return fmt.Errorf("unmarshalling slack update response: %w", err)
343+
}
344+
345+
if !slackResp.OK {
346+
return fmt.Errorf("slack API error: %s", slackResp.Error)
347+
}
348+
349+
s.logger.DebugContext(ctx, "slack message updated",
350+
slog.String("channel", s.channelID),
351+
slog.String("ts", ts),
352+
)
353+
354+
return nil
355+
}

pkg/plugin/notifications/slack/slack_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,15 @@ func TestSlackChannel_NotifyComplete(t *testing.T) {
225225
wantSummaryText: "Fixed the login flow",
226226
},
227227
{
228-
name: "successful completion threaded with broadcast",
228+
name: "successful completion threaded without broadcast",
229229
ticket: sampleTicket(),
230230
threadRef: "1234567890.000001",
231231
result: engine.TaskResult{
232232
Success: true,
233233
Summary: "Applied dependency upgrade",
234234
},
235235
wantSuccess: true,
236-
wantReplyBroadcast: true,
236+
wantReplyBroadcast: false,
237237
},
238238
{
239239
name: "failed completion",

pkg/plugin/notifications/telegram/telegram.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ func (t *TelegramChannel) NotifyComplete(ctx context.Context, ticket ticketing.T
132132
return t.sendMessage(ctx, text)
133133
}
134134

135+
// UpdateMessage is a no-op — Telegram bot messages would require storing
136+
// message IDs which is not currently supported.
137+
func (t *TelegramChannel) UpdateMessage(_ context.Context, _ string, _ string) error {
138+
return nil
139+
}
140+
135141
// Name returns the unique identifier for this notification channel.
136142
func (t *TelegramChannel) Name() string {
137143
return channelName

0 commit comments

Comments
 (0)