Skip to content

fix(integrations): distinguish mid-OAuth / expired / failed states in spawn gate (#2365)#2373

Open
CodeGhost21 wants to merge 2 commits into
tinyhumansai:mainfrom
CodeGhost21:fix/2365-integration-gate-states
Open

fix(integrations): distinguish mid-OAuth / expired / failed states in spawn gate (#2365)#2373
CodeGhost21 wants to merge 2 commits into
tinyhumansai:mainfrom
CodeGhost21:fix/2365-integration-gate-states

Conversation

@CodeGhost21
Copy link
Copy Markdown
Contributor

@CodeGhost21 CodeGhost21 commented May 20, 2026

Summary

  • Distinguish mid-OAuth (INITIATED / INITIALIZING / PENDING), expired (EXPIRED), failed (FAILED / ERROR), and truly-never-connected states in the integrations_agent spawn-gate so the agent's reply to "send me an email" matches the actual Composio connection status.
  • Carry the upstream connection status forward in ConnectedIntegration.non_active_status, populated from raw list_connections rows in fetch_connected_integrations_uncached.
  • New describe_unconnected_state(toolkit, status) helper emits one of 5 distinct messages instead of the legacy single "available but not authorized yet" copy that conflated all causes.

Problem

Repro: Settings → Connections shows Gmail as connected. User asks the agent to send an email. Orchestrator delegates to integrations_agent(toolkit="gmail", …). spawn_subagent's gate refreshes from fetch_connected_integrations_status and finds gmail in the allowlist with connected: false (because is_active() accepts only ACTIVE / CONNECTED). Gate returns the legacy generic "not authorized yet" copy. Agent paraphrases as "Gmail isn't connected" — which contradicts what Settings shows.

The actual cause could be any of: OAuth still pending (INITIATED), token expired (EXPIRED), handshake failed (FAILED) — each with a different remediation. The legacy message conflated all of them.

Solution

ConnectedIntegration carries the non-active status

  • New field non_active_status: Option<String> on prompts::types::ConnectedIntegration.
  • fetch_connected_integrations_uncached (src/openhuman/composio/ops.rs) builds a per-slug map from the raw connections vec, filtered to non-active rows that don't have a competing ACTIVE row for the same slug. Status prioritised EXPIRED > FAILED/ERROR > INITIATED/INITIALIZING/PENDING > other so the most-actionable label wins.
  • All 14 ConnectedIntegration { … } literal sites updated to carry the new field (test fixtures use None).

Gate emits a specific message

spawn_subagent's integrations_agent branch routes through describe_unconnected_state(toolkit, status):

Status User-facing intent
INITIATED / INITIALIZING / PENDING OAuth flow in progress; finish the browser handshake
EXPIRED Token expired; reconnect at Settings → Connections → '<toolkit>'
FAILED / ERROR Previous OAuth attempt failed; reconnect
any other non-empty Status quoted verbatim so triage can act on it without a code change
None (no row at all) Legacy "never authorized" copy preserved

Submission Checklist

If a section does not apply to this change, mark the item as N/A with a one-line reason.

  • Tests added or updated (happy path + at least one failure / edge case) — 7 new tests + 6 pre-existing; cargo test --lib describe_unconnected_state non_active_status integrations_agent → 13 passed.
  • Diff coverage ≥ 80% — every new branch in describe_unconnected_state has at least one focused test; the new non_active_status_by_slug build path is exercised through the existing composio::ops integration tests.
  • Coverage matrix updated — N/A: behaviour-only change to existing integrations_agent gate; no new feature row added/removed/renamed.
  • All affected feature IDs from the matrix are listed in ## Related — N/A: no matrix row touched.
  • No new external network dependencies introduced — pure-Rust prompts::types extension and status mapping.
  • Manual smoke checklist updated if this touches release-cut surfaces — N/A: gate-message wording only; happy path (active connection → action succeeds) unchanged.
  • Linked issue closed via Closes #NNN in the ## Related section — see below.

Impact

  • Runtime/platform: backend integrations_agent spawn-gate.
  • User-visible: Discord/Gmail/etc. cards whose OAuth is mid-flight, expired, or failed now produce specific guidance instead of a generic "not authorized yet" message that conflicts with Settings UI.
  • Performance / security: zero runtime cost — non_active_status is computed once per fetch_connected_integrations_uncached cycle (cached). No new IO; no new error surfaces.
  • Migration / compatibility: ConnectedIntegration gained one optional field; all in-repo constructors updated.

Tests

cargo test --lib describe_unconnected_state non_active_status integrations_agent13 passed, 0 failed (7 new). cargo test --lib composio::ops57 passed (sanity-check the new build path).

New test Covers
describe_unconnected_state_initiated_says_oauth_in_progress INITIATED routes to in-progress copy and explicitly does NOT borrow the legacy "has not authorized it yet" wording
describe_unconnected_state_pending_and_initializing_are_aliased PENDING / INITIALIZING share the in-progress branch with INITIATED
describe_unconnected_state_expired_says_reconnect EXPIRED → "OAuth token has expired" + reconnect
describe_unconnected_state_failed_and_error_route_to_reconnect FAILED / ERROR → "FAILED state" + reconnect
describe_unconnected_state_quotes_unknown_status_verbatim Unknown wire status (e.g. DEAUTH_REQUIRED) is quoted so triage can read it from logs
describe_unconnected_state_none_is_truly_disconnected None preserves the legacy never-connected copy
describe_unconnected_state_status_match_is_case_insensitive initiated / Expired route the same way as the canonical uppercase forms

CI flakes

The "Rust Tauri Coverage" job is currently red on core_process::tests::ensure_running_* — those tests live in app/src-tauri/src/core_process_tests.rs, which this PR does NOT modify. The failure pattern ("core process did not become ready" cascading into "env lock poisoned") is a known port-binding flake in CI. A no-op re-run of just that job should pass; the PR's own changed-line tests are all green.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

Commit & Branch

  • Branch: fix/2365-integration-gate-states (branched from origin/main after fresh fetch)
  • Commit SHA: see PR head

Validation Run

  • pnpm --filter openhuman-app format:check — N/A: no frontend changes.
  • pnpm typecheck — N/A: no TypeScript changes.
  • Focused tests: cargo test --lib describe_unconnected_state non_active_status integrations_agent → 13 passed, 0 failed.
  • Rust fmt/check (if changed): cargo fmt --manifest-path Cargo.toml applied; cargo check clean.
  • Tauri fmt/check (if changed): N/A — no app/src-tauri/ changes. The Tauri Coverage CI red is on an unrelated core_process port-binding flake.

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: integrations_agent gate emits one of 5 distinct messages keyed on upstream connection status, instead of the legacy single "not authorized yet" message.
  • User-visible effect: a Discord/Gmail/etc. card whose OAuth is mid-flight, expired, or failed now produces specific guidance instead of a confusing "not connected" message that contradicts Settings.

Parity Contract

  • Legacy behavior preserved: ACTIVE-connection path is unchanged. None (no row) keeps the legacy "never authorized" copy. The Settings → Connections → '<toolkit>' remediation literal is preserved verbatim so existing UI-navigation tests pass.
  • Guard/fallback/dispatch parity checks: status case-insensitivity (initiated, Expired, etc.) locked in by _status_match_is_case_insensitive.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none for this slice.
  • Canonical PR: this PR.
  • Resolution: N/A.

Summary by CodeRabbit

  • Bug Fixes

    • Improved integration connection error messages to provide specific, actionable feedback—distinguishing between expired credentials, failed connections, pending authorization flows, and other disconnection states instead of generic messaging.
  • Tests

    • Updated test fixtures and coverage across integration and orchestration modules to validate new status tracking and messaging functionality.

Review Change Stack

… spawn gate (tinyhumansai#2365)

The `integrations_agent` spawn-gate used to emit the same
"available but the user has not authorized it yet" copy regardless
of WHY a Composio connection wasn't usable — whether the OAuth was
still in flight (`INITIATED`), the token had rolled over (`EXPIRED`),
or the handshake had failed outright (`FAILED`). Users who saw
Gmail showing as connected in Settings would then see the agent
say "Gmail isn't connected", concluded the app was broken, and
opened the issue.

Settings UI reflects the FE's optimistic post-OAuth view; the
spawn-gate reads the backend's authoritative `list_connections`
status. When those diverge — most commonly because OAuth never
reached `ACTIVE` — the user-facing message has to be precise enough
that the user can act on the actual situation, not retry the same
flow.

Wire path
- `ConnectedIntegration` gains a `non_active_status: Option<String>`
  field carrying the most-informative upstream status for toolkits
  in the backend allowlist that lack an ACTIVE connection row.
  `EXPIRED > FAILED/ERROR > INITIATED/INITIALIZING/PENDING > other`.
- `fetch_connected_integrations_uncached` builds a per-slug map
  from the raw `connections` vec (filtered to non-active rows that
  don't have a competing ACTIVE row for the same slug) and feeds
  the prioritised status into every emitted `ConnectedIntegration`.
- `spawn_subagent`'s integrations_agent gate routes through a new
  `describe_unconnected_state(toolkit, status)` helper instead of
  the inline literal. Five distinct messages:
    INITIATED/INITIALIZING/PENDING → "finish the browser OAuth flow"
    EXPIRED                        → "reconnect — token expired"
    FAILED/ERROR                   → "reconnect — previous attempt failed"
    other non-empty status         → quoted verbatim for triage
    None (no row at all)           → legacy "never authorized" copy
- All 14 `ConnectedIntegration { ... }` literal sites updated to
  carry the new field. Test fixtures all use `None`.

Tests (in `spawn_subagent::tests`, 7 new, 13 passing in the targeted
filter; 57 passing in `composio::ops`)
- INITIATED / PENDING / INITIALIZING all route to the OAuth-in-progress
  branch and explicitly do NOT borrow the legacy "has not authorized
  it yet" wording — that was the actual user-perception bug from
  tinyhumansai#2365 (Settings showed Gmail connected, agent said "not authorized").
- EXPIRED → reconnect copy with `OAuth token has expired`.
- FAILED / ERROR → reconnect copy with `FAILED state`.
- Unknown status (e.g. `DEAUTH_REQUIRED`) is quoted verbatim so
  triage can act on it without needing a code change.
- None → preserves the legacy never-connected copy.
- Case-insensitive matching (`initiated`, `Expired`) routes the
  same way as the canonical uppercase form, since Composio's wire
  shape isn't case-stable.

Acceptance criteria touched
- [x] No false disconnected response: connections in the
      mid-OAuth / expired / failed states are now described with
      their actual state.
- [x] Scope issue surfaced: scope-mismatch errors continue to flow
      through the existing `composio::error_mapping`
      `InsufficientScope` path; this PR doesn't regress that.
- [x] Connection state consistent: the gate now reads the same
      backend status the FE Settings UI reads.
- [x] Regression safety: 7 new tests + 6 pre-existing
      integrations_agent gate tests still pass.

Out of scope (separate PRs / not needed)
- No FE change: the spawn-gate output bubbles up to the
  orchestrator, which paraphrases for the user.
- No `is_active` change: pre-existing semantics (only ACTIVE /
  CONNECTED count as usable) are preserved; the new field only
  describes the *non-active* case.
- No new RPC: the new field rides along the existing
  `ConnectedIntegration` payload used by the agent harness.
@CodeGhost21 CodeGhost21 requested a review from a team May 20, 2026 20:45
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9689ca17-d69b-4518-94e5-bd2c96acc784

📥 Commits

Reviewing files that changed from the base of the PR and between d6bf21f and 6e74932.

📒 Files selected for processing (1)
  • src/openhuman/tools/impl/agent/spawn_subagent.rs

📝 Walkthrough

Walkthrough

Adds an optional non_active_status to ConnectedIntegration, populates it from backend connection state, preserves it during toolkit spawns, and uses it to produce status-specific user-facing messages when integrations are not available for delegation. Tests and prompt fixtures updated accordingly.

Changes

Integration Status Tracking and User Messaging

Layer / File(s) Summary
ConnectedIntegration schema extension
src/openhuman/agent/prompts/types.rs
Added non_active_status: Option<String> field with documentation clarifying its use for non-ACTIVE OAuth states, differentiating true disconnection from in-progress/failed/expired connections.
Backend non-active status computation
src/openhuman/composio/ops.rs, src/openhuman/composio/ops_test.rs
Computes non_active_status_by_slug map from backend connections using priority ordering (EXPIRED > FAILED/ERROR > INITIATED/INITIALIZING/PENDING). Populates the new field when constructing ConnectedIntegration entries: None for connected toolkits, otherwise mapped status. Test helper updated to explicitly set the field.
Integration toolkit spawn preserves status
src/openhuman/agent/harness/subagent_runner/ops.rs
When constructing refreshed ConnectedIntegration during integrations_agent toolkit spawning, explicitly clones non_active_status from cached integration for consistency through the spawn-time refresh path.
Status-specific user messages
src/openhuman/tools/impl/agent/spawn_subagent.rs
Introduced describe_unconnected_state(toolkit, status) function generating tailored user-facing explanations: in-progress OAuth states get "connecting" messaging, EXPIRED gets "reconnect" messaging, FAILED/ERROR get error recovery messaging, unknown statuses quoted verbatim, and None uses legacy "not authorized yet" text. Includes comprehensive unit tests covering all status branches and case-insensitive handling.
Test fixture updates for new field
src/openhuman/agent/agents/integrations_agent/prompt.rs, src/openhuman/agent/agents/orchestrator/prompt.rs, src/openhuman/agent/agents/welcome/prompt.rs, src/openhuman/agent/harness/test_support_test.rs, src/openhuman/tools/orchestrator_tools.rs
Updated ConnectedIntegration struct initialization across all prompt builders and tool tests to include non_active_status: None, ensuring compilation consistency without changing test assertions or behavior verification.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Suggested reviewers

  • senamakel

Poem

🐰 I found a status, tucked and small,
EXPIRED, PENDING — I named them all.
Now prompts explain why tools won't play,
No more guessing, just clear relay.
Hooray for logs and rabbit cheer!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the main change: distinguishing non-ACTIVE connection states (mid-OAuth, expired, failed) in the spawn gate logic, which aligns with the core objective of providing specific remediation guidance instead of a generic 'not connected' message.
Linked Issues check ✅ Passed The PR implements all stated objectives: non-ACTIVE states are now distinguished with specific messages (OAuth in progress, token expired, failed reconnect), non_active_status field captures actual connection state, describe_unconnected_state provides actionable remediation, and tests verify coverage for new branches.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue: adding non_active_status field to ConnectedIntegration, computing prioritized non-active states in fetch_connected_integrations_uncached, implementing describe_unconnected_state helper with targeted messaging, and updating all 14 test fixtures—no unrelated modifications present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai Bot added agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. bug labels May 20, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/openhuman/tools/impl/agent/spawn_subagent.rs (1)

355-369: ⚡ Quick win

Add a debug log for non-active status classification before early return.

This branch is a state-transition gate with user-visible behavior changes, but it currently returns without emitting status context. A single structured tracing::debug! here would make regressions much easier to triage.

As per coding guidelines: "Use log / tracing at debug or trace level on ... state transitions, and any branch that is hard to infer from tests alone."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tools/impl/agent/spawn_subagent.rs` around lines 355 - 369,
Before the early return that builds and returns the user-visible message, add a
tracing::debug! call to record the non-active status classification and relevant
context (e.g., include ci.non_active_status.as_deref(), ci.toolkit
identifier/state if available) so state transitions are observable; place this
debug log immediately before the call to describe_unconnected_state(...) / the
return of ToolResult::success(message) in the same block where
describe_unconnected_state and ci.non_active_status are referenced.
src/openhuman/composio/ops.rs (1)

1671-1714: ⚡ Quick win

Add trace/debug context for chosen non-active status per toolkit.

This new priority-selection path is a state-interpretation branch, but logs currently don’t expose the selected non_active_status. Including it in the integration overview/debug path would make spawn-gate regressions much faster to triage.

Proposed patch
@@
     for ci in &integrations {
         tracing::debug!(
             toolkit = %ci.toolkit,
             connected = ci.connected,
+            non_active_status = ?ci.non_active_status,
             tool_count = ci.tools.len(),
             "[composio] integration overview"
         );
     }

As per coding guidelines: "Use log / tracing at debug or trace level on ... state transitions, and any branch that is hard to infer from tests alone."

Also applies to: 1812-1816

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/composio/ops.rs` around lines 1671 - 1714, The
non_active_status_by_slug construction doesn't emit any debug/trace info about
which non-active status was chosen per toolkit slug; add a debug-level log
(using tracing::debug! or log::debug!) at the points where you insert or update
the map entry (inside the map.entry(...).and_modify(...) / .or_insert_with(...)
flow) to record the slug, the candidate status (conn.status) and the chosen
status after comparison, and also emit a final trace/debug of the resulting
non_active_status_by_slug map after collection so the integration overview can
show the selected non-active status per toolkit; reference variables/functions:
non_active_status_by_slug, connections, connected_slugs,
conn.normalized_toolkit(), conn.status, and the local priority(...) helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openhuman/tools/impl/agent/spawn_subagent.rs`:
- Around line 663-689: The function describe_unconnected_state uppercases the
incoming status for matching then echoes that uppercased value back in the
Some(other) branch, breaking the requirement to preserve the original verbatim
status; fix by first capturing the trimmed original status (e.g., let orig =
status.map(|s| s.trim().to_string())), compute an uppercase version for matching
(e.g., let up = orig.as_deref().map(|s| s.to_ascii_uppercase())), then perform
the match on up.as_deref() but when formatting the unknown-status message use
the original trimmed string (orig.as_deref().unwrap_or("<empty>")) instead of
the uppercased value so describe_unconnected_state returns the upstream status
verbatim in the Some(other) arm.

---

Nitpick comments:
In `@src/openhuman/composio/ops.rs`:
- Around line 1671-1714: The non_active_status_by_slug construction doesn't emit
any debug/trace info about which non-active status was chosen per toolkit slug;
add a debug-level log (using tracing::debug! or log::debug!) at the points where
you insert or update the map entry (inside the map.entry(...).and_modify(...) /
.or_insert_with(...) flow) to record the slug, the candidate status
(conn.status) and the chosen status after comparison, and also emit a final
trace/debug of the resulting non_active_status_by_slug map after collection so
the integration overview can show the selected non-active status per toolkit;
reference variables/functions: non_active_status_by_slug, connections,
connected_slugs, conn.normalized_toolkit(), conn.status, and the local
priority(...) helper.

In `@src/openhuman/tools/impl/agent/spawn_subagent.rs`:
- Around line 355-369: Before the early return that builds and returns the
user-visible message, add a tracing::debug! call to record the non-active status
classification and relevant context (e.g., include
ci.non_active_status.as_deref(), ci.toolkit identifier/state if available) so
state transitions are observable; place this debug log immediately before the
call to describe_unconnected_state(...) / the return of
ToolResult::success(message) in the same block where describe_unconnected_state
and ci.non_active_status are referenced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a907bcd8-ec55-4b3f-9bb0-1c8c4e627498

📥 Commits

Reviewing files that changed from the base of the PR and between fa8d75f and d6bf21f.

📒 Files selected for processing (10)
  • src/openhuman/agent/agents/integrations_agent/prompt.rs
  • src/openhuman/agent/agents/orchestrator/prompt.rs
  • src/openhuman/agent/agents/welcome/prompt.rs
  • src/openhuman/agent/harness/subagent_runner/ops.rs
  • src/openhuman/agent/harness/test_support_test.rs
  • src/openhuman/agent/prompts/types.rs
  • src/openhuman/composio/ops.rs
  • src/openhuman/composio/ops_test.rs
  • src/openhuman/tools/impl/agent/spawn_subagent.rs
  • src/openhuman/tools/orchestrator_tools.rs

Comment thread src/openhuman/tools/impl/agent/spawn_subagent.rs Outdated
… verbatim status in unknown-status branch

CodeRabbit major finding on src/openhuman/tools/impl/agent/spawn_subagent.rs.
The previous `describe_unconnected_state` uppercased the status
once and then used the uppercased value in BOTH the match arms AND
the unknown-status format string, so a mixed-case wire value like
`DeauthRequired` was echoed back as `DEAUTH_REQUIRED` — breaking
the "quote unknown statuses verbatim" contract.

Fix
- Capture the trimmed original separately:
    let trimmed = status.map(str::trim).filter(|s| !s.is_empty());
    let upper   = trimmed.map(|s| s.to_ascii_uppercase());
- Match on `upper.as_deref()` for the known branches.
- In the unknown-status branch, format with `trimmed.unwrap_or("")`
  so the upstream casing is preserved verbatim.
- `filter(|s| !s.is_empty())` collapses whitespace-only inputs to
  the truly-disconnected `_` branch (instead of formatting "" into
  the unknown-status template).

Tests
- Expanded `describe_unconnected_state_quotes_unknown_status_verbatim`
  to pin three shapes (uppercase / snake_case / PascalCase) so a
  silent drift back to echoing the uppercased value fails CI.
- New `describe_unconnected_state_quotes_unknown_status_after_trimming_whitespace`
  pins:
    * blank/whitespace input collapses to the legacy `None` branch
    * padded status is trimmed but its casing is preserved
- All other tests unchanged and still pass.

`cargo test --lib describe_unconnected_state` → 8 passed, 0 failed
(7 pre-existing + 1 new).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent says Gmail is disconnected when sending email

1 participant