Skip to content

fix(channels): an approval answer resumes the parked turn, and reaches the agent that asked - #6650

Draft
mmabrouk wants to merge 2 commits into
channels/fix-edit-and-deliveryfrom
channels/fix-approvals-seam
Draft

fix(channels): an approval answer resumes the parked turn, and reaches the agent that asked#6650
mmabrouk wants to merge 2 commits into
channels/fix-edit-and-deliveryfrom
channels/fix-approvals-seam

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member

Context

Stacked on #6649. Findings F100 and F101 in the ledger: the approval card rendered in the channel, but a click started a new turn with the word "Approve" in it, so the runner kept waiting for its answer and the card sat there. In a room with several agents, a typed "Approve" also went to the default agent instead of the one that asked.

Changes

  • The answer goes to the turn that parked. The pending choice the outbox stores with the card now carries the parked interaction's id. When a click, a typed answer, or a numbered reply resolves against it, the dispatcher answers the interaction through the sessions respond path, the same one a playground click uses, and opens no new turn. The continuation's turn events reach the outbox through the thread's session as any turn does. The pending choice is cleared once answered, so a second click is inert.
  • A typed answer reaches the agent whose question is open. A new DAO lookup finds the active thread under the message's thread key that holds a pending choice, whichever agent owns it; the resolve step tries it before the usual addressing chain.
  • A typed answer in the agent's own words resolves. "Approve" or "deny", compared without case, resolves like a click; the numbered reply still works.
  • Both composition roots wire the path. The API process passes its interactions dispatcher; the queue worker builds one from its own services. A deployment with no wiring logs the click and drops it rather than running it as a prompt.

Not in this PR

The ask mode under message scope is still not refused at registration; under that scope a card cannot be answered because each message is its own thread. This cut documents it as a limitation; the first release runs DMs and channels in thread scope.

Tests

  • New: the card carries the interaction id (render and outbox), a click names the interaction in the resolution, a typed answer reaches the asking agent and ordinary text does not, the dispatcher answers through the respond path and opens no turn, and drops the click when no path is wired. Channels unit tier at the top of the stack: 714 pass.
  • Integration tier against the stack: 65 pass. The live approval round trip on the Agenta channel is being run now; its result goes in a comment.

https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT

@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 8, 2026 2:18pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Summary

Summary by CodeRabbit

  • New Features
    • Approval-card responses from connected channels can now resume parked sessions.
    • Typed approval answers can match choice labels regardless of capitalization.
    • Approval responses are routed to the agent awaiting the answer without starting a new turn.
    • Pending choices retain the interaction information needed to process responses.
  • Bug Fixes
    • Prevented approval responses from being incorrectly handled as new conversations.
    • Improved routing so ordinary addressed messages continue reaching the intended agent.

Walkthrough

Channel approval choices now retain their parked interaction identifiers. Channel routing resolves clicks and typed labels, and inbox dispatch forwards answers to parked sessions without opening a new turn.

Changes

Channel approval responses

Layer / File(s) Summary
Choice metadata and rendering
api/oss/src/core/channels/dtos.py, api/oss/src/core/channels/render/*, api/oss/src/tasks/asyncio/channels/outbox.py, api/oss/tests/pytest/unit/channels/test_channels_outbox_worker.py, api/oss/tests/pytest/unit/channels/test_channels_render.py
Rendered approval choices and persisted pending choices now carry interaction_id.
Approval answer resolution
api/oss/src/core/channels/interfaces.py, api/oss/src/core/channels/service.py, api/oss/src/dbs/postgres/channels/dao.py, api/oss/tests/pytest/unit/channels/test_channels_service_routing.py
Channel resolution finds active threads with pending choices, matches labels case-insensitively, and returns the answered interaction and token.
Parked session dispatch
api/oss/src/tasks/asyncio/channels/inbox.py, api/entrypoints/worker_queues.py, api/entrypoints/routers.py, api/oss/tests/pytest/unit/channels/test_channels_inbox_dispatcher.py
Inbox dispatch forwards approval answers through InteractionsDispatcher, clears the pending choice, and skips new-turn creation.

Priority: ➖ Normal — Schedule the channel approval-response change because it spans API, workers, routing, persistence, and session resumption, while card rendering still defers the first release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: 🟠 High · up to 765cc

Approval responses can resume the wrong interaction, resume one interaction twice, or fail to resume it at all. These paths should be corrected before enabling channel approvals.

Sequence Diagram(s)

sequenceDiagram
  participant Channel
  participant ChannelsService
  participant ChannelsDAO
  participant InboxDispatcher
  participant InteractionsDispatcher
  Channel->>ChannelsService: submit approval click or typed label
  ChannelsService->>ChannelsDAO: find thread awaiting choice
  ChannelsDAO-->>ChannelsService: pending choice
  ChannelsService-->>InboxDispatcher: resolved interaction answer
  InboxDispatcher->>InteractionsDispatcher: respond_many
  InboxDispatcher->>ChannelsDAO: clear pending choice
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 14 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: approval answers resume parked turns and route to the asking agent.
Description check ✅ Passed The description directly explains the approval-answer routing changes, composition wiring, limitations, and test coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch channels/fix-approvals-seam

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Live QA on the stack found the outbound half of approvals is blocked by a sessions-layer gap, not this lane.

What this lane fixes (the inbound/return half) is unit- and integration-tested and correct: a click, a typed "Approve", or a numbered reply answers the parked session interaction through the sessions respond path and opens no new turn.

The gap: when an ask-mode agent parks on a write, the runner parks correctly (park-approval tool=Write, heartbeat running=false), but the channels outbox never renders the approval card, so the channel only ever shows "Working…". The outbox folds and renders on a turn_ended event on streams:sessions, and that event is published only by SessionTurnsService.complete_turn. A parked turn never completes, so nothing is published and the card is never drawn; the orphan-sweep watchdog later marks the turn lost.

Fix (a separate, sessions-domain change): publish a park signal to streams:sessions when a turn parks on an interaction — a turn_ended with stop_reason=paused, or a dedicated turn_paused — so the channels outbox can fold the paused turn and render the card. It needs a design call on where that signal is emitted. Approvals are deferred from the channels first cut until it lands.

@mmabrouk

mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Codex (astra, medium, simplify) reviewed this lane: "request changes" on answer selection. Applied now: the awaiting-choice DAO lookup excludes a cleared pending choice (stored as JSON null, which passed a bare IS NOT NULL) and inactive rows before the limit, so a newer cleared or closed thread can't hide an older one still waiting.

Two edge cases Codex raised are recorded as follow-ups, since approvals are deferred from the first cut until the outbound card-render-on-park gap is wired: (1) bind a button's value to its interaction id so a stale card can't answer a newer interaction; (2) an atomic claim so two clicks racing before the choice clears can't both respond. Both only bite once the card actually renders live, which it does not yet.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@mmabrouk I will review the updated pending-choice lookup and the inbound approval path. I will treat the deferred stale-card binding and atomic-claim cases as out of scope unless the current code makes them reachable.

⚠️ Action not completed

Head commit changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mmabrouk

mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Superseded context: the feature-flag lane is #FLAG (stacked on this). Linking once created.

mmabrouk added a commit that referenced this pull request Sep 8, 2026
…real interaction id

This completes the approval loop (F100/F101), which PR #6650 left with a
working answer path but no visible card.

- A parked turn never calls complete_turn, so the channels outbox was
  never told to fold it and the card never drew. The record-ingress now
  publishes a turn-ended to streams:sessions on every terminal record.
  That stream is consumed only by the channels outbox, and the outbox
  keys its output by (turn_id, index), so a turn that also ends through
  complete_turn just edits the same message instead of posting twice. It
  also covers the approval continuation, a detached run that bypasses
  complete_turn the same way.
- The outbox stored the fold's ACP token as the pending choice's
  interaction id; the sessions respond path answers by the
  SessionInteraction row id. The outbox now resolves the row id for the
  turn's open interaction (matched on the token) before storing it.

Verified live on the Agenta channel: the card renders, an "approve"
reply resumes the parked turn, the approved tool runs, and the agent's
answer ("Done! I've created hello.txt...") posts back. A normal turn
still answers exactly once, no duplicate.

Claude-Session: https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT
…s the agent that asked

The approval card rendered, and a click started a new turn with the
word "Approve" in it, so the runner kept waiting for its answer and
Slack sat on the card (F100, F101). Now:

- The pending choice the outbox stores with the card carries the parked
  interaction's id. A click, a typed "Approve", or a numbered reply that
  resolves against it makes the dispatcher answer the interaction
  through the sessions respond path, the same path a playground click
  uses, and no new turn opens. The continuation's turn events reach the
  outbox through the thread's session as any turn does. The pending
  choice is cleared once answered.
- A typed answer carries no agent, and the room's default was the wrong
  one when another agent asked. The thread that holds the open question
  now names the agent, whichever it is.
- A typed answer in the agent's own words ("Approve", "deny") resolves
  like a click; the numbered reply still works.
- Both composition roots, the API process and the queue worker, wire the
  answer path through their interactions dispatcher.

Claude-Session: https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT
…-choice lookup

Codex review of the approval lane: a cleared pending choice is stored as
JSON null, which passes a bare IS NOT NULL, and an inactive row could win
LIMIT 1 and then be rejected in Python. Filter active rows and a real
(non-JSON-null) pending choice before the limit, so a newer cleared or
closed thread never hides an older one that is still waiting.

Two further edge cases Codex raised stay as follow-ups, since approvals
are deferred from the first cut until the park signal that renders the
card is wired: binding a button's value to its interaction id so a stale
card cannot answer a newer interaction, and an atomic claim so two clicks
racing before the choice clears cannot both respond.

Claude-Session: https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT
mmabrouk added a commit that referenced this pull request Sep 8, 2026
…real interaction id

This completes the approval loop (F100/F101), which PR #6650 left with a
working answer path but no visible card.

- A parked turn never calls complete_turn, so the channels outbox was
  never told to fold it and the card never drew. The record-ingress now
  publishes a turn-ended to streams:sessions on every terminal record.
  That stream is consumed only by the channels outbox, and the outbox
  keys its output by (turn_id, index), so a turn that also ends through
  complete_turn just edits the same message instead of posting twice. It
  also covers the approval continuation, a detached run that bypasses
  complete_turn the same way.
- The outbox stored the fold's ACP token as the pending choice's
  interaction id; the sessions respond path answers by the
  SessionInteraction row id. The outbox now resolves the row id for the
  turn's open interaction (matched on the token) before storing it.

Verified live on the Agenta channel: the card renders, an "approve"
reply resumes the parked turn, the approved tool runs, and the agent's
answer ("Done! I've created hello.txt...") posts back. A normal turn
still answers exactly once, no duplicate.

Claude-Session: https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT
@mmabrouk
mmabrouk force-pushed the channels/fix-approvals-seam branch from a6e8a40 to 765cc6b Compare September 8, 2026 14:17
@mmabrouk

mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 45219e19-e342-447e-9b9f-d1b6bc4b7745

📥 Commits

Reviewing files that changed from the base of the PR and between ea51d08 and 765cc6b.

📒 Files selected for processing (14)
  • api/entrypoints/routers.py
  • api/entrypoints/worker_queues.py
  • api/oss/src/core/channels/dtos.py
  • api/oss/src/core/channels/interfaces.py
  • api/oss/src/core/channels/render/dtos.py
  • api/oss/src/core/channels/render/render.py
  • api/oss/src/core/channels/service.py
  • api/oss/src/dbs/postgres/channels/dao.py
  • api/oss/src/tasks/asyncio/channels/inbox.py
  • api/oss/src/tasks/asyncio/channels/outbox.py
  • api/oss/tests/pytest/unit/channels/test_channels_inbox_dispatcher.py
  • api/oss/tests/pytest/unit/channels/test_channels_outbox_worker.py
  • api/oss/tests/pytest/unit/channels/test_channels_render.py
  • api/oss/tests/pytest/unit/channels/test_channels_service_routing.py

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.

Comment on lines +257 to +277
async def _dispatch_detached_run(*, project_id, user_id, request) -> str:
result = await workflows_service.invoke_workflow_detached(
project_id=project_id,
user_id=user_id,
request=request,
)
return result.run_id

# A channel's approval answer resumes the parked session the way a
# playground click does: through the interactions dispatcher.
interactions_dispatcher = InteractionsDispatcher(
workflows_service=workflows_service,
interactions_service=SessionInteractionsService(
interactions_dao=SessionInteractionsDAO(engine=transactions_engine),
watch_publisher=SessionsWatchPublisher(),
),
records_service=RecordsService(
records_dao=RecordsDAO(engine=get_analytics_engine()),
),
dispatch_fn=_dispatch_detached_run,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 'dispatch_fn|_dispatch_detached_run|InteractionsDispatcher\(|turns_service|streams_service' \
  api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py \
  api/entrypoints/routers.py \
  api/entrypoints/worker_queues.py

Repository: Agenta-AI/agenta

Length of output: 31573


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- dispatcher contract ---'
sed -n '337,490p' api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py

printf '%s\n' '--- worker callback and compositions ---'
sed -n '230,365p' api/entrypoints/worker_queues.py

printf '%s\n' '--- detached invocation definitions ---'
rg -n -C 8 'def invoke_workflow_detached|async def invoke_workflow_detached|invoke_workflow_detached\(' \
  api/oss api/entrypoints -g '*.py'

Repository: Agenta-AI/agenta

Length of output: 24373


🤖 get_repo_knowledge executed:

get_repo_knowledge Agenta-AI/agenta /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/learnings /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/conventions

Length of output: 15180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- reference resolution implementation ---'
rg -n -C 35 'def resolve_interaction_references|async def resolve_interaction_references' \
  api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py

printf '%s\n' '--- interaction data/reference producers ---'
rg -n -C 12 'SessionInteractionData\(|references\s*=|continuation_execution_id|target_turn_id' \
  api/oss/src/tasks/asyncio api/entrypoints \
  -g '*.py' | head -n 420

Repository: Agenta-AI/agenta

Length of output: 198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 'resolve_interaction_references' api/oss/src/tasks/asyncio/sessions api/oss/src -g '*.py'

Repository: Agenta-AI/agenta

Length of output: 11793


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- reference fallback contract ---'
sed -n '1,150p' api/oss/src/core/sessions/interactions/references.py

printf '%s\n' '--- session service construction in worker entrypoint ---'
rg -n -C 12 'SessionTurnsService|SessionStreamsService|session_turns|session_streams' \
  api/entrypoints/worker_queues.py api/entrypoints/routers.py

Repository: Agenta-AI/agenta

Length of output: 25294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '2920,3055p' api/oss/src/core/workflows/service.py

Repository: Agenta-AI/agenta

Length of output: 5237


Wire session reference services into the channel dispatcher.

At api/entrypoints/worker_queues.py:267, InteractionsDispatcher receives no turns_service or streams_service. For interactions without stored references, resolve_interaction_references therefore returns no workflow references, and invoke_workflow_detached raises WorkflowServiceUrlMissing instead of resuming the session. Add the corresponding session services used by the API composition.

Comment on lines +445 to +455
async def fetch_thread_awaiting_choice(
self,
*,
project_id: UUID,
#
space_id: UUID,
external_key: Optional[UUID],
) -> Optional[ChannelThread]:
"""The active thread under this key, whichever agent holds it, that
has a pending choice; None when nothing is waiting for an answer."""
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make fetch_thread_awaiting_choice abstract.

ChannelsDAO implements the query, but FakeChannelsDAO inherits the current return None fallback. Any such DAO passed to ChannelsService can make _agent_awaiting_answer() skip pending-choice routing for typed answers without a construction error. Add @abstractmethod and explicit implementations to affected interface fakes.

tool = pending_interaction.get("tool")
title = f"Approval needed: {tool}" if tool else "Approval needed"
interaction_id = pending_interaction.get("id")
interaction_id = str(interaction_id) if interaction_id else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 'pending_interaction|interaction_id|UUID\(interaction_id\)' \
  api/oss/src/core/sessions \
  api/oss/src/tasks/asyncio/channels \
  api/oss/tests/pytest/unit/channels

Repository: Agenta-AI/agenta

Length of output: 50372


🤖 get_repo_knowledge executed:

get_repo_knowledge Agenta-AI/agenta /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/learnings /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/conventions

Length of output: 42478


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- render.py ---'
sed -n '1,180p' api/oss/src/core/channels/render/render.py

printf '%s\n' '--- channel DTO definitions ---'
rg -n -C 8 'class ChannelPendingChoice|class Channel.*Item|interaction_id' api/oss/src/core/channels

printf '%s\n' '--- interaction record and fold producers ---'
rg -n -C 8 'entity_id|interaction_request|pending_interaction|SessionInteraction|class Interaction' \
  api/oss/src/core/sessions/records api/oss/src/core/sessions \
  -g '*.py' | head -n 260

Repository: Agenta-AI/agenta

Length of output: 35643


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- pending_interaction construction ---'
rg -n -C 12 'pending_interaction\s*=|["'\'']pending_interaction["'\'']|interaction_request|interaction_id' \
  api/oss/src/core/channels api/oss/src/core/sessions \
  -g '*.py' | head -n 360

printf '%s\n' '--- UUID interaction model and persistence types ---'
rg -n -C 8 'class SessionInteraction|id: UUID|interaction_id: UUID|respond_interaction|UUID\(interaction_id\)' \
  api/oss/src/core/sessions api/oss/src/core/channels \
  -g '*.py' | head -n 320

Repository: Agenta-AI/agenta

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- pending_interaction construction ---'
rg -n -C 12 'pending_interaction|interaction_request|interaction_id' \
  api/oss/src/core/channels api/oss/src/core/sessions \
  -g '*.py' | head -n 360

printf '%s\n' '--- UUID interaction model and persistence types ---'
rg -n -C 8 'class SessionInteraction|id: UUID|interaction_id: UUID|respond_interaction|UUID\(interaction_id\)' \
  api/oss/src/core/sessions api/oss/src/core/channels \
  -g '*.py' | head -n 320

Repository: Agenta-AI/agenta

Length of output: 50372


Validate the interaction ID before rendering the approval card.

render_turn_result converts any truthy pending_interaction["id"] to a string. InboxDispatcher._answer_interaction later passes that value to UUID(interaction_id). If the ID is not UUID-formatted, dispatch raises before clearing the pending choice, so the approval remains pending. Reject invalid IDs before creating the resumable approval card, or enforce a UUID invariant for every paused interaction.

Comment on lines +1503 to +1512
waiting = await self.channels_dao.fetch_thread_awaiting_choice(
project_id=project_id,
space_id=space.id,
external_key=thread_key,
)
if waiting is None:
return None
token = resolve_pending_choice(
pending_choice=waiting.data.pending_choice, candidate=candidate
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🤖 get_repo_knowledge executed:

get_repo_knowledge Agenta-AI/agenta /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/learnings /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/conventions

Length of output: 9981


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- service resolve slice ---'
sed -n '1420,1555p' api/oss/src/core/channels/service.py
printf '%s\n' '--- pending-choice definitions and uses ---'
rg -n -C 4 'def resolve_pending_choice|resolve_pending_choice|pending_choice|answered_interaction_id|interaction_id' api/oss/src/core/channels api/oss/src/tasks/asyncio/channels api/oss/tests/pytest/unit/channels/test_channels_service_routing.py | head -n 500

Repository: Agenta-AI/agenta

Length of output: 46287


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- service resolve slice ---'
sed -n '1420,1555p' api/oss/src/core/channels/service.py
printf '%s\n' '--- direct definitions and callers ---'
rg -n -C 5 'def resolve_pending_choice|resolve_pending_choice|pending_choice|answered_interaction_id|interaction_id' api/oss/src/core/channels api/oss/src/tasks/asyncio/channels api/oss/tests/pytest/unit/channels/test_channels_service_routing.py

Repository: Agenta-AI/agenta

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- channel adapter action mapping ---'
rg -n -C 6 'ChannelEventKind\.ACTION|kind=.*ACTION|processed\.content|action_id|interaction_id|choice.*token|button|callback' api/oss/src/core/channels api/oss/src/tasks/asyncio/channels | head -n 500
printf '%s\n' '--- exact superseded-choice test ---'
sed -n '560,680p' api/oss/tests/pytest/unit/channels/test_channels_service_routing.py
printf '%s\n' '--- event DTOs ---'
rg -n -C 8 'class Channel(Inbox|Event)|class .*Event|processed:|external_locator|ChannelEventKind' api/oss/src/core/channels/dtos.py api/oss/src/core/channels

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- action event and adapter definitions ---'
fd -t f -i 'adapter|slack|discord|telegram|event|ingress' api/oss/src/core/channels api/oss/src | head -n 120
rg -n -C 5 'ChannelEventKind|processed\.content|action_id|callback_id|interaction_id|button|callback|choice\.token' api/oss/src/core/channels api/oss/src/integrations api/oss/src/entrypoints 2>/dev/null | head -n 500

Repository: Agenta-AI/agenta

Length of output: 41556


Authorization Bypass

Reachability: External
Exploitability: Moderate
CWE: CWE-863 — Incorrect Authorization

Bind each action to its rendered approval card.

Slack action parsing forwards only the button value as the candidate token. The action or card identity is not retained for validation. Since approval cards reuse approve and deny, an old card can resolve the current pending interaction and submit its response.

Persist an immutable card or choice-instance identifier in the rendered action and validate it before setting answered_interaction_id. Extend the superseded-choice test to cover an old approve action after a replacement card also uses approve.

Comment on lines +161 to +174
await self._respond_interaction_fn(
project_id=project_id,
user_id=user_id or resolution.agent.created_by_id,
interaction_id=UUID(interaction_id),
answer={"approved": approved, "message": resolution.resolved_choice},
)
# clear the question so the common case (a later card supersedes it) has
# nothing stale to resolve against; this is not a concurrency guard --
# two clicks racing before either clears is a known follow-up (F101).
await self.channels_service.channels_dao.set_pending_choice(
project_id=project_id,
thread_id=resolution.thread.id,
pending_choice=None,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🤖 get_repo_knowledge executed:

get_repo_knowledge Agenta-AI/agenta /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/conventions /tmp/coderabbit-repo-knowledge/agenta-ai-agenta-4b53879a/learnings

Length of output: 24296


🏁 Script executed:

#!/bin/bash
set -eu
file='api/oss/src/tasks/asyncio/channels/inbox.py'
printf '%s\n' '--- target file ---'
sed -n '1,240p' "$file"
printf '%s\n' '--- related definitions and callers ---'
rg -n -C 4 'set_pending_choice|_answer_interaction|respond_interaction|pending_choice|channels_dao' api/oss/src

Repository: Agenta-AI/agenta

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- inbox resolution path ---'
sed -n '250,410p' api/oss/src/tasks/asyncio/channels/inbox.py
printf '%s\n' '--- pending-choice contract and implementation ---'
sed -n '450,515p' api/oss/src/core/channels/interfaces.py
sed -n '1175,1235p' api/oss/src/dbs/postgres/channels/dao.py
printf '%s\n' '--- interaction response wiring and contract ---'
rg -n -C 8 'respond_interaction_fn|InboxDispatcher\(|respond_interaction\(' api/oss/src --glob '*.py' | head -240
printf '%s\n' '--- response command implementation ---'
rg -n -C 8 'async def respond_interaction|def respond_interaction|respond_interactions' api/oss/src/core api/oss/src/services api/oss/src/apis/fastapi/sessions api/oss/src/tasks/taskiq/sessions --glob '*.py'

Repository: Agenta-AI/agenta

Length of output: 30953


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- interaction worker callback ---'
sed -n '1,85p' api/oss/src/tasks/taskiq/sessions/interactions_worker.py
printf '%s\n' '--- response service implementation ---'
sed -n '611,760p' api/oss/src/core/sessions/commands/service.py
printf '%s\n' '--- response router completion and idempotency handling ---'
sed -n '1338,1455p' api/oss/src/apis/fastapi/sessions/router.py
printf '%s\n' '--- dispatcher construction sites ---'
rg -n -C 12 'InboxDispatcher' api/oss/src --glob '*.py'

Repository: Agenta-AI/agenta

Length of output: 21999


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- duplicate-answer handling ---'
sed -n '690,835p' api/oss/src/core/sessions/commands/service.py
printf '%s\n' '--- interaction dispatcher ---'
sed -n '1,220p' api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py
printf '%s\n' '--- all response callback wiring ---'
rg -n -C 10 'respond_interaction_fn\s*=|respond_interaction_fn=|interactions\.respond|respond_interaction\.kiq|respond_interaction\.kicker' . --glob '*.py' --glob '!**/node_modules/**'

Repository: Agenta-AI/agenta

Length of output: 30369


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- channel response callback ---'
sed -n '220,300p' api/entrypoints/worker_queues.py
printf '%s\n' '--- API channel response callback ---'
sed -n '1085,1170p' api/entrypoints/routers.py
printf '%s\n' '--- interactions dispatcher response entrypoints ---'
rg -n 'async def respond|respond_many|idempotency' api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py
sed -n '300,520p' api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py

Repository: Agenta-AI/agenta

Length of output: 13709


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- ChannelsService.resolve and pending-choice resolution ---'
rg -n 'async def resolve|answered_interaction_id|pending_choice' api/oss/src/core/channels/service.py api/oss/src/core/channels --glob '*.py'

Repository: Agenta-AI/agenta

Length of output: 4682


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '1235,1480p' api/oss/src/core/channels/service.py

Repository: Agenta-AI/agenta

Length of output: 9678


Claim the pending choice before responding.

Two inbox workers can resolve the same pending choice before InboxDispatcher._answer_interaction clears it. The configured callback can then dispatch the same continuation twice because InteractionsDispatcher.respond_many has no atomic claim or idempotency key. Add a service/DAO compare-and-set claim for the expected interaction identifier. Only the successful claimant should invoke the callback. Keep the claim recoverable when the callback fails.

Comment on lines +170 to +174
await self.channels_service.channels_dao.set_pending_choice(
project_id=project_id,
thread_id=resolution.thread.id,
pending_choice=None,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Route the pending-choice write through ChannelsService.

Line 170 bypasses the service layer and calls the DAO directly. Add a ChannelsService.set_pending_choice method and call it from InboxDispatcher.

As per coding guidelines, “Required direction: Router -> Service -> DAO Interface -> DAO Implementation -> DB”.

Source: Coding guidelines

mmabrouk added a commit that referenced this pull request Sep 8, 2026
…real interaction id

This completes the approval loop (F100/F101), which PR #6650 left with a
working answer path but no visible card.

- A parked turn never calls complete_turn, so the channels outbox was
  never told to fold it and the card never drew. The record-ingress now
  publishes a turn-ended to streams:sessions on every terminal record.
  That stream is consumed only by the channels outbox, and the outbox
  keys its output by (turn_id, index), so a turn that also ends through
  complete_turn just edits the same message instead of posting twice. It
  also covers the approval continuation, a detached run that bypasses
  complete_turn the same way.
- The outbox stored the fold's ACP token as the pending choice's
  interaction id; the sessions respond path answers by the
  SessionInteraction row id. The outbox now resolves the row id for the
  turn's open interaction (matched on the token) before storing it.

Verified live on the Agenta channel: the card renders, an "approve"
reply resumes the parked turn, the approved tool runs, and the agent's
answer ("Done! I've created hello.txt...") posts back. A normal turn
still answers exactly once, no duplicate.

Claude-Session: https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant