Skip to content

Let an admin fix an app card in one press, and carry the task on once the app is ready - #844

Closed
guidovizoso wants to merge 9 commits into
mainfrom
guido/connect-card-resume
Closed

guidovizoso wants to merge 9 commits into
mainfrom
guido/connect-card-resume

Conversation

@guidovizoso

Copy link
Copy Markdown
Collaborator

What this changes

When a Bot needs an app it can't use, it stops and draws an app card. Two things made that a dead end.

An admin was asked to request access from themselves. An admin looking at a card for an app that is off, or that the Bot wasn't given, got the same "Ask an admin" a member gets. Now they get the fix itself, one primary button: "Turn on and give it to Sales" or "Give it to Sales". It calls a new admin-only route, POST /api/plugins/for/:agentId/apps/:app/approve, which files the request the app's state needs, as the admin, and approves it straight away through the same code the inbox's Approve runs. The approve body moved into a shared carryOut, and the filing checks into a shared requestable, so one press can't grant more or check less than the two presses. No refs are sent, so only the app's read-only actions are granted, as the inbox does; send and write actions are still chosen on the Bot's page. Where one press can't do it (no broker key, a set-up left unfinished, a team account nobody connected) the admin gets a link to the app's admin page in a new tab, with a sentence that says what is missing and ends "then try again". The card reads the app again when its tab has focus back.

Once the app was ready, the person had to ask again. The card said "Ask again and the Bot can use it". Now a card that sees its app go from blocked to ready, because the person finished connecting in the other tab or an admin turned it on, sends one message on the person's behalf, "Gmail is ready now. Please carry on.", and the Bot picks the task back up with the whole conversation behind it.

  • A message, not a resumed tool call. The handoff resume answers a browser tool call that is still open. An app card is drawn from a call that has already answered, in a turn that has ended, so the next turn is what carries the task on. A visible message also says why the Bot started again.
  • Only while the card is the latest thing: the person has said nothing since it, nothing is queued, and the Bot isn't working. A routine firing after the card counts as the conversation moving on (latestFrom).
  • Only once per card, across re-renders, remounts and tabs, by a mark in localStorage keyed by the tool call id and set before sending.
  • Only automatically where the card saw the change. A card drawn already ready (an admin approved while the conversation was closed) offers a Continue button instead. For show_app_access it offers one only if the card's marker says the app was blocked when the Bot asked: the tool now writes blocked: true | false from the state it found. Older answers without the field offer nothing.

Where it runs

  • New state that outlives a request? On the server, nothing new: requests, grants and trail rows are the existing Postgres tables. In the browser, the "already carried on" mark lives in localStorage (with an in-memory copy for a browser that refuses storage). That mark is per browser, on purpose: it only stops a second send from the same person's other tabs.
  • What happens on the second replica? The one press is a single request handled by one process, reading and writing Postgres; a second press on another process sees the first's rows. Two different browsers of the same person, both open on the conversation when the app turns ready, could each send the carry-on message once; the mark does not cross browsers.
  • Anything serialised? Filing is an insert that does nothing on conflict with the person's pending request for the same Bot, app and kind. Approving is the inbox's existing claim, a conditional update from pending, so two admins pressing at once cannot both carry it out: the second is answered 409 "That request has already been answered." Putting a request back is a conditional update on the claim the same admin made.
  • Anything fanned out to a browser? No. The card refetches the app's state on focus.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway. This changes who can grant an app and how a turn starts, not how a Bot's calls are made; the Bot's calls with the newly granted actions go through the gateway as any other.
  • New refusals and new failures each write a row. The one press writes app_access.requested, then app_access.approved marked direct, as the two presses would. Its refusals write app_access.refused. A press that can't be carried out withdraws the request it filed and writes app_access.cancelled marked direct, so no app_access.requested is left unanswered; an admin's own request that was already waiting is put back as it was.
  • Nothing new is trusted from the client that the server can resolve itself. Admin is checked on the server; the request kind is worked out from the app's state, not sent; the route takes no refs. The blocked flag is written by the server into the tool's answer, and only decides whether the browser offers a Continue button.

Changelog

  • "An app card carries the task on once the app is ready" under Unreleased.

How it was checked

  • New tests:
    • server/tests/app-access-routes.integration.test.ts: one press turns an app on and gives it read-only, with the trail two presses leave; gives an app that is already on and grants no send; only an admin may press it, and nothing is filed for anybody else; a state with nothing for an admin to do is refused like a request; a press that can't be carried out leaves no request waiting; one of the admin's own already waiting is put back.
    • app/tests/app-access-card.test.tsx and connect-app-card.test.tsx: a card that sees the app become ready carries on once; a card the conversation has moved on from only says it is ready; a card drawn already ready offers Continue, which carries on once; no conversation, no carrying on.
    • app/tests/transcript-latest.test.ts: what counts as the latest thing, including a file with no caption and a routine firing.
    • app/tests/app-access-action.test.tsx, app-access-mutations.test.ts, shared/connect-prompt.test.ts, server/tests/app-access-tool.test.ts: the admin's button and its refusal sentence, the route's path with no refs, and the blocked marker.
  • In a running app on a combined branch of this and five sibling PRs (local stack, 2026-10-26): with no search app, the Bot drew a Parallel Search card. As an admin, "Turn on and give it to General Assistant" turned the card ready, "Parallel Search is ready now. Please carry on." was sent, and the Bot searched the web and cited its sources.
  • The full server suite on the combined branch: 5,821 pass. The only failures were the assertion-shape guard, since fixed here (the approve call is now held to an absent body, so the assertion can fail), and one Mastra test that needs a package not installed locally. The app suite: 1,675 pass.

Adds POST /api/plugins/for/:agentId/apps/:app/approve, admin only. It
files the request the app's state needs, as the admin, and approves it
straight away through the same code the inbox's Approve runs: the
approve body moved into a shared carryOut, and the filing checks into a
shared requestable, so the one press can't grant more or check less
than the two presses would.

The trail reads as the two presses: app_access.requested, then
app_access.approved marked direct. No refs, so only read-only actions
are granted, as the inbox does. A press that can't be carried out
withdraws the request it filed; one of the admin's own that was already
waiting is put back as it was.
… asked

A refusal is blocked by definition, but show_app_access also answers for
an app that is ready, and its card can't tell later whether the app
became ready since or was ready all along. The tool now writes
blocked: true or false from the state it found, so a card drawn already
ready can offer to carry on only where something actually changed.
Older answers without the field offer nothing.
An admin looking at an app that is off, or that a Bot wasn't given, now
gets one primary button: "Turn on and give it to <Bot>" or "Give it to
<Bot>". It calls the new one-press approve route, so the app is turned
on and its read-only actions granted with the same checks and trail as
the inbox. Send and write actions are still chosen on the Bot's page.

Where one press can't do it (no broker key, a set-up left unfinished, a
team account nobody connected) the admin gets a link to the app's admin
page in a new tab instead. The card reads the app again when its tab has
the focus back, so it follows whatever was done over there.
When the person finishes connecting in the other tab, or an admin turns
the app on and gives it to the Bot, the card used to say "Ask again and
the Bot can use it". Now the card that sees its app go from blocked to
ready sends one message on the person's behalf, "Gmail is ready now.
Please carry on.", through the same ask any transcript card uses.

Why a message and not handoff-resume: that resumes a browser tool call
that is still open. An app card is drawn from a call that already
answered, and its turn has ended, so there is nothing open to answer.
The next turn is what carries the task on. A visible message says why
the Bot started again, and the Bot gets the whole conversation with it.

Guards:
- Only while the card is the latest thing: the person has said nothing
  since it, has nothing queued, and the Bot isn't working. The
  transcript passes this down with the tool call id.
- Only once per card, across re-renders, remounts and tabs, by a mark in
  localStorage keyed by the tool call id, set before sending.
- Only automatically where the card saw the change. A card drawn already
  ready (an admin approved while the conversation was closed) offers a
  Continue button. For show_app_access it does so only if the marker
  says the app was blocked when the Bot asked.
The card that carries a task on once its app is ready only did so while it
was the latest thing, but a routine firing after it did not count, so a
later connect sent "please carry on" onto the routine's work. Any user turn
now counts. The rule is pulled out as latestFrom and tested.
A one press that could not be carried out withdrew the request it had just
filed without a trail row, leaving an app_access.requested nothing answered.
It now writes app_access.cancelled, marked direct, as a withdrawal does.
The one press withdraws the request it filed when it cannot be carried out,
yet its refusals ended "then approve this request again". They now end
"then try again" there; the inbox's wording is unchanged. The browser's
fallback sentence follows the copy rules too.
@guidovizoso

Copy link
Copy Markdown
Collaborator Author

Landed in #847.

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