From 734cf797df7a8faf9651694a96f65d49d6f58877 Mon Sep 17 00:00:00 2001 From: Michael Chou Date: Mon, 17 Aug 2026 10:05:51 -0700 Subject: [PATCH] =?UTF-8?q?fix(agentex):=20Slack=20gateway=20=E2=80=94=20e?= =?UTF-8?q?mpty=20ack=20body=20+=20clearer=20breadcrumb=20attribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slack renders a bare `{}` JSON body as a stray message, so the ack-only responses (modal opened, view_submission close, ignored interactions) now send a truly EMPTY 200 via a _slack_ack helper instead of returning `{}`. - Breadcrumb footer reads "<@user> asked *agent*" instead of the ambiguous "via <@user> through *agent*" (via/through were synonyms back-to-back). Co-Authored-By: Claude Opus 4.8 --- agentex/openapi.yaml | 10 ++------ agentex/src/api/routes/slack.py | 25 ++++++++++++++----- .../use_cases/slack_gateway_use_case.py | 2 +- agentex/tests/unit/api/test_slack_routes.py | 23 +++++++++++++++++ 4 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 agentex/tests/unit/api/test_slack_routes.py diff --git a/agentex/openapi.yaml b/agentex/openapi.yaml index b62ae7a1..048b22f0 100644 --- a/agentex/openapi.yaml +++ b/agentex/openapi.yaml @@ -2563,10 +2563,7 @@ paths: description: Successful Response content: application/json: - schema: - additionalProperties: true - type: object - title: Response Slack Commands Slack Commands Post + schema: {} /slack/interactions: post: tags: @@ -2578,10 +2575,7 @@ paths: description: Successful Response content: application/json: - schema: - additionalProperties: true - type: object - title: Response Slack Interactions Slack Interactions Post + schema: {} /tracker/{tracker_id}: get: tags: diff --git a/agentex/src/api/routes/slack.py b/agentex/src/api/routes/slack.py index 011c8439..93f83be9 100644 --- a/agentex/src/api/routes/slack.py +++ b/agentex/src/api/routes/slack.py @@ -6,13 +6,22 @@ secrets microservice. Delegates all logic to SlackGatewayUseCase. """ -from fastapi import APIRouter, BackgroundTasks, Request +from fastapi import APIRouter, BackgroundTasks, Request, Response +from fastapi.responses import JSONResponse from src.domain.use_cases.slack_gateway_use_case import DSlackGatewayUseCase router = APIRouter(prefix="/slack", tags=["Slack"]) +def _slack_ack(result: dict) -> Response: + """Serialize a slash-command / interaction response for Slack. An ack-only result + (empty dict) becomes a truly EMPTY 200 body — Slack renders a bare ``{}`` JSON body + as a stray message, so "show nothing" must send no body, not ``{}``. A non-empty + result (ephemeral message / response_action) is sent as JSON.""" + return JSONResponse(result) if result else Response(status_code=200) + + @router.post("/events", summary="Slack Events API ingress for the @agent app") async def slack_events( request: Request, @@ -31,13 +40,15 @@ async def slack_events( async def slack_commands( request: Request, use_case: DSlackGatewayUseCase, -) -> dict: +) -> Response: # Slash commands are application/x-www-form-urlencoded, not JSON. Read the raw # body first (needed for signature verification), then parse the form. body = await request.body() form = dict(await request.form()) headers = {k.lower(): v for k, v in request.headers.items()} - return await use_case.handle_slash_command(body=body, headers=headers, form=form) + return _slack_ack( + await use_case.handle_slash_command(body=body, headers=headers, form=form) + ) @router.post("/interactions", summary="Slack interactivity ingress (modals, shortcuts)") @@ -45,12 +56,14 @@ async def slack_interactions( request: Request, background: BackgroundTasks, use_case: DSlackGatewayUseCase, -) -> dict: +) -> Response: # Interactions are form-encoded with a JSON `payload` field. Raw body first (for # signature verification), then the form. The turn runs out-of-band like events. body = await request.body() form = dict(await request.form()) headers = {k.lower(): v for k, v in request.headers.items()} - return await use_case.handle_interaction( - body=body, headers=headers, form=form, background=background + return _slack_ack( + await use_case.handle_interaction( + body=body, headers=headers, form=form, background=background + ) ) diff --git a/agentex/src/domain/use_cases/slack_gateway_use_case.py b/agentex/src/domain/use_cases/slack_gateway_use_case.py index 0c5c6e02..33b63e75 100644 --- a/agentex/src/domain/use_cases/slack_gateway_use_case.py +++ b/agentex/src/domain/use_cases/slack_gateway_use_case.py @@ -531,7 +531,7 @@ async def _submit_agents_modal( "elements": [ { "type": "mrkdwn", - "text": f"via <@{user}> through *{agent}*", + "text": f"<@{user}> asked *{agent}*", } ], }, diff --git a/agentex/tests/unit/api/test_slack_routes.py b/agentex/tests/unit/api/test_slack_routes.py new file mode 100644 index 00000000..4953646f --- /dev/null +++ b/agentex/tests/unit/api/test_slack_routes.py @@ -0,0 +1,23 @@ +"""Slack route ack helper: an ack-only result (empty dict) must be a TRULY EMPTY 200 +body — Slack renders a bare ``{}`` JSON body as a stray message, so "show nothing" has +to send no body, not ``{}``. A non-empty result is sent as JSON.""" + +import json + +import pytest +from fastapi.responses import JSONResponse +from src.api.routes.slack import _slack_ack + + +@pytest.mark.unit +class TestSlackAck: + def test_empty_result_is_empty_200_body(self): + resp = _slack_ack({}) + assert resp.status_code == 200 + assert resp.body == b"" # not b"{}" — nothing for Slack to render + + def test_non_empty_result_is_json(self): + payload = {"response_type": "ephemeral", "text": "hi"} + resp = _slack_ack(payload) + assert isinstance(resp, JSONResponse) + assert json.loads(resp.body) == payload