Skip to content

Commit e3dd95c

Browse files
authored
fix(slack-gateway): treat empty visible output as no reply (#217)
1 parent d2de27f commit e3dd95c

4 files changed

Lines changed: 638 additions & 19 deletions

File tree

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
---
2+
date: 2026-04-16
3+
author: Onur Solmaz <onur@textcortex.com>
4+
title: Slack Gateway No-Reply Outcome Architecture
5+
tags: [spritz, slack, channel-gateway, runtime, error-handling, architecture]
6+
---
7+
8+
## Overview
9+
10+
Spritz should treat "the runtime produced no user-visible reply" as a normal,
11+
typed outcome instead of a gateway error.
12+
13+
Today the Slack gateway can collapse this case into a generic public error
14+
message. That is the wrong product behavior. If the runtime produced no visible
15+
reply, the gateway should usually send nothing to Slack.
16+
17+
This document defines the long-term contract for that behavior.
18+
19+
Related docs:
20+
21+
- [Slack Channel Gateway Implementation Plan](2026-03-24-slack-channel-gateway-implementation-plan.md)
22+
- [Unified Public Error Architecture](2026-04-03-unified-public-error-architecture.md)
23+
- [OpenClaw Integration](2026-03-13-openclaw-integration.md)
24+
25+
## Problem
26+
27+
The Slack gateway currently has a binary outcome model after it prompts the
28+
conversation runtime:
29+
30+
- reply succeeded and a message is posted to Slack
31+
- prompt path failed and the gateway may post a generic internal error message
32+
33+
That is too coarse.
34+
35+
There is a third real-world outcome:
36+
37+
- the runtime accepted and processed the prompt, but produced no user-visible
38+
message
39+
40+
This can happen for valid reasons, for example:
41+
42+
- the runtime only emitted internal reasoning or trace material
43+
- the runtime intentionally decided not to answer
44+
- the runtime ended with no assistant text after filtering or normalization
45+
- a future backend supports explicit "no reply" behavior
46+
47+
When that happens, posting a generic Slack error is misleading. Nothing may
48+
actually be broken. The runtime may have completed successfully and simply not
49+
produced deliverable content.
50+
51+
## Goals
52+
53+
- make "no visible reply" a first-class outcome in Spritz
54+
- stop posting false error messages to Slack for that outcome
55+
- keep true runtime or transport failures visible
56+
- make the contract reusable across channel gateways, not Slack-only in spirit
57+
- preserve observability so operators can distinguish `no_reply` from failures
58+
59+
## Non-Goals
60+
61+
- changing model behavior to always emit visible text
62+
- exposing internal reasoning or trace content to end users
63+
- inventing Slack-specific business logic for one backend only
64+
- suppressing genuine runtime, gateway, or transport errors
65+
66+
## Core Decision
67+
68+
Spritz should model delivery after a prompted conversation as three distinct
69+
outcomes:
70+
71+
1. `deliver_message`
72+
2. `no_reply`
73+
3. `hard_error`
74+
75+
The important rule is:
76+
77+
- `no_reply` is not a public error
78+
79+
For Slack, that means:
80+
81+
- `deliver_message`: post the message
82+
- `no_reply`: do not post a message
83+
- `hard_error`: post the generic failure message only when product policy says
84+
the user should see one
85+
86+
## Why This Is the Right Abstraction
87+
88+
The Slack gateway is a delivery adapter. Its job is to:
89+
90+
- send user input to a conversation runtime
91+
- receive the runtime outcome
92+
- map that outcome to Slack delivery behavior
93+
94+
The gateway should not infer that "empty visible output" means failure.
95+
96+
That inference is unsafe because:
97+
98+
- the runtime may have succeeded
99+
- the backend may intentionally support silent completion
100+
- "no visible output" and "internal execution failed" are semantically
101+
different
102+
- users see a false signal when the adapter converts silence into an error
103+
104+
The clean architecture is to make the runtime outcome explicit, then let the
105+
gateway handle each outcome deterministically.
106+
107+
## Proposed Contract
108+
109+
### Runtime prompt result
110+
111+
The prompt path should return a typed result, not just `(reply, promptSent,
112+
err)`.
113+
114+
Recommended shape:
115+
116+
```json
117+
{
118+
"type": "deliver_message",
119+
"message": "Hello from the runtime."
120+
}
121+
```
122+
123+
```json
124+
{
125+
"type": "no_reply",
126+
"reason": "empty_visible_output"
127+
}
128+
```
129+
130+
```json
131+
{
132+
"type": "hard_error",
133+
"publicMessage": "I hit an internal error while processing that request."
134+
}
135+
```
136+
137+
The internal representation does not need to match this JSON exactly, but the
138+
typed semantics should.
139+
140+
### Required fields
141+
142+
- `type`: one of `deliver_message`, `no_reply`, `hard_error`
143+
144+
### Outcome-specific fields
145+
146+
For `deliver_message`:
147+
148+
- `message`: non-empty user-visible text
149+
150+
For `no_reply`:
151+
152+
- `reason`: stable machine-readable reason such as `empty_visible_output`
153+
- optional operator metadata for logs and metrics
154+
155+
For `hard_error`:
156+
157+
- internal cause information for logs
158+
- optional public copy override when the channel should show one
159+
160+
## Slack Delivery Rules
161+
162+
### `deliver_message`
163+
164+
The gateway posts the returned message into the correct Slack thread.
165+
166+
Rules:
167+
168+
- message must be non-empty after final normalization
169+
- this is the only outcome that produces a normal assistant reply post
170+
171+
### `no_reply`
172+
173+
The gateway acknowledges the Slack event and posts nothing.
174+
175+
Rules:
176+
177+
- do not post the generic internal error message
178+
- do not synthesize filler text such as "No response"
179+
- do record structured logs and metrics
180+
181+
This is the key product fix.
182+
183+
### `hard_error`
184+
185+
The gateway handles the failure through the existing public error policy.
186+
187+
Rules:
188+
189+
- only real failures should reach this outcome
190+
- the generic Slack failure message remains acceptable here
191+
- transport failures and runtime execution failures stay visible
192+
193+
## What Counts as `no_reply`
194+
195+
Spritz should classify the following cases as `no_reply` unless product policy
196+
explicitly says otherwise:
197+
198+
- the runtime completed but returned no assistant-visible text
199+
- the runtime output reduced to empty content after normalization
200+
- the runtime emitted internal-only material that the channel adapter cannot
201+
deliver as a user message
202+
- the runtime explicitly signaled a silent completion outcome
203+
204+
The key test is simple:
205+
206+
- was there a successful prompt execution with no deliverable user-visible
207+
message?
208+
209+
If yes, the outcome is `no_reply`, not `hard_error`.
210+
211+
## What Does Not Count as `no_reply`
212+
213+
These are still `hard_error`:
214+
215+
- the prompt request could not be sent
216+
- the runtime failed before completing the request
217+
- the session could not be bootstrapped
218+
- the gateway could not resolve the channel session
219+
- the gateway had a real Slack post failure after deciding to deliver a message
220+
221+
That boundary matters because silent suppression is only correct for successful
222+
no-message completions, not real failures.
223+
224+
## Current Gap in the Slack Gateway
225+
226+
The current Slack gateway flow in
227+
[`integrations/slack-gateway/slack_events.go`](/Users/onur/repos/spritz/integrations/slack-gateway/slack_events.go)
228+
still treats part of this space as an error path.
229+
230+
Today, after prompting the runtime:
231+
232+
- if the prompt was sent and the path still returns an error
233+
- the gateway can overwrite the reply with:
234+
`I hit an internal error while processing that request.`
235+
- then it posts that message back to Slack
236+
237+
That behavior is reasonable for true failures, but wrong for the specific case
238+
where the prompt completed and the only issue is missing visible output.
239+
240+
The implementation gap is not "Slack needs to understand one model provider."
241+
The gap is "the runtime result contract does not cleanly distinguish no visible
242+
reply from hard failure."
243+
244+
## Recommended Implementation Shape
245+
246+
### 1. Introduce a typed delivery outcome in the prompt path
247+
248+
Refactor the conversation prompt flow so it returns a typed outcome object
249+
instead of relying on a mixed interpretation of:
250+
251+
- reply text
252+
- prompt-sent bookkeeping
253+
- error presence
254+
255+
That keeps the decision at the right layer.
256+
257+
### 2. Centralize empty-visible-output classification
258+
259+
One owning function should decide whether the runtime result is:
260+
261+
- `deliver_message`
262+
- `no_reply`
263+
- `hard_error`
264+
265+
Do not duplicate that logic at multiple Slack callsites.
266+
267+
### 3. Keep Slack posting logic dumb
268+
269+
The Slack gateway should only map typed outcomes to channel behavior:
270+
271+
- post message
272+
- post nothing
273+
- post failure message
274+
275+
This keeps the adapter simple and reusable.
276+
277+
### 4. Keep structured operator visibility
278+
279+
`no_reply` must still be visible operationally.
280+
281+
Record:
282+
283+
- outcome type
284+
- normalized reason
285+
- conversation ID
286+
- channel ID
287+
- message timestamp
288+
- whether the prompt was accepted
289+
290+
That gives operators evidence without turning silent completions into public
291+
errors.
292+
293+
## Observability
294+
295+
Spritz should track `no_reply` explicitly.
296+
297+
Recommended logs:
298+
299+
- prompt completed with `delivery_outcome=no_reply`
300+
- stable reason such as `empty_visible_output`
301+
- conversation and channel identifiers
302+
303+
Recommended metrics:
304+
305+
- `channel_gateway_prompt_outcomes_total{provider="slack",type="deliver_message"}`
306+
- `channel_gateway_prompt_outcomes_total{provider="slack",type="no_reply"}`
307+
- `channel_gateway_prompt_outcomes_total{provider="slack",type="hard_error"}`
308+
309+
Recommended alerts:
310+
311+
- alert on sustained `hard_error` rate
312+
- do not alert on normal low-volume `no_reply`
313+
- investigate `no_reply` spikes because they may reveal runtime regressions or
314+
policy mismatches
315+
316+
## Testing Strategy
317+
318+
This behavior needs direct regression coverage.
319+
320+
Required tests:
321+
322+
1. runtime returns normal visible text
323+
- Slack gateway posts exactly one reply
324+
2. runtime completes with empty visible output
325+
- Slack gateway posts nothing
326+
- gateway reports success for delivery bookkeeping
327+
3. runtime fails before prompt completion
328+
- Slack gateway follows the hard-failure path
329+
4. runtime fails after a typed `hard_error`
330+
- Slack gateway posts the generic error message when configured to do so
331+
5. Slack post fails after `deliver_message`
332+
- gateway preserves existing retry and deduplication behavior
333+
334+
Important assertion:
335+
336+
- the empty-output case must not post `I hit an internal error while
337+
processing that request.`
338+
339+
## Interaction With Public Error Policy
340+
341+
This design fits the broader public error architecture.
342+
343+
The public error model should be used when something user-visible failed.
344+
`no_reply` is different:
345+
346+
- it is a valid delivery outcome
347+
- it may still deserve operator visibility
348+
- it does not automatically deserve a user-facing error message
349+
350+
In plain terms:
351+
352+
- no visible answer is not the same thing as a visible failure
353+
354+
## Future Extension
355+
356+
Although Slack is the immediate driver, this should be treated as a shared
357+
channel-gateway contract.
358+
359+
Other adapters may also need to distinguish:
360+
361+
- message to send
362+
- nothing to send
363+
- actual failure
364+
365+
That argues for defining the outcome in the shared conversation delivery layer,
366+
not as Slack-only conditional logic.
367+
368+
## Migration Plan
369+
370+
1. Define the typed prompt delivery outcome in the conversation prompt layer.
371+
2. Update Slack gateway prompt handling to consume the typed result.
372+
3. Add regression tests for `no_reply`.
373+
4. Add outcome metrics and logs.
374+
5. Reuse the same contract in other channel adapters if and when needed.
375+
376+
## Final Recommendation
377+
378+
The production-ready fix is:
379+
380+
- make `no_reply` a first-class outcome
381+
- classify empty visible output into that outcome centrally
382+
- have Slack acknowledge the event and send nothing
383+
- reserve the generic Slack error message for real failures only
384+
385+
That is the smallest clean architecture that fixes the current behavior without
386+
adding provider-specific hacks or hiding genuine failures.

0 commit comments

Comments
 (0)