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
2 changes: 2 additions & 0 deletions docs/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ Health snapshot: `curl http://127.0.0.1:3456/router/health` (per-adapter `status
| Field | Values | Description |
|---|---|---|
| `questionMode` | `"interactive"` \| `"disabled"` | When `interactive`, the agent's `question` tool renders Slack actions blocks / Telegram inline keyboards with a numbered-text fallback. When unset or `"disabled"`, the question tool isn't initialized. |
| `questionNudgeIntervalSeconds` | `int` | Idle gap after which the bridge re-posts a "still waiting for your answer" nudge to a session's bound peers when a `question` is outstanding, and the spacing between subsequent nudges. Re-surfaces an answer lost in transit (e.g. a misrouted bridge reply) instead of letting the step hang to the job's hard deadline. `0` → built-in default (5 min); `<0` → disable nudging. |
| `questionNudgeMax` | `int` | Caps how many nudges are sent for a single pending question (so a walked-away reviewer can't be pinged forever). `0` → built-in default (3); `<0` → unlimited (bounded in practice by the job deadline). |
| `permissionMode` | `"allow"` \| `"deny"` \| `"ask"` \| empty | How the bridge resolves agent permission requests on bridge-bound sessions. `allow`/`deny` auto-resolve; `ask`/empty defer to opencode's default UI (will hang headless). Unrecognised values fail-safe to deny with a one-shot WARN log. |
| `toolUpdatesEnabled` | `bool` | Stream tool-call lifecycle (`🔧 <tool> · <params>`, `✓ <tool> · <result>`, `✗ <tool> · <error>`) to chat. Error lines surface regardless of this flag. |
| `channels.{telegram,slack,mattermost}` | object | Per-platform configuration; see below. |
Expand Down
47 changes: 47 additions & 0 deletions internal/bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,53 @@ type Inbound struct {
// CommandArgs is the remainder of the message after the command name,
// if Command is set.
CommandArgs string `json:"commandArgs,omitempty"`
// Source classifies how the adapter produced this inbound (see the
// InboundSource* constants). The question router reads it to decide
// whether the transport already gave the reviewer visual feedback —
// a button click self-renders a "✓ Answered" widget, so no extra
// acknowledgment is sent — versus a typed/custom answer, which gets
// none otherwise and so is acknowledged explicitly. Empty when the
// producing adapter (or an older orchestrator, over /router/inbound)
// didn't set it; treated as "unknown", which SUPPRESSES the extra ack
// so an unstamped button click is never double-acknowledged.
Source string `json:"source,omitempty"`
}

// InboundSource classifies how a reviewer's inbound was produced. It rides
// on Inbound.Source (JSON `source`) across the /router/inbound wire so the
// orchestrator-mediated path and the daemon (adapter-owned) path agree on
// whether an explicit answer acknowledgment is warranted.
const (
// InboundSourceButton is a question-UI block-actions button click. The
// adapter/orchestrator already rewrote the message to "✓ Answered", so
// the question router does NOT send a separate acknowledgment.
InboundSourceButton = "button"
// InboundSourceModal is a custom-answer modal submit (view_submission).
// No widget confirmation is rendered for it, so it IS acknowledged.
InboundSourceModal = "modal"
// InboundSourceMessage is a free-text DM / channel-thread message. No
// widget feedback exists for it, so a typed answer IS acknowledged.
InboundSourceMessage = "message"
// InboundSourceAppMention is an @-mention of the bot in a channel. Like
// a plain message, a typed answer via mention IS acknowledged.
InboundSourceAppMention = "appmention"
)

// AnswerWasAcknowledgedByTransport reports whether the transport that
// produced this inbound already gave the reviewer visible confirmation of
// their answer (today: only the interactive button, which self-renders a
// "✓ Answered" widget). When true the question router skips its own
// acknowledgment to avoid double feedback. Unknown/empty Source returns
// true (conservative: suppress the extra ack rather than risk double-acking
// an unstamped button click from an older orchestrator).
func (in Inbound) AnswerWasAcknowledgedByTransport() bool {
switch in.Source {
case InboundSourceModal, InboundSourceMessage, InboundSourceAppMention:
return false
default:
// InboundSourceButton and any unknown/empty value.
return true
}
}

// Outbound is the normalized representation of an outbound message the
Expand Down
16 changes: 16 additions & 0 deletions internal/bridge/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ type Config struct {
// router_send agent tool to specific peers. NOT enforced in v1 (see
// chat-bridge-agent-tool spec). Schema-defined but ignored at runtime.
AgentPeerAllowlist []PeerRef `json:"agentPeerAllowlist,omitempty"`

// QuestionNudgeIntervalSeconds is the idle gap after which the question
// router re-posts a short "still waiting for your answer" status to the
// bound peers of a session with an outstanding question, and the spacing
// between subsequent nudges. It re-surfaces a question whose answer was
// lost in transit (e.g. a misrouted bridge reply) instead of letting the
// interactive step sit silent until the job's hard deadline.
// 0 → use the built-in default (QuestionNudgeDefaultInterval)
// <0 → disable nudging entirely
QuestionNudgeIntervalSeconds int `json:"questionNudgeIntervalSeconds,omitempty"`

// QuestionNudgeMax caps how many nudges are sent for a single pending
// question (so a walked-away reviewer can't be pinged forever).
// 0 → use the built-in default (QuestionNudgeDefaultMax)
// <0 → unlimited (bounded in practice by the job deadline)
QuestionNudgeMax int `json:"questionNudgeMax,omitempty"`
}

// ChannelsConfig holds per-platform channel sections.
Expand Down
30 changes: 30 additions & 0 deletions internal/bridge/inbound_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package bridge

import "testing"

// TestAnswerWasAcknowledgedByTransport pins the ack-suppression truth table
// that the question router relies on (GENAI-151): only sources that leave the
// reviewer with NO visible confirmation get an explicit acknowledgment; a
// button (self-renders "✓ Answered") and any unknown/empty source are treated
// as already-acked so an unstamped inbound from an older orchestrator is never
// double-acknowledged.
func TestAnswerWasAcknowledgedByTransport(t *testing.T) {
t.Parallel()
cases := []struct {
source string
alreadyAckd bool
}{
{InboundSourceButton, true}, // widget already shows "✓ Answered"
{"", true}, // unknown / unstamped → suppress (never double-ack a button)
{"someFutureSource", true}, // unknown → suppress
{InboundSourceMessage, false}, // typed DM/channel answer → ack
{InboundSourceAppMention, false}, // @mention answer → ack
{InboundSourceModal, false}, // custom-answer modal submit → ack
}
for _, c := range cases {
got := Inbound{Source: c.source}.AnswerWasAcknowledgedByTransport()
if got != c.alreadyAckd {
t.Errorf("source=%q AnswerWasAcknowledgedByTransport()=%v, want %v", c.source, got, c.alreadyAckd)
}
}
}
5 changes: 5 additions & 0 deletions internal/bridge/mattermost/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,11 @@ func (a *Adapter) dispatchPosted(ctx context.Context, ev WSEvent, inbound chan<-
Text: text,
AuthorID: post.UserID,
ReceivedAt: time.Now().UnixMilli(),
// Mattermost has no interactive question widget — every answer is a
// typed post with no transport-side feedback, so a reply to a pending
// question gets an explicit acknowledgment downstream (see
// QuestionRouter.TryHandleQuestionReply).
Source: bridge.InboundSourceMessage,
}

// Persist any attached files into the bridge media store. Failures
Expand Down
5 changes: 5 additions & 0 deletions internal/bridge/mattermost/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ func TestHandlesDMPostedEvents(t *testing.T) {
if in.Peer.PeerID != "dm_channel_1|post1" {
t.Errorf("PeerID = %q, want dm_channel_1|post1", in.Peer.PeerID)
}
// Mattermost has no interactive widget → typed answer → tagged so a
// reply to a pending question gets an explicit acknowledgment.
if in.Source != bridge.InboundSourceMessage {
t.Errorf("Source = %q, want %q", in.Source, bridge.InboundSourceMessage)
}
}

func TestFiltersOwnMessages(t *testing.T) {
Expand Down
163 changes: 163 additions & 0 deletions internal/bridge/service/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ const (
recentlyAnsweredTTL = 30 * time.Second
recentlyAnsweredSweepInterval = 10 * time.Second

// questionNudgeDefaultInterval is the idle gap before the first "still
// waiting" nudge, and the spacing between subsequent nudges, when the
// operator hasn't overridden cfg.QuestionNudgeIntervalSeconds. Chosen so
// a reviewer who stepped away is reminded a few times well inside a
// typical job deadline, without pinging on every brief pause.
questionNudgeDefaultInterval = 5 * time.Minute
// questionNudgeDefaultMax caps nudges per pending question when the
// operator hasn't overridden cfg.QuestionNudgeMax.
questionNudgeDefaultMax = 3
// questionNudgeSweepInterval is how often the nudger wakes to look for
// questions that have gone quiet past the interval. Kept well below the
// nudge interval so a due nudge fires promptly without busy-looping.
questionNudgeSweepInterval = 30 * time.Second

// interactiveInboundBufferCap bounds the per-session queue of reviewer
// messages buffered while an interactive flow step has no question
// pending (see BufferInbound). Drop-oldest once the cap is hit so a
Expand Down Expand Up @@ -76,6 +90,13 @@ type QuestionRouter struct {
type pendingQuestion struct {
requestID string
prompts []question.Prompt
// askedAt is when the question was surfaced; lastNudge is when the
// nudger last re-posted a "still waiting" reminder (zero until the
// first one). nudges counts how many reminders have been sent so the
// per-question cap is enforced. Guarded by QuestionRouter.mu.
askedAt time.Time
lastNudge time.Time
nudges int
}

// NewQuestionRouter constructs a router and starts the subscriber
Expand All @@ -89,6 +110,7 @@ func (s *Service) newQuestionRouter() *QuestionRouter {
if s.app != nil && s.app.Questions != nil {
s.launchSupervised("question-router", r.run)
s.launchSupervised("question-stale-cache-sweeper", r.runSweeper)
s.launchSupervised("question-nudger", r.runNudger)
}
return r
}
Expand Down Expand Up @@ -208,6 +230,7 @@ func (r *QuestionRouter) handleNewRequest(ctx context.Context, req question.Requ
pend := &pendingQuestion{
requestID: req.ID,
prompts: req.Questions,
askedAt: time.Now(),
}
r.mu.Lock()
r.pending[req.SessionID] = pend
Expand Down Expand Up @@ -375,9 +398,47 @@ func (r *QuestionRouter) TryHandleQuestionReply(ctx context.Context, sessionID s
return false
}
r.rememberAnswers(sessionID, answers)
r.maybeAckAnswer(ctx, in, answers)
return true
}

// maybeAckAnswer sends a short confirmation for a typed/custom answer that
// got NO transport-side feedback. A button click already self-renders a
// "✓ Answered" widget (InboundSourceButton, or an unstamped inbound from an
// older orchestrator — treated conservatively as already-acked), so it is
// skipped; a free-text @mention / DM / modal answer otherwise leaves the
// reviewer with no sign the agent received it. Sent to the answering peer
// via the pod's own outbound adapter, so it works in both daemon and
// orchestrator-mediated deployments. Best-effort — a delivery failure never
// blocks the (already-committed) Reply.
func (r *QuestionRouter) maybeAckAnswer(ctx context.Context, in bridge.Inbound, answers [][]string) {
if in.AnswerWasAcknowledgedByTransport() {
return
}
if ack := formatAnswerAck(answers); ack != "" {
r.svc.replyToPeer(ctx, in.Peer, ack)
}
}

// formatAnswerAck renders the short confirmation sent back to a reviewer
// who typed/mentioned a custom answer. Lists the recorded answer(s) so it's
// unambiguous what the agent captured; returns "" when there's nothing to
// echo (defensive — the caller then skips the send).
func formatAnswerAck(answers [][]string) string {
var picks []string
for _, row := range answers {
for _, a := range row {
if a = strings.TrimSpace(a); a != "" {
picks = append(picks, a)
}
}
}
if len(picks) == 0 {
return ""
}
return "👍 Got it — recorded your answer: " + strings.Join(picks, ", ") + ". Working on it…"
}

// BufferInbound queues a reviewer message that arrived for an
// interactive flow session while no question was pending. The next
// question the flow agent asks (handleNewRequest) is auto-answered from
Expand Down Expand Up @@ -431,6 +492,108 @@ func (r *QuestionRouter) ClearSession(sessionID string) {
delete(r.buffered, sessionID)
}

// runNudger periodically re-surfaces questions that have gone unanswered
// past the nudge interval, re-posting a short "still waiting" status to the
// session's bound peers. This turns a silently-lost answer (e.g. a bridge
// reply misrouted to another Socket Mode consumer, GENAI-151) into a
// visible, recoverable prompt instead of an interactive step that hangs to
// the job's hard deadline. Stops when the service context cancels.
func (r *QuestionRouter) runNudger(ctx context.Context) {
ticker := time.NewTicker(questionNudgeSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
r.nudgeDue(ctx, now)
}
}
}

// nudgeConfig resolves the effective nudge settings from cfg, applying
// defaults for zero values. A negative interval disables nudging; a
// negative max means unlimited (bounded in practice by the job deadline).
func (r *QuestionRouter) nudgeConfig() (interval time.Duration, max int, enabled bool) {
interval = questionNudgeDefaultInterval
max = questionNudgeDefaultMax
if r.svc != nil && r.svc.cfg != nil {
switch s := r.svc.cfg.QuestionNudgeIntervalSeconds; {
case s < 0:
return 0, 0, false
case s > 0:
interval = time.Duration(s) * time.Second
}
if m := r.svc.cfg.QuestionNudgeMax; m != 0 {
max = m
}
}
return interval, max, true
}

// nudgeDue sends a reminder for every pending question idle past the
// interval and under the per-question cap. The pending map is scanned under
// the lock to collect the due-list — updating each entry's counters in the
// SAME critical section so a concurrent reply/sweep can't double-send — then
// the chat I/O happens after the lock is released.
func (r *QuestionRouter) nudgeDue(ctx context.Context, now time.Time) {
interval, max, enabled := r.nudgeConfig()
if !enabled {
return
}
type dueNudge struct {
sessionID string
prompts []question.Prompt
}
var todo []dueNudge
r.mu.Lock()
for sid, p := range r.pending {
// Reference clock: the last nudge, or the ask time if none sent yet.
last := p.lastNudge
if last.IsZero() {
last = p.askedAt
}
if now.Sub(last) < interval {
continue
}
if max >= 0 && p.nudges >= max {
continue
}
p.nudges++
p.lastNudge = now
todo = append(todo, dueNudge{sessionID: sid, prompts: p.prompts})
}
r.mu.Unlock()

for _, d := range todo {
text := formatNudge(d.prompts)
if text == "" {
continue
}
if _, err := r.svc.SendBySessionID(ctx, d.sessionID, bridge.Outbound{Text: text}); err != nil {
logging.Warn("bridge: question nudge send failed",
"session", d.sessionID, "err", err)
continue
}
logging.Info("bridge: question nudge sent", "session", d.sessionID)
}
}

// formatNudge renders the short "still waiting" status. Includes the first
// prompt's question text so the reviewer knows exactly what's outstanding;
// returns "" when there's nothing to echo (caller skips the send).
func formatNudge(prompts []question.Prompt) string {
if len(prompts) == 0 {
return ""
}
q := strings.TrimSpace(prompts[0].Question)
if q == "" {
return ""
}
return "⏳ Still waiting on your answer to continue:\n> " + q +
"\nReply with a button/number above, or @mention me with your answer."
}

// renderQuestionPrompt formats one or more question.Prompt entries as
// numbered-option chat text. Format mirrors the TS bridge's fallback
// rendering so the user experience is unchanged.
Expand Down
Loading
Loading