Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions agentex/src/domain/use_cases/slack_gateway_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,18 +198,37 @@ def _strip_selector(text: str, selector: str) -> str:
return stripped


def _turn_content(inbound: InboundSlack, prompt: str) -> str:
def _turn_content(
inbound: InboundSlack, prompt: str, *, self_posts: bool = False
) -> str:
"""Prepend the Slack conversation context to the turn.

normalize() is otherwise lossy — it hands the agent only the prompt text and drops
the channel/thread. But to read channel history the agent needs the channel id to
point its Slack tools at, so we prefix a short, clearly-delimited context line and
put the user's message after a blank line. Harmless when no Slack tool is enabled."""
put the user's message after a blank line. Harmless when no Slack tool is enabled.

``self_posts`` is set for agents the gateway does NOT relay (golden-agent, which has
Slack write tools). For them we add an explicit directive to post their own reply
into this thread, because nothing is delivered on their behalf — without it the turn
would run and produce text that never reaches Slack."""
context = (
f"[Slack context] channel_id={inbound.channel} thread_ts={inbound.thread_ts}. "
f"This message came from that Slack thread; to read earlier messages or the "
f"channel's history, use your Slack tools with this channel_id."
)
if self_posts:
context += (
" IMPORTANT: your text response is NOT posted to Slack for you. Deliver your "
f"reply by calling post_message(channel_id={inbound.channel}, "
f"thread_ts={inbound.thread_ts}, ...). Posting a message HIDES the 'thinking…' "
"indicator, so if a turn produces MORE THAN ONE message, call "
f"set_status(channel_id={inbound.channel}, thread_ts={inbound.thread_ts}, "
"status='is thinking…') right after each message that is NOT your final one; "
"do NOT call it after your final message, so the indicator clears there. Use "
"post_message for other channels/DMs too; only this thread's reply is your "
"responsibility to post."
)
return f"{context}\n\n{prompt}"


Expand Down Expand Up @@ -554,6 +573,24 @@ async def _run_turn(self, inbound: InboundSlack) -> None:
)
return

# golden-agent is the only agent with Slack WRITE tools (SlackBot is
# auto-enabled on every slack-origin golden-agent turn, config or not), so it
# posts its own reply into the thread. For it we DON'T relay (that would
# double-post the answer). We DO still show "thinking…" while it works — that
# clears on its own when the agent posts its reply (posting a message clears
# the assistant status), so the agent's post IS the done signal. Every other
# agent — registered agents (their own name, no config) and SYNC agents — has
# no Slack tools, so the gateway is the single writer and relays. Errors still
# surface via except. (config_id can't distinguish this: configs are personas
# that KEEP the golden-agent name, so the name is the exact self-posting
# signal.)
if target.agent_name == _DEFAULT_AGENT_NAME:
await self._set_status(inbound, "is thinking…")
await self._dispatch(
target, inbound, prompt, principal, auth_headers, collect=False
)
return

# AI-app "thinking…" indicator while the turn runs (assistant pane); cleared
# automatically when we post the reply. No-op outside an assistant thread.
await self._set_status(inbound, "is thinking…")
Expand Down Expand Up @@ -653,6 +690,8 @@ async def _dispatch(
prompt: str,
principal: Any,
auth_headers: dict[str, str],
*,
collect: bool = True,
) -> str | None:
"""Create-or-resume a task on the resolved agent, then inject the turn, acting as
the shared v1 identity (x-api-key -> principal for authz, delegated downstream as
Expand All @@ -676,9 +715,13 @@ async def _dispatch(
)
agent = await acp.agent_repository.get(name=target.agent_name)
task_name = f"slack:{inbound.thread_ts}"
# golden-agent isn't relayed (it self-posts), so its context gets the directive to
# post its own reply. Keyed on the same signal _run_turn uses to skip the relay.
content = TextContentEntity(
author=MessageAuthor.USER,
content=_turn_content(inbound, prompt),
content=_turn_content(
inbound, prompt, self_posts=target.agent_name == _DEFAULT_AGENT_NAME
),
format=TextFormat.MARKDOWN,
)
# First-turn task params: golden-agent's agent_config id (when this turn resolved
Expand Down Expand Up @@ -732,13 +775,22 @@ async def _dispatch(
# lagging) replica, which may not have the just-created task yet.
task = await self._resolve_task_after_race(acp.task_service, task_name)

# Snapshot existing messages so we can isolate THIS turn's reply.
seen = await self._seen_message_ids(acp.task_message_service, task.id)
# Snapshot existing messages so we can isolate THIS turn's reply (only needed
# when we're going to collect + relay it).
seen = (
await self._seen_message_ids(acp.task_message_service, task.id)
if collect
else set()
)
await acp.handle_rpc_request(
method=AgentRPCMethod.EVENT_SEND,
params=SendEventRequestEntity(task_name=task_name, content=content),
agent_id=agent.id,
)
# Self-posting agents (golden-agent) own their Slack output: send the event and
# return without polling — the gateway never relays their reply.
if not collect:
return None
Comment thread
greptile-apps[bot] marked this conversation as resolved.
# Poll the task's messages for this turn's settled agent reply.
return await self._collect_reply(acp.task_message_service, task.id, seen)

Expand Down
62 changes: 59 additions & 3 deletions agentex/tests/unit/use_cases/test_slack_gateway_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,24 @@ def test_prepends_channel_context_and_preserves_prompt(self):
assert "channel_id=C123" in content
assert "thread_ts=1700.1" in content
assert content.endswith("summarize") # user's prompt after the context block
assert "post_message" not in content # read-only context by default

def test_self_posts_adds_post_directive(self):
inbound = sg.InboundSlack(
team_id="T",
channel="C123",
user="U",
text="summarize",
thread_ts="1700.1",
selector=None,
)
content = sg._turn_content(inbound, "summarize", self_posts=True)
# golden-agent is told to deliver its own reply into the thread, and to keep the
# thinking indicator alive across multi-message turns via set_status.
assert "post_message" in content
assert "set_status" in content
assert "channel_id=C123" in content and "thread_ts=1700.1" in content
assert content.endswith("summarize")


@pytest.mark.unit
Expand Down Expand Up @@ -654,8 +672,15 @@ async def test_sync_agent_gives_up_after_exhausting_retries(self, monkeypatch):
@pytest.mark.unit
class TestRunTurn:
@pytest.mark.asyncio
async def test_delivers_reply_with_attribution(self, monkeypatch):
async def test_relays_reply_with_attribution_for_non_golden_agent(
self, monkeypatch
):
"""Non-golden agents have no Slack tools -> the gateway is the single writer, so
it relays their reply with attribution."""
uc = SlackGatewayUseCase()
monkeypatch.setattr(
uc, "_resolve_target", AsyncMock(return_value=(Target("pr-bot"), "hi"))
)
monkeypatch.setattr(uc, "_dispatch", AsyncMock(return_value="the answer"))
deliver = AsyncMock()
monkeypatch.setattr(uc, "_deliver", deliver)
Expand All @@ -667,7 +692,37 @@ async def test_delivers_reply_with_attribution(self, monkeypatch):

text = deliver.await_args.args[1]
assert "the answer" in text
assert "via golden-agent" in text
assert "via pr-bot" in text

@pytest.mark.asyncio
async def test_golden_agent_self_posts_without_relay(self, monkeypatch):
"""golden-agent posts its own reply via SlackBot, so the gateway does NOT relay
(no _deliver) — it just fires the turn with collect=False. It DOES set the
'thinking…' status (which clears when the agent posts). Uses config_id=None on
purpose: the signal is the golden-agent NAME, not the config."""
uc = SlackGatewayUseCase()
monkeypatch.setattr(
uc,
"_resolve_target",
AsyncMock(return_value=(Target(sg._DEFAULT_AGENT_NAME), "hi")),
)
dispatch = AsyncMock(return_value="ignored")
monkeypatch.setattr(uc, "_dispatch", dispatch)
deliver = AsyncMock()
monkeypatch.setattr(uc, "_deliver", deliver)
status = AsyncMock()
monkeypatch.setattr(uc, "_set_status", status)
inbound = sg.InboundSlack(
team_id="T", channel="C", user="U", text="hi", thread_ts="1", selector="hi"
)

await uc._run_turn(inbound)

deliver.assert_not_awaited() # gateway does NOT relay golden-agent's reply
status.assert_awaited_once() # but DOES show "thinking…" while it works
assert "thinking" in status.await_args.args[1]
dispatch.assert_awaited_once()
assert dispatch.await_args.kwargs.get("collect") is False # fire, don't poll

@pytest.mark.asyncio
async def test_denied_authz_does_not_dispatch(self, monkeypatch):
Expand Down Expand Up @@ -750,7 +805,8 @@ async def test_event_drives_dispatch(
assert target.agent_name == "golden-agent"
assert inbound_arg.thread_ts == expected_thread
assert prompt == expected_prompt
deliver.assert_awaited_once()
# golden-agent self-posts via SlackBot, so the gateway doesn't relay.
deliver.assert_not_awaited()


@pytest.mark.unit
Expand Down
Loading