Cap a reported message's time at the server's clock, so a browser that runs ahead cannot hide what a routine said - #341
Merged
Conversation
…t runs ahead cannot hide what a routine said `POST /:channelId/activity` took `at` from the browser and `recordActivity` applied it with a forward-only guard, returning silently when nothing moved. The parser's comment said a wrong clock could lose a report, not corrupt the row. That is the clock that is behind. One that is ahead lands its report, stamps the row into the future, and then every correct report loses until real time catches up: the routine runner's, which is the one report a headless firing makes; a relayed handoff answer's; every other member's browser. The reply was in the thread and the roster never said so, and the run row read `succeeded`. A reported time is now capped at the server's own clock — a browser may say when, but not later than now. Clamped rather than refused, because clocks are a little ahead all the time; a stamp in the past is kept, so a message and its reply reported by the same clock still land in that order. `now` is injectable, and the test that pins the clamp fails before this change.
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
September 2, 2026 22:10
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
guidovizoso
previously approved these changes
Sep 3, 2026
# Conflicts: # CHANGELOG.md
davidmckayv
approved these changes
Sep 3, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Validated locally + CI verify green; CHANGELOG-only rebase.
jerelvelarde
added a commit
to jerelvelarde/openbot
that referenced
this pull request
Sep 9, 2026
* Relay handoff answers home, show working channels, and open the app to the keyboard (#290)
* Gate first sign-in behind a per-user onboarding wizard
Two columns on users say where somebody is in first-run onboarding and
when they finished; /api/me carries the status and one POST moves it.
The app redirects an unfinished user to /onboarding — an animated
three-step wizard — and an address the build does not know now goes
home through the same gate instead of a bare 404.
* Say work is offered aloud, so a sweep can start now instead of at its next poll
The handoff queue was swept every two seconds, and a person is waiting through
every hop, so each leg of a handoff cost up to a full interval doing nothing.
Offering work now fires pg_notify with the kind as payload, inside the offering
transaction where there is one so it fires on commit and never before, and any
replica can listen on a dedicated connection and kick its sweep immediately.
A notification is a latency optimisation, never a delivery mechanism: one lost
in transit costs up to one poll interval, not the work, and the queue's tables
stay the only truth.
* Let a channel tell its members a turn is running in it
A transient busy flag on the channel activity event, announced and never
written: busy is a moment, not a fact about the channel, and a missed signal
costs at most a stuck-looking dot until the next real event, never data.
Two ways in. The server signals by thread (signalBusy) for the runs it can see
— the runtime's lock acquire and release now carry an onRunBusy seam, so every
run the platform processes lights its channel, including one whose tab has
navigated away — and a scratch thread maps to no channel and signals nowhere,
which is the point of a scratch thread. The browser signals by channel
(POST /:channelId/busy) for the one thing the server cannot see, a person's own
turn beginning, with a membership check so belonging to a channel is not
something an outsider can probe for.
* Relay a handoff answer back into the conversation that asked
A forward hop used to answer in the addressed Bot's own channel with the
person: two conversations for one question, and the answer landed somewhere
they never asked anything. Now the hop runs in a scratch thread of the
addressed Bot's own — minted per hop, never mapped to a channel, never shown —
and what it said comes home through a second queued hop that has the asking
Bot relay the answer, attributed, in the conversation the person is watching.
The delivery gathers the Bot's words from the stream as it goes past, because
the runner publishes the turn to the platform and the events are the one
chance to hear it. The relay rides the same durable queue as the turn that
produced it, so a pod dying between the two loses the relay to a retry rather
than for ever; the answerIn marker that stops a failure notice recursing stops
a relay relaying. Answers are clipped at 12k characters so a Bot that comes
back with a book cannot swamp the relaying run's prompt, and a backwards hop
reads only the tail of the asking conversation so relaying never grows slower
with the channel.
Wired in index.ts: the scratch thread replaces the direct-channel answerIn,
the roster announcement resolves thread to channel and happens only when the
turn said something, the asking channel is lit while a forward hop runs, and
the queue's new offered-work notification kicks the sweep so a person is not
waiting out a poll interval per leg.
* Show a channel working on the roster while a turn runs in it
Three bouncing dots badged on the channel's avatar, driven by the busy flag on
the activity socket. Socket-only and transient on the summary type: the roster
query never returns it, so it is undefined until a busy event arrives and drops
whenever the roster refetches — the acceptable failure for a hint about a
moment. The event patcher flips only the busy field: the spread that serves
ordinary activity would carry the event's null message onto the row and wipe
the preview, and busy is not activity, so the row does not re-sort either.
The channel reports its own turns over the new busy endpoint, fire-and-forget,
and deliberately does not clear on unmount: a turn keeps running server-side
after the person leaves the channel, and the server clears it when the run's
lock is released.
* Keep an open channel current with turns nobody here streamed
A relayed handoff answer runs on the server and lands in the thread with no
browser attached; the transcript restored history once, on mount, and would
show the new turn only after leaving and coming back. The chat now watches the
roster's own channel-list cache — the sidebar updating and the transcript
refreshing are one signal and cannot drift apart — and when this channel's
lastMessageAt advances to a moment a Bot authored, the durable history is read
again and messages whose ids the transcript has never seen are appended.
Appended by id, not compared by length, because the stored read keeps only what
the platform can parse and can be shorter than the screen while still holding
the news. Retried briefly, since the roster is patched when the turn is on
record with the runner and the platform's read can be a beat behind.
The chat also reports its own turns to the busy endpoint, keyed on whether a
turn is in flight, so the roster's working dots cover the one run the server
cannot see begin.
* Start a new chat from anywhere with Shift+N
A small hotkey registry, one place on purpose: the binding a listener matches
against and the combo the settings page shows are the same record, so the list
under Preferences is what the keys actually do rather than what somebody
remembered they did — each key drawn as its own keycap, symbols on a Mac and
names elsewhere. Matching is exact rather than at-least, so Shift+N does not
fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain
Shift+letter combo is left alone while the focus is anywhere editable.
Bound in _authed rather than _app, so a person on settings or admin can start
a chat without first clicking back into the app frame. The new-channel
composer's recipient picker takes focus on arrival, so Shift+N then typing a
name is one motion.
* Format the onboarding route
The wizard shipped unformatted; biome's own output, no hand edits.
* Read the asking conversation before taking the run's lock
The history read is the one call in a delivery that throws on a platform
error, and it sat after the lock was acquired but before the try whose finally
gives the lock back: a 500 from the platform leaked the lock for its full TTL.
On a forward hop that lock is a scratch thread and the leak costs nothing; on
a relay it is the asking conversation itself, so the person could not type for
two minutes, the retry a minute later collided with the hop's own leftover
hold and spent an attempt on it, and a relay that ran out of attempts vanished
without a notice.
The read is of the conversation that asked, which the lock — taken on the
conversation being answered in — never protected. Hoisted above the acquire it
fails before anything is held, and the happy path holds the lock for less of
the turn too. Raised by kevin9327 on #290, who had the same reordering up as
#305 against main; a test now pins the ordering.
* Refetch the roster as well as the user when onboarding completes
Finishing the wizard navigates straight into the app, and the channel list —
cached from before the wizard, or from a fetch that ran while it held the
screen — could greet the person with an empty sidebar their reload then fixed.
Seen once on a real deployment during review of #290 and not cleanly
reproduced, so this is a hardening of the seam rather than a confirmed fix:
both invalidations use refetchType "all" for the same reason, nothing is
observing these queries while the wizard is up.
* Say which wizard agents are examples, and unstick its two classes
Two review findings on the wizard's roster step. The invented agents that top
up a sparse deployment rendered identically to real ones, so "Support Agent"
read as a Bot the deployment has; they are dimmed and labelled Example now.
And max-w-lg had run together with overflow-hidden into a class that applied
neither, which is why the roster cards drew wider than every other step.
Also stops the onboarding store's comment promising that step is where the
wizard resumes: it is stored and served but nothing resumes from it yet, and
the comment now says so instead of describing a behaviour that does not
exist.
* Say in the changelog what changed, including reversing 0.0.5's relay stance
Five user-visible changes were on this branch with no entry: the relay, the
working indicator, the transcript catch-up, the onboarding wizard, and
Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on
the addressed Bot's behalf, and this branch makes it do exactly that — so
somebody upgrading off those notes got the opposite of what they were told.
The Unreleased section now names the reversal outright, and the 0.0.5
paragraph carries a pointer forward rather than standing uncorrected.
* Give the queue's wake-up listener back on the way out, like its siblings
`startWorkOfferedListener` holds a Postgres connection for the life of the process and returns a
`stop()` for giving it back, and the return value was dropped on the floor. The shutdown beside it
stops the two listeners either side and says why in its own comment: "so a watch-mode restart does
not leave two behind on every reload". This one was the third, and it was the one not stopped, so
every reload of `bun run dev` on a deployment with handing work between Bots switched on left a
connection behind.
Held at module scope because the listener is started inside the gate that may never run, and the
shutdown is at the top level: undefined there means a deployment with the capability off, which
never started one.
Checked rather than assumed: `pg_stat_activity` goes from one row to two when the listener starts
and back to one after `stop()`.
* Say in the README that this is a template to clone, not a product
The framing existed only in a code comment ("OpenBot exists to be forked"), which is the wrong place
for the first thing somebody needs to know: that there is nothing to sign up for, nothing to install
as a dependency, and that the example tenant package is a worked example to replace rather than a
default to keep. Read without it, "Quick start" looks like the way you use OpenBot rather than the
way you try the starting point before making it yours.
Placed above the alpha and laptop callouts because it frames both.
* Add a setup prompt for somebody doing this with an AI, and link it from the README
Reported as hard to set up even with an assistant helping, and the reason is legible in
`.env.example`: it ships ten empty keys, and only three of them are a person's to fill. The start
script defaults two and generates the third, so an assistant reading the file alone will walk
somebody through seven values that fill themselves.
`prompt.txt` says which three, what each start-up refusal means in the words the server actually
prints, and what an assistant must not do on somebody's behalf: run the browser sign-in, echo their
keys back, or change the Intelligence URLs, which are correct as shipped and not self-serve to
replace.
Every claim in it was checked against this repository rather than written from memory: the pinned
Bun version, the port defaults, the five refusal messages quoted verbatim, and that
TENANT_PACKAGE_DIR is relative to `server/` rather than the root, which is why the default carries
a `../` that is easy to drop.
---------
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* End an empty reply on a line the surface receives, not one only the graph sees (#292)
A reply with no text and no tool call ends the graph — the conditional edge
sees no calls and stops. #289 tried to give that run a visible line by
substituting a fallback AIMessage into the graph's state (withVisibleReply),
so the CHANGELOG already promises "an empty reply ends on a visible line
rather than in silence".
It never reached the person. This service streams AG-UI by reading the run's
framework events, and it emits text only from on_chat_model_stream (the model's
own deltas) and tool events from the tools node. A message a graph node
returns is never one of those, so a fallback placed in state is a fallback the
surface never sees: an empty reply still ended on a bare RUN_STARTED/
RUN_FINISHED pair with nothing between them.
Proof, driving the real reader over a real graph with a fake empty model:
the graph's final state held the fallback, and the client received only
["RUN_FINISHED"].
The guard belongs on the wire, where the surface reads. streamRun now tracks
whether anything a person can see reached it — a line of prose or a tool call —
and ends a run that produced neither on the same fallback line. "Visible" is
decided by textOfChunk, the one rule the streamed deltas already use, so a
reply that is only a Responses-API reasoning summary (text the person is never
shown) counts as empty here too — the case the state-based guard got wrong,
since hasVisibleText read any `text` field as visible.
The translation is extracted to stream.ts for the same reason history.ts and
deltas.ts are: index.ts calls serve() at module scope, so importing runAgent
to test it binds a port. stream.ts imports no runtime module beyond deltas, so
its tests need no provider key and no network.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Let a button drawn as a link answer the keyboard like a button (#303)
Six controls in the app are buttons that navigate: "New skill", "New
agent", the sidebar's new-channel control, the two empty-state returns,
and PageShell's back button, which is five routes in every state each of
them has. All six draw a router Link through `render`.
Base UI defaults `nativeButton` to true, so every one of them was told to
expect a native <button> and found an anchor, and said so at render. The
warning was the visible half. The rest was that Base UI put type="button"
on an anchor, where it means nothing, and withheld the role="button" and
Space-to-activate handling it applies to a non-button — so these read as
links to a screen reader and ignored the Space key.
Replacing the element is exactly the case where the default is wrong, so
the default now follows `render`, once in the shared Button rather than
at each call site. Passing `render` is not proof the result is a
non-button, only that we can no longer assume it is one, so the one call
site that draws a real <button> through `render` says so: the combobox
trigger, which otherwise trips the inverse warning.
SidebarMenuButton, Item and the sidebar's other wrappers call useRender
directly rather than useButton, so they have no nativeButton and never
warned; DropdownMenuItem already defaults it to false. None of them
change here.
* Let a person collapse the sidebar, and reach the roster on a phone (#308)
* Let a person collapse the sidebar, and reach the roster on a phone
The sidebar could always collapse. The primitive has held the state, the width transition and a
Cmd/Ctrl+B listener since it was vendored in, and nothing ever rendered a trigger for any of it. The
only affordance was SidebarRail, a 16px transparent strip carrying tabIndex={-1}, so the eye could
not find it and the keyboard could not reach it.
Under 768px the same sidebar is a Sheet that starts closed, and with no trigger nothing could open
it. The roster is this app's navigation, so on a phone every channel sat behind a control that did
not exist.
The toggle is drawn in the chrome each screen already has, and in both states: one that lived inside
the sidebar could not bring back what it hid. PageShell's back-button bar becomes unconditional so
the control sits at the pane's left edge rather than 400px into a centred prose column, and the three
screens that draw no header of their own get the same 48px band.
The preference now survives a reload. The primitive wrote a sidebar_state cookie for a
server-rendered shell to read on its first byte; nothing renders this app on a server and nothing
ever read it back, so that write is gone and lib/sidebar.ts holds the answer instead, in the shape of
the theme preference beside it. Mobile is deliberately excluded: an overlay that covers the screen it
overlays has no business being open before anybody asked.
* Draw no toggle where there is no sidebar, and cover the playground
Two gaps found by auditing every screen under a shell rather than trusting the list of files the
feature commit happened to touch.
The admin playground drew no toggle at all, so collapsing the sidebar there left no way back. It is
the one admin page that keeps its own geometry — an editor beside a live preview, as its comment says
— so the toggle goes inline in its header rather than in a band of its own, which would take 48px
from the thing being previewed.
`PageShell` is not guaranteed to be inside a shell. Every one of the twenty screens that draws it is
inside one today, so a toggle reading `useSidebar` unconditionally works here; a screen that renders
`PageShell` outside a provider is a reasonable layout choice, and it would have met a thrown error
during render rather than a missing button. `useSidebar` throwing is correct for a part OF a sidebar,
where its absence is a wiring bug. A control that merely offers to toggle one reads the context
optionally and draws nothing when there is none.
* Draw the bar only when it holds something, and name the action a phone will take
Two things review found.
The bar was unconditional, which on `/assist` and `/link/slack` meant a 56px band holding nothing:
those two draw PageShell directly under `_authed`, so there is no sidebar, the toggle correctly draws
nothing, and neither passes a Back link. An empty band of chrome reads as a layout bug rather than as
chrome. It is now drawn when it has at least one of the two to hold, and a screen without it keeps
the full original space above its heading rather than the reduced space the bar was paying for.
The label was wrong on a phone. `open` describes the desktop pane, so a mobile toggle always read
"Show sidebar" even with the Sheet open. The Sheet is modal, so nothing could be clicked while it was
wrong, but the context already carries `openMobile` and there is no reason to name the wrong action.
* Let a Bot visualize with Open Generative UI, not describe markup (#309)
* Let a Bot draw the answer, instead of describing markup it cannot show
A Bot asked for a chart could write the HTML for one and never put it on
screen. The renderer was already here, wired to the stored components the
playground publishes, but the runtime half was not: `openGenerativeUI` was
never passed, and that middleware is the only thing that turns a streamed
`generateSandboxedUi` call into the activity events that paint. Without it
the tool's own renderer shows the waiting message and then returns nothing,
so the Bot wrote a whole interface into a transcript that stayed empty.
The switch is one deployment capability beside `accessibility` and the
computer, on unless `OPENBOT_GENERATIVE_UI_DISABLED` says otherwise. It is
not a per-Bot grant because the SDK has no seam for one: the agent list
narrows only the event transform while the browser keeps offering the tool
to every Bot, so naming some Bots would leave the rest able to call it and
draw nothing, which is worse than not offering it at all.
The flag has to reach the browser as well as the runtime, which is why it
is projected on /api/capabilities. The SDK reads the capability as on when
either the runtime says so or the provider prop is merely present, so a
deployment that switched the server half off while the app kept passing the
prop would land back in exactly the state above.
Channels could not have shown one either. The transcript projection named
the roles it understood and dropped the rest, so a turn whose whole answer
was a drawing rendered as silence. Activities are carried through it now,
and the call that produces one is dropped from the transcript rather than
left as an empty row under every interface.
* Never let a generated interface occupy nothing
The SDK sizes the frame it draws into from one measurement taken inside the
sandbox and writes it as an inline height. Two races make that number wrong.
The measurement is queued alongside the expressions that build the interface,
so when it runs first it measures an empty body, writes `height: 0px`, and
because the container clips, the interface is invisible; nothing retries,
because the measurement is one-shot. Separately, the effect that measures
bails when the sandbox is still loading and its only dependency is
"generation finished" — an interface restored from history is finished on its
first render, so it bails and never runs again.
A floor is the smallest thing that removes the failure that matters.
`min-height` clamps above an inline height, so a collapsed measurement can no
longer render nothing while a correct larger measurement is left alone. A
reader gets an interface that is present and may be cut off, rather than a
turn that looks like the Bot said nothing.
Selected through the frame the sandbox library creates rather than through a
wrapper of ours, because both chat surfaces build this container and only one
of them is ours; a rule needing our wrapper would fix the transcript and
leave the packaged chat broken.
It compensates for arithmetic this repository does not own, so it says so and
says when to delete it.
* Make drawing something a deployment asks for, not something it inherits
The switch was written as `OPENBOT_GENERATIVE_UI_DISABLED`, matching the
accessibility flag beside it. That symmetry was wrong. Accessibility names a
deployment in an analytics label, so defaulting it on costs a fork nothing it
would mind. This decides whether a model may put code it wrote on somebody's
screen and pull libraries from a CDN to run it.
Written as a disable switch, absence is the permissive answer: every existing
deployment acquires the capability by upgrading rather than by choosing it,
and a deployment that builds its default branch automatically acquires it
without anybody present to decide. That is the wrong direction for a
capability whose failure mode is somebody else's code running in front of a
signed-in person.
So `OPENBOT_GENERATIVE_UI` instead, and only "true" or "1" count as yes.
Anything else — a stray "false", an empty variable left by a template — leaves
it off, because a value nobody intended should not turn a capability on. The
old spelling is deliberately inert, so a deployment that set it does not read
as having made a choice it has not made.
Both halves still read one answer. Absent, the runtime is not given the
middleware and /api/capabilities reports the capability off, so the browser
never offers the tool and no Bot writes an interface nothing will draw.
* docs: correct embedded PostgreSQL volume path (#307)
* Tell a browser to refetch when a server's channel subscription comes back (#311)
* Tell a browser to refetch when a server's channel subscription comes back
* Say the resync rule once each, instead of four times over
---------
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Say Blocked about a refusal the audit page had been calling Allowed (#302)
The audit page decides what a row was twice: once for the label and the colour, and once for the
`eventType` list behind the Blocked and Did not happen saved views. Both were hand-written lists, and
the fallback for anything neither list recognises is "Allowed".
Six event types were on neither. A hop one Bot was not allowed to make, an endpoint this deployment
refused to dial, a rotation the vault turned down and a sign-in it turned away were each drawn as
Allowed, in the muted colour every ordinary row uses, on the screen an administrator opens to find
out what this deployment refused. Clicking Blocked showed none of them. `agent.handoff_failed` and
`agent.escalation_failed` read as Allowed too, where they are "Did not happen": nothing was refused,
and nothing came of it either.
The comment beside the predicate already says why this matters — "the fallback below calls anything
it does not recognise Allowed, which for a refusal is the one wrong answer" — and the trail's own
notes say `agent.handoff_refused` is the more important of its pair, because a hop that happened is
visible in the transcript and one that was refused is invisible everywhere else. It was invisible
here, and worse than invisible: affirmatively wrong.
Two lists in one module now, and the page derives both the row and the saved view from them, so the
next refusal is added in one place or in none. Nothing else changes: the types already recognised
keep their labels, `mcp.call_failed`, `component.function_failed` and `bot.declined` keep the
accurate labels they already had, and the fallback stays open so a row type this build has never
heard of is still not called a refusal.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Match a coworker's connector by name, not as a substring of another word (#293)
When the intent router falls back and exactly one coworker can reach a system
the message names, the message is routed to that coworker. onlyCoworkerReaching
matched the system id with `haystack.includes(...)`, a bare substring test.
So "how do I deal with a slacker" matched the `slack` connector, and "escribe
un cuento sobre una jirafa" — a giraffe — matched `jira`. A message that named
neither system was read as naming one, and because a fallback pins the channel
to one coworker for the life of the thread, it misrouted the whole conversation
to a specialist that could not answer it. Every untagged message takes this
path when the router endpoint is down, which is the case that surfaced it.
The id is now matched on word boundaries: bounded by a non-alphanumeric
character or an edge of the message, with the id's own characters taken
literally. A system named on its own still routes to its holder, and one buried
inside a longer word does not. Separator loosening is unchanged, so google-drive
still answers to "google drive".
Word boundaries do not settle a name that genuinely appears as its own word for
another reason (a `linear` connector and "linear regression"); that is a limit
of a lexical reach hint, not this substring defect, and is left as is.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Score each action once in a policy dry-run, not once per audit row it wrote (#294)
Testing a candidate boundary against recent history replays the audit trail's
computer-action rows. It replayed three event types, and one is a duplicate: a
permitted action that fails is recorded twice — the decision row written before
it was attempted (action_allowed / action_refused) and a failure row written
when it did not succeed (action_failed) — and both carry the same action and are
returned by the query.
So every failed action was scanned and scored twice. And because a dry-run
policy carries a refused action out, a refused action can fail too, leaving two
rows that disagree on the baseline: the decision row records "refused", while
the failure row has no decision of its own and fell to the "allowed" branch. A
candidate policy that refused the same action identically was then reported as a
new refusal it never introduced.
The failure row is an outcome, not a decision. The replay now skips it and
scores each action once from its decision row, and the query no longer fetches
it — so it also stops spending the scan budget on rows that would be dropped.
The baseline for a permitted-but-failed action is still "allowed", because that
is what its decision row says.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Record what a rule in dry-run would refuse a Bot's tools, not only its browser (#301)
`dry-run` is how a boundary is measured against live traffic before it starts refusing anybody:
`evaluateActionPolicy` returns `allowed: false` with `forward: true`, so the policy refuses and the
mode lets the action through anyway. The value of the mode is entirely in the row it leaves behind.
The browser gateway writes that row. It keys its event type on `decision.allowed`, so a matched
action is recorded as `computer.action_refused` and then carried out. The connector path keyed its
row on `verdict.forward` instead, so in dry-run it wrote nothing before the call and then wrote
`mcp.call_succeeded` — the same decision, on the sibling surface, recorded as its opposite.
So an administrator who wrote `deny: mcp.effect == "write"`, switched the mode to "Record it and
allow it" exactly as the Boundaries page describes, and came back a week later found `Blocked` empty
and every `eventType=mcp.call_rejected` query answering zero. The dry-run report could not
compensate: its replay reads browser rows only. The rule looked inert, and enforcing it started
refusing Bots with nothing in the trail that could have warned anybody. That is the outcome the mode
exists to prevent.
The refusal is now written on the policy's answer rather than on what the mode did with it, which is
what the gateway has always done. `decision.carriedOut` tells the two rows apart: false is a call
this deployment stopped, true is one dry-run recorded and let past. The outcome row is untouched, so
a forwarded call still says separately whether the vendor answered, and an enforcing deployment
writes exactly the rows it wrote before.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Replace a connector's tool list in one step, so a failed refresh leaves it alone (#304)
`refreshTools` replaced the list with a `delete` and then an `insert`, auto-committed separately. The
catch below them says what is supposed to happen when a refresh goes wrong:
The tools already held are left alone: a vendor being briefly unreachable is not a reason to
revoke what Bots are using.
That is only true of a failure BEFORE the delete, which is the one the suite covers. Once the delete
has committed, anything that stops the insert leaves the connector with no tools at all.
It does not take a crash. `mcp_tools` is keyed on `(server_id, name)`, and the list is passed through
to a single multi-row insert with no de-duplication, so a server that answers `tools/list` with the
same name twice makes that insert fail on a duplicate key — a custom server an administrator added by
URL, or a curated vendor having a bad day. A pod killed mid-refresh, a dropped connection or a
statement timeout do the same thing.
`mcp_tools` is shared, so this is not one replica: every one of them loses that connector at the same
moment. `listForAgent` reads grants against the tool list, so every grant an administrator made stops
being offered without being revoked, and `grantedToolGuidance` then tells the Bot outright that it
holds none of that vendor's tools and to say so. Nothing repairs it: `refreshTools` is only ever
reached from `addServer`, `addCustomServer` and an administrator pressing Refresh, so the connector
stays empty until somebody notices `lastError` on the Plugins page.
Rolled back together, the vendor's bad answer lands in `lastError` and the Bots go on using what they
were granted.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Name the Bot on a hop that was refused, retried or lost, not only on one that landed (#306)
The Audit screen's Bot column reads `payload.bot` and nothing else, and renders a dash without it.
`agent.handoff_offered` says so in its own comment, added when the accepted pair was fixed:
without this the two handoff rows are the only Bot actions on a screen headed "Every action a
Bot took" that name no Bot.
The pair got the key. The four rows either side of it did not: `agent.handoff_refused`, both
`agent.handoff_retried` rows, and `agent.handoff_failed` carry `from` and no `bot`, so every one of
them draws a dash.
Those are the rows the trail exists for. `server/src/audit.ts` says it outright of the first one —
"a hop that happened is visible in the transcript anyway; a hop that was refused is invisible
everywhere else" — and the same holds for one that ran out of attempts, which is where a person's
unanswered question ends. So the screen named the Bot on the outcome you could already see, and drew
a dash on the three you could not.
`agent.escalated` and `agent.escalation_failed` one file over share a single payload and get this
right on both outcomes, which is the shape being restored here.
Pairs with #302, which stops that same screen calling `agent.handoff_refused` "Allowed". Together a
refused hop reads as a refusal and says whose it was; either alone leaves half the row wrong.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Make and manage coworkers in dialogs (#317)
* Gate first sign-in behind a per-user onboarding wizard
Two columns on users say where somebody is in first-run onboarding and
when they finished; /api/me carries the status and one POST moves it.
The app redirects an unfinished user to /onboarding — an animated
three-step wizard — and an address the build does not know now goes
home through the same gate instead of a bare 404.
* Say work is offered aloud, so a sweep can start now instead of at its next poll
The handoff queue was swept every two seconds, and a person is waiting through
every hop, so each leg of a handoff cost up to a full interval doing nothing.
Offering work now fires pg_notify with the kind as payload, inside the offering
transaction where there is one so it fires on commit and never before, and any
replica can listen on a dedicated connection and kick its sweep immediately.
A notification is a latency optimisation, never a delivery mechanism: one lost
in transit costs up to one poll interval, not the work, and the queue's tables
stay the only truth.
* Let a channel tell its members a turn is running in it
A transient busy flag on the channel activity event, announced and never
written: busy is a moment, not a fact about the channel, and a missed signal
costs at most a stuck-looking dot until the next real event, never data.
Two ways in. The server signals by thread (signalBusy) for the runs it can see
— the runtime's lock acquire and release now carry an onRunBusy seam, so every
run the platform processes lights its channel, including one whose tab has
navigated away — and a scratch thread maps to no channel and signals nowhere,
which is the point of a scratch thread. The browser signals by channel
(POST /:channelId/busy) for the one thing the server cannot see, a person's own
turn beginning, with a membership check so belonging to a channel is not
something an outsider can probe for.
* Relay a handoff answer back into the conversation that asked
A forward hop used to answer in the addressed Bot's own channel with the
person: two conversations for one question, and the answer landed somewhere
they never asked anything. Now the hop runs in a scratch thread of the
addressed Bot's own — minted per hop, never mapped to a channel, never shown —
and what it said comes home through a second queued hop that has the asking
Bot relay the answer, attributed, in the conversation the person is watching.
The delivery gathers the Bot's words from the stream as it goes past, because
the runner publishes the turn to the platform and the events are the one
chance to hear it. The relay rides the same durable queue as the turn that
produced it, so a pod dying between the two loses the relay to a retry rather
than for ever; the answerIn marker that stops a failure notice recursing stops
a relay relaying. Answers are clipped at 12k characters so a Bot that comes
back with a book cannot swamp the relaying run's prompt, and a backwards hop
reads only the tail of the asking conversation so relaying never grows slower
with the channel.
Wired in index.ts: the scratch thread replaces the direct-channel answerIn,
the roster announcement resolves thread to channel and happens only when the
turn said something, the asking channel is lit while a forward hop runs, and
the queue's new offered-work notification kicks the sweep so a person is not
waiting out a poll interval per leg.
* Show a channel working on the roster while a turn runs in it
Three bouncing dots badged on the channel's avatar, driven by the busy flag on
the activity socket. Socket-only and transient on the summary type: the roster
query never returns it, so it is undefined until a busy event arrives and drops
whenever the roster refetches — the acceptable failure for a hint about a
moment. The event patcher flips only the busy field: the spread that serves
ordinary activity would carry the event's null message onto the row and wipe
the preview, and busy is not activity, so the row does not re-sort either.
The channel reports its own turns over the new busy endpoint, fire-and-forget,
and deliberately does not clear on unmount: a turn keeps running server-side
after the person leaves the channel, and the server clears it when the run's
lock is released.
* Keep an open channel current with turns nobody here streamed
A relayed handoff answer runs on the server and lands in the thread with no
browser attached; the transcript restored history once, on mount, and would
show the new turn only after leaving and coming back. The chat now watches the
roster's own channel-list cache — the sidebar updating and the transcript
refreshing are one signal and cannot drift apart — and when this channel's
lastMessageAt advances to a moment a Bot authored, the durable history is read
again and messages whose ids the transcript has never seen are appended.
Appended by id, not compared by length, because the stored read keeps only what
the platform can parse and can be shorter than the screen while still holding
the news. Retried briefly, since the roster is patched when the turn is on
record with the runner and the platform's read can be a beat behind.
The chat also reports its own turns to the busy endpoint, keyed on whether a
turn is in flight, so the roster's working dots cover the one run the server
cannot see begin.
* Start a new chat from anywhere with Shift+N
A small hotkey registry, one place on purpose: the binding a listener matches
against and the combo the settings page shows are the same record, so the list
under Preferences is what the keys actually do rather than what somebody
remembered they did — each key drawn as its own keycap, symbols on a Mac and
names elsewhere. Matching is exact rather than at-least, so Shift+N does not
fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain
Shift+letter combo is left alone while the focus is anywhere editable.
Bound in _authed rather than _app, so a person on settings or admin can start
a chat without first clicking back into the app frame. The new-channel
composer's recipient picker takes focus on arrival, so Shift+N then typing a
name is one motion.
* Format the onboarding route
The wizard shipped unformatted; biome's own output, no hand edits.
* Say whether this deployment can make a built-in coworker
A coworker created with no endpoint runs on the deployment's managed Bot, and
a deployment without one refuses the create — after somebody has already
filled in the form. GET /api/agents/capabilities states it up front, static
per process because it is configuration rather than data, and registered above
the parameterised route so "capabilities" can never be read as an agent id.
The browser caches it forever for the same reason, through a new
agentCapabilitiesQueryOptions.
* Create a coworker in a multi-step questionnaire dialog
Three steps rather than one form: who it is (name, title, role), who can see
it, and where it runs — the fork where a built-in coworker needs nothing more
and a managed one reveals the endpoint and key fields. Built on the shadcn
questionnaire primitives for the choice cards and fieldset semantics, with
navigation driven by this dialog's own Continue/Back: the primitive's submit
path refuses items it does not consider answered and cannot see these fields.
Panes slide like onboarding — popLayout with a measured, height-following
frame whose 'relative' is what keeps the exiting pane clipped inside it.
Each step validates its slice of the shared form schema on Continue, and the
Built-in card is shown but disabled, with the reason, on a deployment whose
capabilities say it cannot back one. The visibility cards in AgentFields move
to the same radio-card idiom, on a radio-group primitive added for it.
* Show a coworker in a sectioned dialog with a sidebar of its own
The profile carries four distinct concerns — who it is, where it runs, what it
may hand work to, and what can be done to it — and the side panel stacked them
into one column that buried the later ones. Each is a section now, behind a
dialog-internal sidebar headed by the coworker's avatar and name.
General leads with name, title, role and visibility as items that edit in
place: Edit opens that field and that field alone, validated against the same
limits the server enforces, and visibility writes on pick because two named
choices leave no draft worth holding. Below them, the shortcuts — start
channel, duplicate, delete — each as an item with its button. Deleting
confirms in a dialog stacked over this one, with the name in the question and
a backdrop of its own; DialogContent grows overlayClassName for it, which also
forces the backdrop to exist, because Base UI skips backdrops on nested
dialogs and a stacked dialog is precisely the caller asking for one.
* Open coworkers in dialogs from the agents screen
The roster keeps both states in the URL — ?new opens the multi-step create
dialog, ?agent=<id> the coworker's own — so Back still closes them and a link
still lands on them. The DetailPanel goes, and with it the old NewAgent pane
it slid in; the channel screen's use of the side-panel profile is untouched.
The card grids also stop stretching their tracks: auto-fill over the card's
own width, so the gutter is the gutter rather than whatever 1fr left over.
* Sort two files' imports the way biome's assist asks
* Read the asking conversation before taking the run's lock
The history read is the one call in a delivery that throws on a platform
error, and it sat after the lock was acquired but before the try whose finally
gives the lock back: a 500 from the platform leaked the lock for its full TTL.
On a forward hop that lock is a scratch thread and the leak costs nothing; on
a relay it is the asking conversation itself, so the person could not type for
two minutes, the retry a minute later collided with the hop's own leftover
hold and spent an attempt on it, and a relay that ran out of attempts vanished
without a notice.
The read is of the conversation that asked, which the lock — taken on the
conversation being answered in — never protected. Hoisted above the acquire it
fails before anything is held, and the happy path holds the lock for less of
the turn too. Raised by kevin9327 on #290, who had the same reordering up as
#305 against main; a test now pins the ordering.
* Refetch the roster as well as the user when onboarding completes
Finishing the wizard navigates straight into the app, and the channel list —
cached from before the wizard, or from a fetch that ran while it held the
screen — could greet the person with an empty sidebar their reload then fixed.
Seen once on a real deployment during review of #290 and not cleanly
reproduced, so this is a hardening of the seam rather than a confirmed fix:
both invalidations use refetchType "all" for the same reason, nothing is
observing these queries while the wizard is up.
* Say which wizard agents are examples, and unstick its two classes
Two review findings on the wizard's roster step. The invented agents that top
up a sparse deployment rendered identically to real ones, so "Support Agent"
read as a Bot the deployment has; they are dimmed and labelled Example now.
And max-w-lg had run together with overflow-hidden into a class that applied
neither, which is why the roster cards drew wider than every other step.
Also stops the onboarding store's comment promising that step is where the
wizard resumes: it is stored and served but nothing resumes from it yet, and
the comment now says so instead of describing a behaviour that does not
exist.
* Say in the changelog what changed, including reversing 0.0.5's relay stance
Five user-visible changes were on this branch with no entry: the relay, the
working indicator, the transcript catch-up, the onboarding wizard, and
Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on
the addressed Bot's behalf, and this branch makes it do exactly that — so
somebody upgrading off those notes got the opposite of what they were told.
The Unreleased section now names the reversal outright, and the 0.0.5
paragraph carries a pointer forward rather than standing uncorrected.
* Say whether a coworker runs on the deployment's own Bot, and whether it may hand work on
The agent DTO gains builtIn, computed by comparing the stored endpoint
against the managed endpoint the server was configured with, and the
handoff answer gains grantable, asked of the plugin store's own notion
of where an agent runs. Both exist so screens can stop offering
controls the server would only refuse: a built-in coworker was being
nagged for a callback token it will never need, and a remote coworker
was offered handoff switches that bounced.
The endpoint comparison guards on typeof string because two undefineds
compare equal, which quietly made every endpoint-less stub built-in.
* Show routines per coworker, as rows that wear their state
The routine DTO now names its agent, so the list can be scoped to one
coworker's dialog while the Routines page keeps showing them all — one
owner-scoped query either way, the scope a filter rather than a second
endpoint.
The row itself is rebuilt as a muted item whose footer is a set of
chips: the channel it posts to (a link, since it is a place), the last
run with a colored dot carrying the tone, and either the next run,
Paused when switched off, or a pulsing Due when the stamp is already in
the past — which used to render as the nonsense 'Next 5 hours ago'.
Empty states go through the shared Empty component.
* Rebuild the handoff panel on items, and say once when granting is impossible
Each candidate Bot is a muted item with its avatar and a switch, under
a header that answers 'how many of them' at a glance. A coworker that
cannot be granted the handoff tool — it runs as its own agent, outside
this deployment's loop — gets one explanation item instead of a column
of switches that can only bounce off the server's refusal; its stale
grants stay visible so they can still be revoked.
* Grow the coworker dialog: access, routines, a truthful connection tab, and a phone strip
The dialog gains an Access section (which connectors and skills this
coworker has been granted, grouped from its plugin refs), a Routines
section (the scoped list, replacing the global sidebar entry — the
/routines route still answers direct links), and a Connection tab that
tells a built-in coworker the truth: it runs on this deployment's own
Bot, nothing to connect and nothing to authenticate, instead of nagging
for a callback token.
Below the md breakpoint the sidebar gave way to nothing and most
sections were unreachable; a scrollable strip of section buttons now
takes its place, with the coworker's name above it and room left for
the close button.
* Slim the channel's coworker panel to a card that opens the dialog
The panel beside a conversation answers 'who am I talking to' — avatar,
name, role, two buttons: start a new channel with this coworker, or
open the management dialog. It used to duplicate the dialog's whole
surface (edit form, tokens, grants, delete), which was two places to
maintain and a sidebar that scrolled past the conversation it sat
beside.
agent-fields and the radio-group primitive go with it: the edit form
was their last caller, and the create wizard draws its own choices.
* Say in the changelog what the dialogs change for the person running OpenBot
* Let the handoff explanations finish their sentence
`ItemDescription` clamps to two lines, which is right for a roster row whose description is a
subtitle and wrong for an item whose whole job is to explain. Both explanations here run to three,
and in each case the line that gets cut is the useful one: "It can still be asked by Bots that can"
is exactly what a person reading "this coworker cannot hand work on" needs next, and it was
invisible on screen while sitting in the DOM.
Seen on a deployment rather than in the file: the text reads complete in the accessibility tree and
truncated with an ellipsis in the browser.
---------
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Tell the channel that was running that it has stopped, not the one just opened (#315)
A person's own turn is reported by the browser, and that report was keyed on state belonging to the channel screen, so opening another channel before the reply landed left the first row showing working dots until the roster refetched for some unrelated reason. The report now belongs to the turn, which outlives the screen.
* Accept SERVER_PORT as well as PORT, and refuse to start when the two disagree (#312)
The server read PORT and scripts/start.sh read SERVER_PORT, so moving the server by editing one line left the script looking at the old port, accepting whatever answered there and failing several stages later. Either name now moves the server, and setting both to different values is refused at boot rather than half-applied.
* Align the TRUSTED_ORIGINS fallback with the port the app is actually served on (#313)
The app runs on 3010, and .env.example says so, but the fallback used when TRUSTED_ORIGINS is unset named 3000. A deployment that never set the variable trusted an origin nothing serves and refused the one that does.
* Reference the release checks by the repository's own syntax (#320)
`uses: ./.github/workflows/ci.yml` and `uses: $/.github/workflows/ci.yml` both call the reusable CI workflow from the caller's own commit, but only the second says so: the relative form is the same spelling a path on disk takes, and GitHub resolves it against the repository rather than the checkout. zizmor 0.6.3 added the audit that flags it.
* Update zizmorcore/zizmor-action action to v0.6.3 (#300)
Picks up the self-repository audit, which publish-release.yml was fixed for in #320.
* Show the repository's Trendshift standing, not only its build status (#348)
The header carried four badges, all of them about the repository's own
machinery: two workflow results, a licence and an alpha marker. None of
them told a first-time visitor that anybody outside the project had
looked at it. OpenBot reached #3 Repository Of The Day on Trendshift on
20 August 2026, and the header said nothing about it.
The badge is Trendshift's own endpoint for repository 175080, so the
rank it draws stays correct without anybody editing this file again.
* Read an empty PORT as unset, so SERVER_PORT still moves the server and NaN never reaches Bun.serve (#340)
#312 read the port as `process.env.PORT ?? process.env.SERVER_PORT ?? "3001"`. `??` fires only on
undefined, and a variable a compose file declares but the host never set, or `PORT=` left in a
`.env`, arrives as an empty string: SERVER_PORT was never consulted, the disagreement check
(`PORT && SERVER_PORT && …`) never fired, and `Number.parseInt("")` handed `Bun.serve` a NaN, which
it answers by binding an ephemeral port. The server came up somewhere nobody had asked for, and
the script polling SERVER_PORT reported one that never started — the failure #312 set out to
remove, back through the other name. `parseInt` also parses a prefix, so `PORT=30o1` started the
server on port 30 without a word.
The port is now read by `loadConfig` through the same `optional` every other setting uses, where
empty means unset, refusing anything that is not a whole number in 1–65535 the way the handoff
caps and AGENT_STALL_TIMEOUT_MS already refuse a typo. Tests cover the empty-string case that is
the whole point, the disagreement, and the refusals under both names.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
* fix(agent-endpoint): correctly handle bracketed IPv6 hosts with port in allowed list (#330)
AGENT_ENDPOINT_ALLOWED_HOSTS is a list of hosts optionally with a
port. For IPv6 the host is bracketed as [::1]:8080. The previous
normalization did host.replace(/^\[/, "").replace(/\]$/, ""),
which stripped the opening bracket but left the closing bracket when
a port followed: "[::1]:8080" became "::1]:8080". The check in
endpoint.ts did a different strip, producing "::1:8080", so the two
sides never matched and a named IPv6 endpoint was always refused.
Similarly, endpoint.ts derived host as url.host with a one-sided
replace, which for Bun's URL.hostname="[::1]" produced a mismatched
form versus the stored entry.
Normalize both sides consistently: store the host as hostname (without
brackets) plus optional :port, and derive the same from URL.hostname/
URL.port. This makes [::1]:8080 pin that port (and [::1] cover any
port, per "host without port covers any port"), and fixes the
bracket stripping for the allowed-host path.
* fix(audit): redact camelCase toolResult and toolArguments in audit trail (#329)
Tool results and arguments were only redacted under snake_case
tool_result/tool_arguments, but audit payloads also carry them as
toolResult/toolArguments (direct MCP and computer tool calls). The
normalized check fell through because the set lacked the stripped
forms toolresult/toolarguments, so plaintext secrets leaked into
audit_events.payload.
Add the two missing normalized keys so both spellings are covered.
Verified with redactAuditPayload unit probes: snake, camel, nested,
and upper-case variants now all return [REDACTED].
* Read an empty supervisor PORT as unset, so NaN never reaches Bun.serve (#343)
Empty PORT= used to parse to NaN and bind an ephemeral port while compose
still published 4300. Treat blank as the default 4300; refuse non-numeric
and out-of-range values instead of parseInt prefix traps.
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Cap a reported message's time at the server's clock, so a browser that runs ahead cannot hide what a routine said (#341)
`POST /:channelId/activity` took `at` from the browser and `recordActivity` applied it with a
forward-only guard, returning silently when nothing moved. The parser's comment said a wrong clock
could lose a report, not corrupt the row. That is the clock that is behind. One that is ahead
lands its report, stamps the row into the future, and then every correct report loses until real
time catches up: the routine runner's, which is the one report a headless firing makes; a relayed
handoff answer's; every other member's browser. The reply was in the thread and the roster never
said so, and the run row read `succeeded`.
A reported time is now capped at the server's own clock — a browser may say when, but not later
than now. Clamped rather than refused, because clocks are a little ahead all the time; a stamp in
the past is kept, so a message and its reply reported by the same clock still land in that order.
`now` is injectable, and the test that pins the clamp fails before this change.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Record which coworker a person chose on /channel/new, not only in the home composer (#342)
The routing routes write a `channel.routed` row for both ways a message finds a coworker, and say
why the named case is recorded at all: a trail with rows for routed conversations and none for
chosen ones reads exactly like a row that failed to write. The home composer honours that, telling
the server about an `@` choice before it starts the channel. `/channel/new` — where the sidebar's
+, a coworker's card and its profile all lead — never did: the person picked a coworker in the To:
field and the screen started the channel and told nobody, so every conversation begun from those
three places has no row.
One shared sequence now, `startWithChosen` in lib/channels/start.ts: record the choice, swallowing
a failure to write it as the home composer already did, then start. Both screens call it through
`useStartChannel().startChosen`, so they cannot drift again. Pure and tested; the routed branch of
the home composer is unchanged.
Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Fix/transcript anchor on first token (#339)
* Keep the transcript anchored to the question when the answer's first token arrives
* Note the transcript scroll fix in the changelog
* Format chat-transcript.tsx (biome)
---------
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Restore stored history when it is ahead of the join snapshot (#334)
Opening a channel joins the realtime gateway, and the join replaces the
agent's messages with the gateway's snapshot of the thread. That snapshot
can lag the durable store, so a turn that had finished, been persisted and
answered in full came back without its last exchange. History was restored
only into an empty agent, so the stale snapshot stayed for good: the gap
was there on every reload, with no unreadable count to explain it.
The store now wins when it holds more than the agent does and holds
everything the agent already has. The second half is the guard this
replaces: a message typed while history was loading is not in the store
yet, and a run still streaming has messages the store has not seen, so
neither is rolled back.
Claude-Session: https://claude.ai/code/session_01WaHWJ1niprhBc5NzJ9pxme
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Drop dangling tool calls from a chat turn's history before the model sees it (#333)
A frontend tool handler torn down mid-run leaves an assistant message
whose tool call will never be answered in the agent's live thread. Every
retry sends it straight back up as input.messages, and the AI SDK refuses
the conversation with AI_MissingToolResultsError naming the same call id
each time. Found live: one person's next three messages all failed that
way, and the conversation stayed dead until they worked out for themselves
to start another one.
Routines already sanitize the history they seed for exactly this failure.
That sanitizer moves to agents/history-sanitize.ts, re-exported from
routines/run-turn.ts, and now runs on every chat turn as well: on a
built-in Bot's run, on the clone the runtime makes before each run, which
the base class hard-codes to itself, and in the middleware that forwards a
remote Bot's messages to its endpoint, since a framework there converting
with the same SDK refuses the same conversation.
The rule for what counts as answered is the model API's own, and it is
stricter than the one routines had. A result answers a call only if it
lands after the call and before the next user message, because the API
walks the conversation in order and refuses it at that message. A browser
handler that resolved late appended its result after the person had typed
again, so the call read as answered under the old rule while every retry
still failed with the same id; a routine seeding such a history used to
keep both halves and fail, and now drops both and runs. Only a user row
is a boundary: the API also stops at a system row, but it checks the
converted messages, and the runtime drops system and developer rows on
the way there unless a forwarding flag this deployment never sets is on,
so a call answered after a skill's system row is answered as far as the
model call is concerned. A tool result survives only as the answer to a
surviving call, so one that sits ahead of its own call, or a second answer
to a call already answered, is dropped rather than sent where no provider
accepts it. A call a resume is about to answer survives the pass, since
run appends that result after conversion. Ids are never rewritten and the
stored thread is untouched.
The relay path gets the same fix for free. A Bot answering a relayed
question is seeded the asking conversation with every tool row stripped
and every assistant row that said something kept, tool calls and all, so
each of those calls was a guaranteed dangle until this pass ran.
Claude-Session: https://claude.ai/code/session_01H6GuYL1q5gSeMRGUf5R7i9
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Send a stored MCP token with the scheme it names (#332)
Every token stored against a custom MCP server went out as Bearer.
A vendor that forwards the Authorization header to an API which only
speaks Basic still answers the handshake and the tool listing, so the
connector looked connected and its tools were offered, and every real
call came back 401. DataForSEO's hosted server behaves exactly that way.
A token that already begins with Basic or Bearer is now sent as written;
a bare token is still Bearer, so nothing already working changes. The
scheme travels with the credential rather than as another setting on the
server row, so rotating a token can change how it is presented and
nothing else has to know.
Claude-Session: https://claude.ai/code/session_01WaHWJ1niprhBc5NzJ9pxme
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
* Send somebody back to Settings when the vendor cannot be reached at all (#345)
* Send somebody back to Settings when the vendor cannot be reached at all
The callback says every failure ends the same way: back at Settings with a word
about what happened, and nothing written. One did not. Redeeming the code turns a
refusal into null by asking `!response.ok`, and that question needs a response.
There is none when the connection is refused, the name does not resolve, TLS will
not agree, or the fifteen-second timeout fires, so the rejection went straight
through a function whose whole contract is to refuse quietly. Nothing above it
catches anything, so somebody who had just consented at the vendor got a bare 500
with no Location instead of the page that would have told them.
registerDynamicClient had the same hole against the same promise, one step
earlier in the flow, and is fixed with it. Neither route needed changing: both
already do the right thing with null, and both were already tested doing it.
Caught is not the same as unnoticed, so both log the cause. Until now the
framework's own handler printed these on the way to the 500, and buying the
redirect by dropping that would have made a vendor outage look exactly like
nobody trying to connect. The person sees the ordinary failure, indistinguishable
from every other refusal on an endpoint that deliberately tells an unauthenticated
caller nothing; the deployment sees which vendor and why.
* Answer the rest of the connect flow's failures the way it already answers the others
Review of the redemption fix turned up the same shape in three more places on the same
flow, all of them reachable and none of them saying so.
Writing the grant to the vault was still unguarded, which is the identical failure one
step later and on the worst possible step: somebody who has just finished consenting at
the vendor, told by a blank error that something went wrong at the end of the one part
they did correctly. It ends at Settings now, like everything before it. The refresh token
stays out of the log; the row it belonged to was never written.
A malformed endpoint was being read as a vendor outage, because `fetch` refuses an
unusable URL by throwing the same kind of error a refused connection does. Both are
checked before the …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
POST /api/channels/:channelId/activitytakesatfrom the browser —new Date().toISOString()inchannel-chat.tsx'sreport— andChannelStore.recordActivityapplies it with a forward-only guard (lastMessageAt IS NULL OR lastMessageAt < at), returning silently when nothing moved. The parser's comment said a wrong clock "can lose a report, not corrupt the row". It loses the wrong report only when the clock is behind. When it is ahead, the wrong report lands, the row is stamped into the future, and every correct report after it loses — silently — until real time catches up:server/src/routines/runner.ts,say→recordActivitywith servernew Date()), whose whole job, per its own header, is that one report because no browser is open. The reply is in the thread; the roster line and unread dot never move; the run is recordedsucceeded;announceinserver/src/index.ts, also server time);A browser seven minutes ahead — a laptop that woke up before NTP did — hides seven minutes of everybody else's messages from the roster.
The parser now caps
atat the server's own clock: a report may say when, but not later than now. Clamped rather than refused, because clocks are a little ahead all the time and a report a second early is still the report. A stamp in the past is kept as it is, so a person's message and the reply, reported separately by the same clock, still land in the order that clock saw them.nowis an injectable parameter with a default, so the test can be about a specific gap.Where it runs
recordActivity.pg_notifyinside the same transaction.Boundary and audit
Changelog
Unreleased.Proof
New
server/tests/channel-activity-input.test.ts, no database needed. The clamp case fails before the change and passes after:bun run format:check,bun run lint, and the server typecheck pass.