Help us shape the future of Go! Take the 2026 Go Developer Survey

handler

package
v0.48.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 6, 2026 License: MIT Imports: 38 Imported by: 0

Documentation

Overview

This file is the react-to-archive control: a way to END a conversation and get its topic out of the way, without deleting anything and without the agent's involvement.

What archiving is

The topic is MOVED to a channel the relay does not serve (`archive_channel`), with propagate_mode=change_all — one PATCH, the whole topic, messages intact. It is deliberately none of the alternatives:

  • not DELETE /streams/{id}/delete_topic, which is org-admin only: a relay bot must not hold realm admin for a convenience feature;
  • not per-message deletion, which is O(n) API calls with a partial-failure state in the middle;
  • not "close the session and leave the topic", which leaves the junk exactly where it was.

An unserved destination is outside the channel allowlist BY CONSTRUCTION, so an archived topic cannot re-engage the relay, and recovery is one move back. What accumulates there is then a retention policy problem, not this relay's.

Nothing on disk is touched: state/convs/<id>/ survives. Journal.Retire keeps the old conv-id and mints a NEW one for the key, so a later topic of the same name cannot resurrect the old memory — the state stays on disk without being reachable.

Two entry points, one code path

  • REACTION: :wastebasket: on the relay's own LAST message, by an allowlisted human. It arrives through Config.ReactionTrigger, which runs before the agent is involved at all — a destructive control must never depend on the model choosing to call a tool. "The relay's own last message" is resolved across a process boundary — memory, then the journal, then one narrowed API read — because an in-memory-only answer made the gesture stop working on every existing topic at every restart and reload. See Handler.lastOwnMessage.
  • COMMAND: `!archive` (or `!arch`).

Either ARMS a confirmation and posts a warning. The ONLY confirmation is :wastebasket: on that warning message. An expired arm is never carried forward: a later tap starts a fresh cycle with a fresh warning, because the alternative is a destructive act authorised by something the user has long since forgotten.

Why the ordering below is load-bearing

handleUpdate migrates a conversation when its topic moves, and a cross-channel move arrives as that SAME update_message event. Move first and the session FOLLOWS the topic into the archive instead of ending. So the conversation is retired BEFORE the PATCH is issued, and the echoed event then finds nothing to migrate.

This file is `!branch`: spinning an idea out of the conversation it surfaced in, into a topic of its own, without losing the context it grew out of.

The gesture

!branch <text>
!branch #**other-channel** <text>

`<text>` is the intended FIRST MESSAGE of the new topic. It is both the opening prompt and — through the existing autotopic naming path — the new topic's name.

The second entry point: :fork_and_knife:

A reaction cannot carry intent, which is why the typed form exists and why it takes an argument. But there is one case where the intent is already written down: the message being reacted to. Tapping :fork_and_knife: on a message means "spin THIS message out into its own topic" — the reacted-to message's body becomes the seed text, the topic title and the new session's first prompt, exactly as `<text>` does above.

Everything after that is the same code (see performBranch): one branch, two ways to start it, exactly as `!archive` and the :wastebasket: reaction are one archive. The differences are only at the edges and each is forced:

  • the branching user is the REACTOR, not the reacted-to message's sender — a third party may well be the one spinning somebody else's message out, and the seed's @-mention must reach whoever is waiting for the answer;
  • the destination is the origin's own channel, because a reaction has nowhere to name one — which is also why a DM is not branched by reaction at all (see BranchReaction);
  • the branch point is the REACTED-TO message, not "now": the pointer anchors and clamps there, so the branched session reads the origin up to the message it was spun out of and no further.

A second person tapping the same message must not open a second topic, so branched message ids are remembered and a repeat tap replies with a link to the topic that already exists.

Context is PULLED, never pushed

The relay writes NO summary of the origin. It records a parent pointer (channel, topic, and the bare id of the message that triggered the branch — the `!branch` message, or the message the :fork_and_knife: landed on) in the journal, states that pointer in the new session's first turn, and stops. If the new agent needs the context it fetches it itself, with `history(origin: true)` — a lazy fetch cannot be wrong about what matters, and a summary composed before anyone knows what the branch is about can.

That is also what makes branching work from a topic the relay was never engaged in — a human-only thread in an ambient channel — where there is no origin session to ask for a summary in the first place.

The permission model, in one sentence

A session may read its declared parent conversation, clamped to messages at or before the branch point, and nothing else. ONE HOP: the parent's own parent is not reachable (see journal.Parent), or "read my ancestors" would quietly become "read everything".

Ordering

Nothing is created until every refusal has been checked: the destination channel is resolved and confirmed served, the branch is confirmed not to land where it started, and the title is chosen against the topics already there. Only then is the seed message posted — which is what CREATES the topic on Zulip.

After that point exactly one thing can still stop the branch: the journal refusing to allocate the conversation. That aborts the turn and says so, and it leaves a real Zulip topic behind holding one seed message. That is the honest trade — the alternative is deleting a message to tidy up after an error — and the user is told to send a message in the topic to pick it up. The pointer message, which comes after, is the only step that truly degrades: it is logged and nothing else changes.

This file is the Handler half of the `branch` loopback tool (see internal/zulipmcp/branch.go): the agent-callable twin of `!branch` and the :fork_and_knife: reaction.

It adds no way to branch. Every task becomes a branchPlan and goes through branchOnce, the half the two human gestures share, so all three entry points create topics, write the journal and start turns in exactly one way. What is new here is only what an AGENT needs that a human does not:

  • a branch point named by message id (`from_msg`), refused unless it is in the caller's own topic — the child's history(origin=true) is clamped there, and an id from another topic would widen that read to a conversation nobody granted;
  • many branches in one call, capped by zulipmcp.MaxBranchTasks;
  • errors returned to the agent per task, not posted into the topic;
  • ONE notification for the whole call in the caller's topic, and, when the caller is itself a branch, one line in the ROOT of the branch tree, so a human watching the root sees the fan-out at every depth.

Nested branching is allowed: a child has the same tools as anybody else. The one-hop read rule still holds, because each child's parent pointer names only its direct caller.

This file is zulip-acp's half of the relay command surface. The broker itself — sigils, classification, the login state machine, the passthrough allowlist and all the rendering — lives in github.com/kfet/acp-kit/command, shared with poe-acp so the two relays cannot drift.

What stays here is what only Zulip knows:

  1. The Controller implementation, over the journal, the session manager and the handler's own inflight bookkeeping.
  2. The surface pre-filter: Zulip's `/me`, `/poll` and `/todo` are real messages, not client-side slash commands, and must reach the agent untouched.
  3. The `!!` escape and the unknown-command answer, which are this relay's policy about prose that merely starts with a sigil.
  4. `!opts`, the interactive options panel — a Zulip-only surface (zform button widgets), so it cannot live in the shared broker. Everything behind a button is still a broker action; see opts.go.

Ordering is load-bearing and unchanged from a prompt's: the bot-own-message and system-bot guards run first, then `allowed_user_ids`, then the engagement gate, and only then is anything parsed as a command. Dispatch also runs off Journal.Lookup, BEFORE Journal.Ensure, so no command ever allocates a conversation: `!help` in a topic the relay has never answered in leaves nothing on disk.

Package handler turns inbound Zulip events into ACP prompts and streams the agent's answer back into the originating topic.

The unit of conversation is a Zulip TOPIC, scoped by its channel. The topic string itself is never used as a key — see internal/journal for why — so everything here works in terms of the journal's stable conv-id.

This file is INBOUND attachment ingestion: when a human attaches a photo or a file to a Zulip message, the relay downloads it into the conversation's working directory and puts the local path — and, where the agent can take one, the image itself — in front of the agent.

Why it exists

Zulip does not deliver a file with a message. It delivers markdown containing `![name.jpg](/user_uploads/2/20/HASH/name.jpg)`, and the bytes sit behind an authenticated endpoint. Handing that text straight to the agent means handing it a path it cannot open: the only way through was for the agent to curl the Zulip API with the bot's credentials, which is both absurd and a credential it should never need. The symmetric outbound half — `outbox/` uploaded at the end of a turn — has worked since the beginning; this is its missing counterpart.

Where the files go, and for how long

`<session cwd>/inbox/`, rooted exactly like `outbox/`. They PERSIST: the conversation's state directory is the conversation's memory, and an agent that answered about a screenshot last Tuesday should still be able to look at it. Nothing here ever deletes a file.

`!new` does not clear the directory and must not: it retires the conv-id and mints a fresh one (see journal.Retire), so the new conversation gets a NEW working directory and therefore an empty inbox, while the old files stay on disk under the retired id. That is the same rule the rest of the relay follows — ending a conversation never destroys its files.

Bounds, all of them mandatory

Anyone who can post in a served channel can attach a file, so every limit here is a limit on what a stranger can make the relay do:

  • maxAttachments per message, so a wall of links cannot cost a hundred authenticated round-trips.
  • A per-FILE byte cap and a per-MESSAGE total, both configurable. A file over cap is skipped and NAMED in the prompt; it never fails the turn.
  • inlineImageMax bounds what may be base64'd into the prompt itself. A 20 MB image is a legal attachment and an illegal prompt.
  • MaxInlineImagePixels bounds the PIXEL dimensions of that inline copy, which no byte cap can do: a provider rejects an entire request over 2000px a side, so a phone photo small enough to pass every byte budget would otherwise kill the turn, and every later turn in the topic. See internal/imagefit.
  • attachTimeout bounds the whole ingestion, so a wedged download cannot eat a turn's budget.

EVERY failure degrades to the plain Zulip link plus a note. A turn must never fail because a download did.

This file is link auto-hydration: when a human pastes a link to another Zulip message, the relay fetches that message and puts it in front of the agent, so "see this" means something.

Why the relay does it and not the agent

It is the same idea as `!branch`, at a smaller scale: context is PULLED, on demand, by the thing that needs it. The difference is that here the human has already named exactly which message they mean, so there is nothing left to decide — an agent asking a tool for a message id it can already see would be a round-trip for a value nobody is uncertain about.

Two spellings, both of which Zulip's composer produces:

  • `#**channel>topic@949**`, the message-link mention;
  • any URL containing `/near/949`, which is what "Copy link to message" puts on the clipboard.

They are read out of the RAW markdown. The relay registers its event queue with apply_markdown=false, so the message content it holds is what the human typed, mention syntax intact — there is no rendered HTML to parse and no `topic_links` field to consult (that field carries linkifier matches from the TOPIC string, which is a different feature entirely).

Bounds, all of them mandatory

  • At most maxLinks per message. A message pasting forty links must not be able to spend forty API calls or forty thousand tokens.
  • Each body truncated to maxLinkRunes.
  • THE SAME CHANNEL ONLY, which is a stricter rule than the channel allowlist and has to be. `GET /messages/{id}` runs with the BOT's permissions, and the bot is subscribed to every channel it serves — so "served" alone would let anyone in one served channel paste a `/near/` link into another and have the relay read out a channel they cannot see. That is privilege escalation, not sharing. Restricting hydration to the channel the linking message is itself in makes the check free and exact: whoever posted there can read there. A DM is never hydrated at all, in either direction.
  • Deduped per conversation, so re-pasting the same link in a follow-up does not re-inject the same block turn after turn.

This file is zulip-acp's half of the agent→relay loopback.

The generic half — the MCP transport, the tool set, and the shared session-control actions — lives in acp-kit (mcphost, relaytool, command, schedule). What stays here is what only Zulip knows:

  1. Turning a conv-id (the state manager's session key, which is what mcphost binds from the connection token) into the broker's KEY token, and back into a place to post — and, for the Zulip-specific tools in internal/zulipmcp, into the journal.Key that says which topic or DM narrow may be read.
  2. Posting out of band, through the same rollover splitter every agent answer goes through — because Zulip truncates at 10000 code points SILENTLY, and a `post` tool that bypassed the splitter would be a brand-new way to lose output.
  3. Re-applying the relay's gates when a scheduled prompt fires with no human in the loop.

The loop hazard

The agent posts, Zulip delivers that message back as an event, and the relay must not treat its own words as a new turn. handleMessage's FIRST guard is `m.SenderID == h.cfg.BotUserID`, before any allowlist. That guard was always correct; the loopback is what makes it load-bearing, so it has a test of its own (TestLoopbackPostDoesNotFeedItselfBack).

This file is `!opts`: the relay's interactive options panel.

Why it lives here and not in acp-kit/command

Every other `!command` is shared with poe-acp and slack-acp, because every relay needs the same controls. `!opts` is the exception on purpose: it is a rendering of controls that already exist, onto a surface only Zulip has — the `zform` widget, a message-attached button form. Nothing behind a button is new. Each one carries a `reply` string that Zulip's web client sends as an ORDINARY message from the clicking user, so a click walks the same gates, the same allowlist and the same `!` parser a typed command does. There is no second code path to keep in step, and every action still goes through the broker's exported actions.

The phone is the default reader

zform renders as buttons in the Zulip WEB app and nowhere else — the phone app shows the message's plain markdown. So the body must be complete, current and usable with a thumb, and is written first. If the server rejects the widget outright, the panel still posts (see postPanel).

What this comment USED to say — that the markdown body is "the product" and the widget mere decoration — is false, and was false when written. MEASURED on Zulip 12.2: when a message carries widget_content the WEB client hides the markdown body ENTIRELY and renders only the widget. So the body is the product on every client that does not render widgets, and invisible on the one that does. Both halves have to stand alone; neither is decoration.

One panel per conversation — and a poll beside it

`!opts` posts a PAIR of messages: the panel proper, and a model poll (poll.go). They are posted together, retired together, and recorded together in the journal (journal.SetOpts takes both ids).

The split is forced by the wire: a message carries at most one widget_content, and the two controls need different widgets. It is also the right split. Choosing a model is the one control with more than three options and the one a phone reader actually reaches for, and a poll is the only Zulip widget MEASURED to render AND be votable on iOS. The panel keeps the session controls, which are three fixed actions and fit a chip each.

Why it is re-posted rather than edited

A widget message CANNOT BE EDITED. Measured on Zulip 12.2: a PATCH on a message carrying widget_content comes back 400 "Widgets cannot be edited." (see internal/zulipproto/zform.go). So a self-updating panel cannot be a PATCHed message — that design is not available at any price, and the obvious-looking implementation would fail on the first knob change on every server where the widget WORKED.

What is done instead: the panel is re-posted and the old one retired. Net effect on the topic is the same — exactly one live panel, and no growing pile of stale controls — and it is strictly better on a phone, because the panel lands where the reader is rather than somewhere above the fold. Retiring means DELETE, the one place this relay deletes a message it posted, falling back to rewriting the body to a pointer line when the realm forbids deletion (a plain, widget-less panel can still be edited) and to leaving it alone when neither works. The live panel's id is persisted per conversation in the journal, next to the streaming tail.

The phone cannot tap a zform — so the panel also wears reactions

zform is a web-only surface, which left the phone reader — the DEFAULT reader, see above — with markdown they had to retype by thumb. Reactions are the one interactive control Zulip renders on EVERY client, so the panel seeds its own emoji chips: `new`, `octagonal_sign` for stop, and `bar_chart` for status. Tapping a chip produces a reaction event, which reaction.go routes back through optsReaction below into the SAME `!` dispatch a typed command and a zform click walk. Three surfaces, one parser; see optsChips.

There used to be `one`..`six` digit chips for the model choices too, and they are GONE. Two reasons, either sufficient:

  • They did not work. MEASURED: with all nine chips present on the server (confirmed by API read), the iOS client drew the row starting at `three` — `:one:` and `:two:` never appeared, which is to say the current model and the one below it were unreachable by thumb, on the client the chips existed for. Cause unknown; not a count limit, not a seeding failure.
  • They are redundant. The model poll is a better surface on every measured axis: it renders and votes on iOS, and each option carries its own TEXT label, so nothing depends on a glyph drawing or on a positional emoji↔model mapping a footer has to explain.

The three session chips survive because the poll has no equivalent for them: `!new`, `!stop` and `!status` are actions, not a choice among alternatives, and a poll option that fires one would be a checkbox pretending to be a button.

Two things about the remaining chips were measured and are load-bearing:

  • The chips must be added SEQUENTIALLY. Zulip renders the chip row in first-added order, so a concurrent seed would scramble them against the legend that names them.
  • A bot CANNOT remove somebody else's reaction. The DELETE succeeds and removes only the bot's own, so an un-tap can never be undone — which is why op=remove is consumed and dropped here and why optsReactionFooter says so out loud. A chip left behind is cosmetic: a model change repaints, and the repaint deletes the whole panel, chips and all. A `!stop` or `!status` tap does not repaint, and its chip simply stays lit.

This file is the POLL PRIMITIVE, and its one caller today: the model menu, the half of `!opts` that a phone can actually use.

The primitive

A poll is a table of (label, reply) pairs. postPoll renders the labels as poll options; a vote on option N dispatches reply N verbatim (journal.Conv.PollReplies). Nothing here knows about models: it knows about commands. Three rules bind anyone adding a second kind of poll, and all three are load-bearing:

  1. EVERY REPLY MUST BE RELAY-AUTHORED. Never user text, never agent text. A persisted reply is an arbitrary command replayed later by a tap; the only reason that is safe is that nothing a prompt can influence reaches it. Stated at length on journal.Conv.PollReplies, where the field lives.

  2. THE REPLY MUST REPAINT THE PAIR. This is the primitive's CONTRACT, not the model poll's accident. A poll cannot be edited (400 "Widgets cannot be edited."), so a tick cannot be cleared — two allowlisted users voting differently leaves BOTH ticks showing, and the poll then displays a state that is not the relay's. The model poll self-heals because every model change runs through SetModelOverride, which repaints: the old poll is deleted and a fresh one posted with the new state in its question. A poll whose reply does NOT repaint shows stale ticks forever, and there is no way to fix it after the fact. So: do not add a poll option whose command leaves the pair standing.

    MEASURED caveat, and it bounds the repaint rather than breaking it: message_content_delete_limit_seconds is 600 on this deployment, so a repaint can only DELETE a pair younger than ten minutes. An older poll is refused, cannot be edited either (widgets never can), and stays in the scrollback. It is inert — the vote path matches conv.PollID exactly — so a reader of an idle topic sees two polls after voting, the lower one live.

  3. AN UNHANDLED DISPATCH IS DROPPED, never forwarded to the agent as prose. Generic replies make forwarding more tempting and more wrong: the reply is relay-authored, so a dispatch that does not recognise it is a relay bug, and the correct output of a relay bug is a log line — not a turn spent asking the model what "!model p/x" might have meant.

# Participant-added options are silently ignored — and the question # line says so

Zulip lets any viewer add an option to a poll, and there is no server setting to forbid it (checked: the poll widget takes no such parameter; the ability is part of the widget). An added option is keyed by the adder's USER ID, not by a canned index, so ParseVote rejects it and nothing the relay does can resolve it. Someone will add "gpt-99" and watch nothing happen.

All three available answers are taken, cheapest first:

  • the QUESTION LINE says "vote, don't add" (pollQuestion). A poll shows its question on every client, it costs no round-trip, and it lands before the mistake rather than after it. This is the one that actually helps.
  • the ADDITION is logged (handleSubmessage), so a confused user's "I added it and nothing happened" is answerable from the log.
  • nothing is posted in reply. A poll is a control surface, and narrating a mis-tap into the topic would put relay chatter in the transcript the model reads — the same reason a knob change is a reaction and not a message.

Why a poll and not buttons or chips

Three surfaces were built and measured on this deployment, Zulip 12.2:

  • zform buttons (opts.go): render in the WEB app only. The iOS client shows nothing at all.
  • reaction chips: render everywhere, and broke anyway. With nine reactions present on the panel — every one confirmed held by the server via the API — the iOS client drew the row starting at `three`. `:one:` and `:two:` never appeared, so the CURRENT model (pinned first) and the one after it could not be tapped. Cause unknown; not a count limit, not a seeding failure.
  • a poll: renders AND is votable on iOS. Proven end to end.

The poll also removes the whole class of bug the chips had rather than the one instance: an option carries its own TEXT, so nothing depends on a glyph drawing or on a positional emoji↔model mapping that a legend has to explain to the reader.

A vote is a command, and walks the command gates

A vote arrives as a `submessage` event and is routed by Handler.pollVote into h.dispatch with the text `!model <id>` — the exact string a human could type and the exact string the loopback tool's action ends at. There is no second path to model switching, which is the same rule a chip and a zform button already obey: one parser, N surfaces.

It therefore also walks the same gates, in the same order: the relay's own user id, the bot-sender set, the allowlist, and then the one call that can recognise a bot created since startup. A vote from a user the allowlist does not name changes nothing.

An un-vote is not a choice

MEASURED: a poll vote toggles, sending vote +1 then -1 then +1. The selection is the latest POSITIVE vote; a -1 is dropped entirely. Reading it as anything else would let un-ticking an option mean "no model", which is not a state the relay has.

This file is the relay half of `rename_topic` (internal/zulipmcp).

Why the rename is deferred

A turn posts into a topic while it runs: the placeholder, every streaming edit, each rollover message, the end-of-turn repost. The poster is built from the conversation's key as it was at the START of the turn (see convPoster), so a topic that moves underneath a running turn splits its own answer in half — the messages already posted travel with the move, and every message posted after it lands back in a topic named after a question nobody is asking any more.

So the tool call does not rename anything. It ARMS a rename, and Handler.applyRename performs it from endTurn, after the turn has left the inflight map and every byte of the answer is posted. This is the same rule `new_session` follows, and for the same reason: a loopback tool must never destroy the turn that is calling it.

Why the arm belongs to the TURN, not the conversation

A turn can be superseded: a follow-up cancels whatever is running and starts its own turn in the same conversation. The cancelled turn then unwinds asynchronously, so two turns are briefly alive at once. An arm keyed on the conversation would let the dying turn apply the LIVE turn's title — moving the topic out from under a turn that is still streaming into it, which is exactly what the deferral exists to prevent. So the arm hangs off the turn's own inflightEntry, and applyRename refuses to act once another turn holds the conversation.

Why the journal is renamed here rather than on the event

Zulip echoes the move back as an update_message event, and handleUpdate migrates the journal when it arrives. That is correct and stays — but it is not immediate, and in the window between the PATCH and the event a message in the NEW topic would miss the conversation and allocate a second one. Renaming the journal inline closes the window; the echoed event then finds nothing left to move and says so.

This file makes schedule changes visible without a question.

A turn that arms, cancels or is started by a schedule says so in its footer, and the reply that armed a schedule carries an :alarm_clock: reaction until that schedule is gone. Both are derived from the Handler's own command.Scheduler methods, which every path goes through — the agent's tools and the `!unschedule` command alike — so no change can bypass them.

A turn that changed nothing gets no marker. A footer that always talks about schedules is a footer nobody reads.

This file shows in the topic list whether agent work is live.

A channel topic WITHOUT Zulip's resolved mark (✔) has work in it: a turn is running, or a schedule is pending. A topic WITH the mark is idle. The relay uses the native mark rather than a label of its own because every client already draws it, filters on it (is:resolved) and sorts it, and because a topic move is the only per-topic state a bot can change without posting a message.

When the mark moves

  • markBusy, at the start of every turn, before anything is posted: the topic is unresolved. A message sent to a resolved topic thus unresolves it when its turn starts.
  • settleStatus, wherever the topic may have gone idle (a turn ended, a schedule fired, was armed or was removed): the topic is resolved when no turn runs and nothing is scheduled, and unresolved otherwise. A human who resolved the topic while work was live is overridden here, at the next change.

settleStatus does nothing while a turn runs. Moving the topic under a running turn splits the turn's own answer in two (see rename.go), and the turn's end settles the topic anyway.

Why a lock per topic

A turn can start in the gap between settleStatus deciding "idle" and its move landing. Both paths hold the topic's lock (statusLock) across decide-and-move and re-read the conversation from the journal inside it, so the turn's markBusy runs after the resolve and undoes it, never before it. The lock is per topic, not global, because it is held across Zulip requests: a slow move in one topic must not delay the start of a turn in another.

The server is truth

markBusy reads the triggering message back from the server and takes its topic as the topic's current name. That covers a resolve whose event was missed, and a message sent just before the relay's own move, whose event still names the old topic.

Identity

The journal index ignores the prefix (journal.BaseTopic), so "foo" and "✔ foo" are one conversation and toggling never costs a session. The journal is updated inline after each move, as applyRename does, so a message in the moved topic cannot miss the conversation while the echoed update_message event is still on its way.

The notice

Zulip's Notification Bot posts "marked this topic as resolved" for every resolve. send_notification_to_old_thread/new_thread=false (which MoveMessage always sends) does NOT suppress it — measured on Zulip 12.2. The relay never answers it: it comes from a system bot. Users can have such notices marked read automatically (resolved_topic_notice_auto_read_policy), and an operator who does not want them sets "topic_status": false.

Index

Constants

View Source
const (
	DefaultMaxAttachmentBytes      int64 = 20 << 20
	DefaultMaxAttachmentTotalBytes int64 = 60 << 20
)

DefaultMaxAttachmentBytes caps ONE inbound attachment and DefaultMaxAttachmentTotalBytes caps a whole message's worth.

20 MB per file is comfortably above a phone photo and comfortably below anything that belongs in a chat message; the message total is three of them, so a burst of photos still arrives while a dump of ten large files does not. Both are operator-configurable (`max_attachment_bytes`, `max_attachment_total_bytes`).

View Source
const DefaultCatchupTurns = 4

DefaultCatchupTurns is how many catch-up turns run at once. A long outage can leave many topics waiting, and starting all of their turns together would be a burst of agent prompts at startup.

View Source
const InboxDir = "inbox"

InboxDir is the per-conversation directory inbound attachments are downloaded into, relative to the session cwd. The mirror image of OutboxDir.

View Source
const InterruptedMarker = "\n\n*(relay restarted — turn interrupted)*"

InterruptedMarker is appended to a tail message the relay was still streaming into when it stopped. A restart kills the child agent, so the turn is dead regardless; the correct behaviour is to say so and let the next message start cleanly, never to try to continue a half-streamed message.

View Source
const MaxCatchupPerTopic = 50

MaxCatchupPerTopic bounds how many missed messages one topic's catch-up turn carries. The newest are kept: they are the ones a reader still waits on, and the older ones are named in the header.

View Source
const OutboxDir = "outbox"

OutboxDir is the per-conversation directory an agent writes files into to have them uploaded to Zulip. Relative to the session cwd.

Variables

This section is empty.

Functions

func AgentVersionFromCmd added in v0.37.0

func AgentVersionFromCmd(argv []string) func() string

AgentVersionFromCmd returns a Config.AgentVersion that runs `argv[0] --version` once, on first use, and caches the first line of its output. It is the fallback for an agent that sends no agentInfo at initialize. On any failure it returns "", and `!status` then shows only the agent command. It runs without the agent's env or cwd, which `--version` does not need.

func TypingIntervalFor added in v0.28.0

func TypingIntervalFor(expiry time.Duration) time.Duration

TypingIntervalFor converts a server's typing expiry period into a refresh cadence with room to spare — two thirds of it, never less than a second.

Exported because the cadence is settled at startup, from the realm snapshot, and handed to the Handler in its Config.

Types

type Agent

type Agent interface {
	Prompt(ctx context.Context, sid acp.SessionId, prompt []acp.ContentBlock) (acp.StopReason, error)
	// PromptTurn is Prompt, and it also returns the agent's leaf entry
	// id for the turn. The journal keeps it, so a branch from this
	// turn can fork the session at it. See forkPoint.
	PromptTurn(ctx context.Context, sid acp.SessionId, prompt []acp.ContentBlock) (client.TurnResult, error)
	Models() (models []client.ModelInfo, currentID string)
	// SetModel selects the model for one session. It backs `!model
	// <id>`; the relay never calls it unless a user asked for a
	// specific model.
	SetModel(ctx context.Context, sid acp.SessionId, modelID string) error
	// CurrentModel is the model one session is running. The footer
	// names it; Models' current is process-wide and names whichever
	// session was opened last.
	CurrentModel(sid acp.SessionId) (string, bool)
	// AvailableCommands is the agent's advertised command catalog,
	// which gates the passthrough allowlist: the relay forwards
	// `!reload` as `/reload` only when the agent actually offers it.
	AvailableCommands() []client.CommandInfo
	// Caps is the agent's advertised capability set. Inbound
	// attachment ingestion consults promptCapabilities.image to decide
	// whether an image may be sent as a content block or must be named
	// on disk instead.
	Caps() client.Caps
	// AgentInfo is the agent's name and version from the ACP
	// initialize response. `!status` shows it.
	AgentInfo() client.AgentInfo
	// SessionStats is what the agent reported about one session over
	// ACP: thinking level, context usage, cost. `!status` shows it.
	SessionStats(sid acp.SessionId) (client.SessionStats, bool)
	// ForkSession copies the parent session into a new one (ACP
	// session/fork). A branch uses it so the new topic starts with the
	// origin's context. See forkBranch.
	ForkSession(ctx context.Context, cwd string, parent acp.SessionId, at string, sink client.SessionUpdateSink) (acp.SessionId, error)
	// ListSessions lists the agent's sessions in one directory, newest
	// first. A branch uses it to find a reaped origin's session.
	ListSessions(ctx context.Context, cwd string) ([]client.SessionInfo, error)
}

Agent is the subset of *client.AgentProc the handler drives directly. Session lifecycle lives in Sessions.

type ChannelSet added in v0.3.0

type ChannelSet interface {
	Name(streamID int64) (string, bool)
	// ID is the reverse of Name: it resolves a channel name typed as a
	// `#**mention**` to its id, and reports whether the relay serves
	// it. `!branch` is the only caller — it is handed a name and must
	// refuse a destination the relay could not post in.
	ID(name string) (int64, bool)
	// Ambient reports whether a channel engages without an @-mention.
	Ambient(streamID int64) bool
	// Autotopic reports whether a general-chat message in a channel
	// is moved to a generated topic before it is answered.
	Autotopic(streamID int64) bool
}

ChannelSet is the relay's channel allowlist: it answers Name for a served channel and reports false for every other one.

It is an interface rather than a map because the set may be static (an explicit `channels` list) or live (the "*" sentinel, following the bot's subscriptions); see internal/channels.

type Config

type Config struct {
	Client Poster
	// Mark, when set, is the offline catch-up's persisted mark: every
	// processed message advances it, and a message at or below it is
	// dropped as already handled. Nil turns both off.
	Mark Mark
	// CatchupTurns bounds how many catch-up turns run at once. 0 uses
	// DefaultCatchupTurns.
	CatchupTurns int
	Agent        Agent
	Sessions     Sessions
	Journal      *journal.Journal

	// ProbeStatus reports how far the startup model probe has got, so
	// an empty model list can be worded honestly: "the agent has not
	// been asked yet" and "the agent has no models" are different
	// sentences, and only the second is fixed by `!login`. Optional —
	// nil reads as probe.StatusProbed, i.e. the empty list is the
	// agent's real answer.
	ProbeStatus func() probe.Status

	// BotUserID is the relay's own Zulip user id. Messages from it are
	// refused unconditionally: the relay must never feed its own
	// output back into the agent.
	BotUserID int64
	// BotFullName is the bot's display name, used to recognise
	// @-mentions in raw markdown.
	BotFullName string
	// BotSenderIDs are user ids whose messages are never treated as a
	// human turn: every bot in the realm, snapshotted at startup. A
	// bot that appears later is not in the set, but the cross-realm
	// system bots — the ones that actually post unprompted — are
	// caught by the SenderRealm check instead.
	BotSenderIDs map[int64]struct{}

	// Channels is the served-channel allowlist. The relay answers
	// nowhere else. It is consulted per event rather than snapshotted,
	// because a set that follows the bot's subscriptions changes
	// underfoot while the relay runs.
	Channels ChannelSet
	// AllowedUsers, if non-nil, restricts who the relay answers. It
	// applies to direct messages exactly as it does in a channel.
	AllowedUsers map[int64]struct{}

	// DMs enables direct-message conversations. Off by default: a
	// relay hands the whole realm an agent with a shell, and the
	// channel allowlist — which cannot gate a DM, because a DM is in
	// no channel — is the only thing standing in the way. Serving DMs
	// must therefore be something an operator asks for.
	DMs bool

	// NoProgressTimeout cuts a turn that has stopped making progress:
	// no agent output and no tool-call activity for this long. It is a
	// WEDGE guard, not a working bound — a legitimately long tool call
	// keeps resetting it, so a turn that is working is never cut. 0
	// defaults to 2 minutes.
	NoProgressTimeout time.Duration
	// TurnCeiling is an OPT-IN absolute cap on one turn, enforced
	// regardless of progress. 0 (the default) means no ceiling.
	TurnCeiling time.Duration
	// ZulipCallTimeout bounds one relay-initiated Zulip API call made
	// outside a turn (the `post` loopback tool). 0 defaults to 2
	// minutes.
	ZulipCallTimeout time.Duration
	// EditInterval coalesces streaming edits.
	EditInterval time.Duration
	// BatchEdits suppresses intra-turn streaming edits entirely: the
	// answer is published once, when the turn closes. The zero value
	// is the streaming behaviour, so it is stated as the negative —
	// config.Config's operator-facing key is `stream_edits`.
	//
	// It also suppresses the eager "Thinking…" placeholder, which is
	// the whole point: with no placeholder, the single post at Close
	// is the first and only message CREATED, so the one push
	// notification the user gets carries the real answer. Liveness
	// comes from the typing indicator instead — see typing.go.
	BatchEdits bool
	// TopicStatus marks a channel topic resolved (✔) while it is idle
	// and unresolved while agent work is live. See topicstatus.go.
	TopicStatus bool
	// SpinnerInterval animates the "Thinking…" placeholder. nil is the
	// default: 900ms while streaming, off in batch mode. A non-nil 0
	// disables the animation — the placeholder is posted once and no
	// spinner goroutine is started at all. It is unused in batch mode,
	// where there is no placeholder to animate.
	SpinnerInterval *time.Duration
	// TypingInterval is how often quiet mode refreshes the typing
	// indicator. It must be comfortably inside the realm's
	// server_typing_started_expiry_period_milliseconds; resolve it
	// with TypingIntervalFor. 0 in batch mode defaults to 10s; a
	// negative value turns the indicator off. Unused while streaming.
	TypingInterval time.Duration

	// Budget, SealMarker and ContinuationMarker configure the splitter.
	Budget             int
	SealMarker         string
	ContinuationMarker string

	// SilentSentinel lets the agent decline to answer an ambient turn.
	SilentSentinel string
	// HideThinking suppresses thought chunks on the streamed path.
	HideThinking bool

	// RepostOnClose re-posts a finished streamed answer as NEW
	// messages and deletes the placeholder-seeded originals, so the
	// mobile push notification carries the answer instead of
	// "Thinking…" — Zulip notifies on message CREATION only.
	RepostOnClose bool

	// AckEmoji is the emoji reaction placed on the triggering message
	// for the duration of a turn. Empty disables the acknowledgement.
	AckEmoji string

	// Site is the relay's own realm URL. Inbound attachment ingestion
	// needs it to recognise an ABSOLUTE `https://<realm>/user_uploads/…`
	// link as ours; an empty value restricts ingestion to relative
	// paths, which is the safe direction to fail.
	Site string

	// SiteAliases are additional host names this realm answers to, so
	// an absolute upload link copied from a browser is recognised as
	// ours even when the human reached the realm by a different name
	// than the relay does. Entries may be bare hosts or full URLs.
	SiteAliases []string

	// InboundAttachments downloads the files a human attached to a
	// message into <cwd>/inbox and puts their local paths (and, when
	// the agent takes image blocks, the images themselves) in front of
	// the agent. See inbox.go.
	InboundAttachments bool
	// MaxAttachmentBytes caps ONE inbound attachment and
	// MaxAttachmentTotalBytes caps a whole message's worth. A file
	// over either is skipped with a note in the prompt, never a failed
	// turn. Both must be positive when InboundAttachments is set; New
	// fills a zero with the default.
	MaxAttachmentBytes      int64
	MaxAttachmentTotalBytes int64
	// MaxInlineImagePixels caps the long edge, in pixels, of an image
	// base64'd into the prompt; 0 disables downscaling. Only the
	// inlined copy is resized — the file in <cwd>/inbox is always the
	// original. See internal/imagefit.
	MaxInlineImagePixels int

	// Reactions delivers emoji reactions into the owning conversation
	// as an ambient synthetic turn. See reaction.go for why every gate
	// there is mandatory — reaction events are NOT narrowed by the
	// event queue, so the whole realm's traffic reaches us.
	Reactions bool

	// ReactionTrigger, if set, gets first refusal on every reaction
	// that resolves to an engaged conversation, and reports whether it
	// consumed it — i.e. whether the relay itself acted on the
	// reaction instead of handing it to the agent.
	//
	// In production this is Handler.ArchiveReaction (archive.go) then
	// Handler.BranchReaction (branch.go), in that order: archiving and
	// branching a topic are RELAY actions, not agent turns, and a
	// destructive control must never depend on the model choosing to
	// call a tool. Archive is tried first because it owns a
	// confirmation cycle whose arming message must not be shadowed.
	ReactionTrigger func(ctx context.Context, conv journal.Conv, ev zulipproto.Event, m *zulipproto.Message) bool

	// ArchiveStreamID and ArchiveChannel are the destination of the
	// archive control: the channel a topic is MOVED to when someone
	// reacts :wastebasket: to the relay's last message or types
	// `!archive`. Zero disables the whole control.
	//
	// The destination must be a channel this relay does NOT serve —
	// that is what makes an archived topic unable to re-engage it —
	// and the bot must be permitted by realm policy to move messages
	// between channels. Both are established at startup (see
	// cmd/zulip-acp), so nothing here has to discover them mid-action.
	ArchiveStreamID int64
	ArchiveChannel  string

	// ArchiveMoveLimit is the realm's move_messages_between_streams
	// time limit as it applies to THIS bot: how old the oldest message
	// in a topic may be and still be movable. Zero means unlimited —
	// either the realm sets no limit, or the bot is exempt.
	//
	// It is not decoration. A move refused on age grounds fails at the
	// LAST step of an archive, after the conversation has been ended
	// and retired, leaving the topic where it was with no conversation
	// attached to it. Knowing the limit lets the archive settle the
	// question before it touches anything; see Handler.movable.
	ArchiveMoveLimit time.Duration

	// After is the timer the reaction debounce waits on. Defaults to
	// time.After.
	//
	// It is injected for the same reason the clock is: a debounce
	// proved by sleeping is a flaky test, and this is the one place in
	// the relay whose behaviour IS a duration.
	After func(d time.Duration) <-chan time.Time

	// OnReactionBatch, if set, is called with a conversation's
	// coalesced reaction burst at the instant the conversation has
	// been claimed for it and the buffer emptied — i.e. the one moment
	// from which WaitIdle can see the turn.
	//
	// It exists so the test suite can prove coalescing without racing
	// a timer; nil in production. Like OnWaitForConv it must only
	// signal.
	OnReactionBatch func(convID string, n int)

	// Commands is the acp-kit chat-command broker. Nil disables the
	// whole `!command` surface, which is what the relay does when it
	// has no agent to ask. New wires the Handler in as the broker's
	// Controller, so the caller must not call SetController itself.
	Commands *command.Broker

	// Updater runs the owner-only `!update` command (acp-kit/update).
	// Nil leaves `!update` unrecognised.
	Updater *update.Updater

	// UpdateDecide receives every reaction ADD before any other gate
	// (acp-kit/autoupdate: the owners' answer to an update offer). It
	// returns true when the reaction was an update decision. Nil = none.
	UpdateDecide func(ctx context.Context, msgID, userID int64, emoji string) bool
	// UpdateStatus is appended to `!update --check`. Nil = nothing.
	UpdateStatus func() string

	// Schedules is the durable store behind scheduled prompts. Nil
	// disables scheduling: the Handler still satisfies
	// command.Scheduler, but every call reports that the relay cannot
	// do it.
	Schedules *schedule.Store
	// Loopback is the self-hosted MCP tool set. Nil disables the
	// agent→relay loopback. The Handler needs it only to drain
	// deferred actions at the end of a turn.
	Loopback *relaytool.Tools

	// Version, AgentCmd and StartTime are reported by `!status`. All
	// optional: an unset field is simply omitted from the reply.
	Version   string
	AgentCmd  string
	StartTime time.Time
	// AgentVersion, if set, names the agent (e.g. "fir 1.18.4") when
	// the agent sent no agentInfo at initialize. See
	// AgentVersionFromCmd. It is called only when needed.
	AgentVersion func() string
	// Now is the clock `!status` measures uptime against. Injected so
	// the test suite never has to sleep. Defaults to time.Now.
	Now func() time.Time

	// OnWaitForConv, if set, is called just before a scheduled turn
	// parks waiting for a conversation to go idle.
	//
	// It exists so the test suite can prove the queueing behaviour
	// deterministically: "the scheduled turn waited rather than
	// superseding the human one" is only observable at the moment the
	// wait is entered, and asserting it with a timer would be exactly
	// the flaky, timing-dependent test AGENTS.md forbids. Nil in
	// production.
	//
	// It runs while the handler's inflight lock is HELD, so it must do
	// nothing but signal. Anything that touches the Handler deadlocks.
	OnWaitForConv func(convID string)

	// OnEarlyPlaceholder, if set, is called once the ambient path has
	// posted its placeholder AND recorded the tail for it.
	//
	// It exists because those are two steps, and the surface only
	// witnesses the first: a test that watched for the posted message
	// and then asserted on the journal was racing the goroutine that
	// records it — which is exactly the kind of nearly-always-passing
	// test that fails once in CI and teaches nobody anything. Nil in
	// production; it must only signal.
	OnEarlyPlaceholder func(convID string)

	// OnTurnEnd, if set, is called once a completed turn's deferred
	// loopback actions have been applied — i.e. at the very last
	// instant the turn's goroutine touches anything.
	//
	// It exists for the same reason as OnWaitForConv: WaitIdle cannot
	// see this work, because endTurn runs deliberately AFTER the turn
	// has left the inflight map (see handleMessage). A test that
	// asserted on it without this hook would be racing the harness's
	// own TempDir cleanup. Nil in production.
	OnTurnEnd func(convID string)

	// OnArchiveExpired, if set, is called after an armed archive
	// confirmation has lapsed and been forgotten.
	//
	// Like the other On* hooks it exists so the test suite can prove
	// the behaviour without racing a timer: the expiry is observable
	// only as an absence, and asserting on an absence with a sleep is
	// exactly the flaky test AGENTS.md forbids. Nil in production; it
	// must only signal.
	OnArchiveExpired func(convID string)

	// Logf receives operational messages.
	Logf func(format string, args ...any)
}

Config configures a Handler.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler implements the event side of the relay.

func New

func New(cfg Config) (*Handler, error)

New constructs a Handler.

func (*Handler) ArchiveReaction added in v0.21.0

func (h *Handler) ArchiveReaction(ctx context.Context, conv journal.Conv, ev zulipproto.Event, _ *zulipproto.Message) bool

ArchiveReaction is what Config.ReactionTrigger is wired to in production. It reports whether it consumed the reaction.

Everything it relies on has already been checked by handleReaction: the reaction is not the relay's own, not another bot's, the user is on the allowlist, and conv is a conversation the relay is engaged in and still serves. What is left is this file's own policy — the right emoji, an add rather than a remove, and the right message.

func (*Handler) BranchReaction added in v0.29.0

func (h *Handler) BranchReaction(ctx context.Context, conv journal.Conv, ev zulipproto.Event, m *zulipproto.Message) bool

BranchReaction is the reaction entry point, wired alongside ArchiveReaction as a second consumer of Config.ReactionTrigger. It reports whether it consumed the reaction.

Everything it relies on has already been checked by handleReaction: the reaction is not the relay's own, not another bot's, the user is on the allowlist, and conv is a conversation the relay is engaged in and still serves. What is left is this file's own policy.

It never touches the origin conversation's turn. There is no cancelInflight, no claim, and no prompt delivered there: the only write to the origin is the pointer message performBranch posts, so tapping :fork_and_knife: while the origin agent is mid-answer costs that answer nothing.

func (*Handler) BranchTasks added in v0.40.0

func (h *Handler) BranchTasks(sessionKey string, tasks []zulipmcp.BranchTask) ([]zulipmcp.BranchResult, error)

BranchTasks is zulipmcp.Config.Branch: it runs the tasks of one `branch` call out of the conversation sessionKey names.

A whole-call error means nothing was created. After that, each task succeeds or fails on its own, and a failure is reported in its result — one bad from_msg must not cost the other nine branches.

func (*Handler) CanSchedule added in v0.7.0

func (h *Handler) CanSchedule() bool

CanSchedule satisfies command.Scheduler: scheduling exists only when the operator turned the loopback on, and this is what keeps `!schedules` and `!unschedule` off the command surface otherwise. The Handler implements the interface unconditionally, so without this the relay would advertise commands nothing could serve.

func (*Handler) CancelAll added in v0.38.0

func (h *Handler) CancelAll() []string

CancelAll stops every in-flight turn and returns where each one was, for `!update --force`. WaitCancelled then waits for those turns to unwind: WaitIdle cannot, because cancelling drops a turn from the inflight registry before it has finished posting.

func (*Handler) CatchUp added in v0.46.0

func (h *Handler) CatchUp(ctx context.Context, msgs []zulipproto.Message, maxAge time.Duration)

CatchUp turns the messages that arrived while the relay was down into turns. msgs is oldest first, as internal/catchup reads it.

Every message goes through the same gates as a live one (route). Then each conversation's missed messages are COLLAPSED into one turn: a topic that had five messages while the relay was down gets one answer, not five turns that supersede each other. Messages older than maxAge are not answered; each topic that had any gets a one-line notice instead. The turns start through startTurn, so the session pool and its concurrency apply exactly as for live turns.

func (*Handler) ConvKey added in v0.10.0

func (h *Handler) ConvKey(sessionKey string) (journal.Key, bool)

ConvKey maps an MCP session key — the conv-id, resolved server-side from the connection's token — to the Zulip conversation it names.

It is the identity check for every Zulip-specific loopback tool (today, `history`): the key is what says WHICH topic or DM the call may read, and it comes from the connection, never from an argument. ok=false rejects the call, which is what happens when the journal has no such conv-id — i.e. the caller is not a conversation this relay owns.

func (*Handler) ConvOrigin added in v0.23.0

func (h *Handler) ConvOrigin(sessionKey string) (journal.Parent, bool)

ConvOrigin maps an MCP session key to the conversation THIS one was branched out of, and reports whether there is one.

It is zulipmcp.Config.Origin, and it is the whole permission surface of `history(origin: true)`. ONE HOP by construction: it returns the caller's own parent pointer and never follows that parent's, so a chain of branches cannot accumulate into a licence to read everything.

The location is resolved from the message id, not from the key

The journal stores the branch point's BARE MESSAGE ID beside the channel and topic, and this is what that id is for. A topic can be renamed, or moved to another channel by `!archive` or by any human, so the stored key rots — and the failure is not merely a miss: a LATER topic of the same name in the same channel would be read instead, which is the permission model silently pointing somewhere nobody granted. So the current location is read back from the message itself, every time.

Anything that does not resolve is refused rather than fallen back on: an origin whose branch-point message has been deleted, or whose topic has moved into a channel the relay no longer serves, is an origin nobody can vouch for. The stored key is kept only as the clamp's companion, never as an authority.

A retired conversation still resolves — LookupID is deliberately id-addressable past retirement — so a turn that is still unwinding after `!new` keeps the origin it started with.

func (*Handler) ConvToken added in v0.7.0

func (h *Handler) ConvToken(sessionKey string) (string, bool)

ConvToken maps an MCP session key — the conv-id, resolved server-side from the connection's token — to the command broker's conversation token. It is relaytool's Config.ConvToken.

ok=false rejects the tool call. That happens when the journal has no such conv-id at all, which means the caller is not a conversation this relay owns.

func (*Handler) DropPendingReactions added in v0.18.0

func (h *Handler) DropPendingReactions() int

DropPendingReactions discards every buffered reaction burst and reports how many reactions were dropped.

It is the shutdown/reload counterpart to the debounce: once polling has stopped, a reaction still waiting to be coalesced is not a turn anyone is waiting on, and letting its timer expire mid-drain would start a turn the re-exec then kills. A flush already claimed and running is a real turn and is drained normally by WaitIdle.

func (*Handler) FireSchedule added in v0.7.0

func (h *Handler) FireSchedule(ctx context.Context, it schedule.Item) (err error)

FireSchedule is schedule.Config.Fire: it runs one scheduled prompt as an ordinary turn in the conversation it was armed in.

It BLOCKS for the whole turn. That is not incidental — acp-kit's store derives a schedule's recursion depth from whatever is firing in the conversation right now, so returning early would make every schedule the turn arms look like a fresh depth-1 one and the depth cap would stop bounding anything.

No human in the loop

A scheduled turn has nobody watching it, so every gate an interactive turn passes is re-applied HERE, at fire time rather than at arm time:

  • the channel must still be served (a channel unsubscribed since the schedule was armed stops firing);
  • direct messages must still be enabled;
  • the conversation must still exist in the journal.

Any of those failing returns schedule.ErrGone, which disarms the item rather than retrying it forever. The one gate with no analogue is `allowed_user_ids`: a scheduled prompt has no sender. It needs none — it can only exist because an allowed user drove a turn that armed it, and it re-enters that same conversation, so it can never reach anywhere its author could not.

func (*Handler) Handle

func (h *Handler) Handle(ctx context.Context, ev zulipproto.Event)

Handle is the zulipproto.EventHandler entry point.

func (*Handler) Idle added in v0.47.0

func (h *Handler) Idle() bool

Idle reports that no turn is in flight.

func (*Handler) MarkInterrupted

func (h *Handler) MarkInterrupted(ctx context.Context)

MarkInterrupted annotates every message the relay was streaming into when it last stopped. A restart kills the child agent, so the turn is dead; saying so beats leaving a truncated answer that looks complete.

It never attempts to continue a half-streamed message.

func (*Handler) PostTo added in v0.7.0

func (h *Handler) PostTo(token, text string) error

PostTo satisfies command.Poster: it sends a message into the conversation the tool call came from, out of band.

There is no target parameter anywhere in this path, by design — see command.Poster. The key comes from the token, so the worst an agent can do is talk in the topic it was already talking in.

func (*Handler) RelayInfo added in v0.6.0

func (h *Handler) RelayInfo(token string) command.RelayInfo

RelayInfo satisfies command.Controller.

func (*Handler) RenameTopic added in v0.20.0

func (h *Handler) RenameTopic(key journal.Key, title string) (string, error)

RenameTopic is zulipmcp.Config.Rename: it arms a topic rename on the turn currently running in the conversation that key names.

It only records. Nothing is sent to Zulip until the turn ends — see applyRename — and the string returned is what the agent is told, so it says exactly that rather than implying the topic has moved.

func (*Handler) ResetSession added in v0.6.0

func (h *Handler) ResetSession(token string) error

ResetSession satisfies command.Controller: it is what `!new` calls.

func (*Handler) Schedule added in v0.7.0

func (h *Handler) Schedule(token, text string, at time.Time, every time.Duration) (schedule.Item, error)

Schedule satisfies command.Scheduler.

func (*Handler) Schedules added in v0.7.0

func (h *Handler) Schedules(token string) []schedule.Item

Schedules satisfies command.Scheduler.

func (*Handler) SetModelOverride added in v0.6.0

func (h *Handler) SetModelOverride(token, modelID string) error

SetModelOverride satisfies command.Controller. The choice is sticky per conversation and applied to the ACP session at the start of the next turn — see convo.Manager.ApplyModel. Every model change passes through here — typed, tapped, or made by the agent through its loopback tool — so the ModelChanged hook is the one place that keeps the options panel honest.

func (*Handler) StatusFor added in v0.6.0

func (h *Handler) StatusFor(token string) command.SessionStatus

StatusFor satisfies command.Controller.

func (*Handler) StopTurn added in v0.6.0

func (h *Handler) StopTurn(token string) bool

StopTurn satisfies command.TurnStopper, which is what enables `!stop`. poe-acp deliberately does not implement it: it answers one HTTP request per turn and has no in-flight turn a later message could reach. This relay streams into an editable message and does.

func (*Handler) Unschedule added in v0.7.0

func (h *Handler) Unschedule(token, id string) error

Unschedule satisfies command.Scheduler.

func (*Handler) WaitCancelled added in v0.38.0

func (h *Handler) WaitCancelled(ctx context.Context) error

WaitCancelled blocks until every turn CancelAll cancelled has unwound, then until no turn is in flight, or ctx is done.

func (*Handler) WaitIdle

func (h *Handler) WaitIdle(ctx context.Context) error

WaitIdle blocks until no turn is in flight or ctx is done. Used for graceful shutdown and to synchronise tests without polling.

Catch-up turns still waiting for a slot count as in flight: the mark has already moved past their messages, so a reload that did not wait for them would lose them.

type Mark added in v0.46.0

type Mark interface {
	Get() int64
	Set(id int64) error
}

Mark is the persisted id of the last message the relay processed. See internal/catchup.

type Poster

type Poster interface {
	SendMessage(ctx context.Context, streamID int64, topic, content string) (int64, error)
	SendDirectMessage(ctx context.Context, userIDs []int64, content string) (int64, error)
	// SendMessageWidget and SendDirectMessageWidget attach a
	// widget_content payload. Only `!opts` uses them; every other
	// message the relay posts is plain markdown.
	SendMessageWidget(ctx context.Context, streamID int64, topic, content, widget string) (int64, error)
	SendDirectMessageWidget(ctx context.Context, userIDs []int64, content, widget string) (int64, error)
	EditMessage(ctx context.Context, id int64, content string) error
	// MoveMessage retopics a message. Only the autotopic path uses
	// it, to lift a general-chat message into a topic of its own; it
	// may be refused by realm policy, so the caller degrades.
	MoveMessage(ctx context.Context, id int64, topic, propagateMode string) error
	// MoveMessageToChannel moves a message — with change_all, a whole
	// topic — to ANOTHER channel. Only the archive control uses it,
	// and only after startup has established that realm policy allows
	// it; see archive.go.
	MoveMessageToChannel(ctx context.Context, id, streamID int64, topic, propagateMode string) error
	// DeleteMessage removes a message the bot posted. Used to retire a
	// superseded `!opts` panel, which cannot be edited when it carries
	// a widget, and to retract the placeholder-seeded streaming chain
	// once the final answer has been re-posted (see
	// Handler.repostForNotify).
	DeleteMessage(ctx context.Context, id int64) error
	GetMessage(ctx context.Context, id int64) (zulipproto.Message, error)
	// Messages reads a narrow of the realm's history. The handler uses
	// it for exactly one thing: resolving the relay's OWN last message
	// in a conversation when neither memory nor the journal knows it —
	// see Handler.lastOwnMessage.
	Messages(ctx context.Context, narrow []zulipproto.NarrowTerm, limit int, beforeID int64) ([]zulipproto.Message, error)
	// OldestMessage returns the first message in a narrow. The archive
	// preflight is the only caller: with propagate_mode=change_all it
	// is the oldest message in the topic that Zulip's move time limit
	// is judged against.
	OldestMessage(ctx context.Context, narrow []zulipproto.NarrowTerm) (zulipproto.Message, bool, error)
	// Topics lists the topic names already in a channel. Only
	// `!branch` uses it, to avoid creating a topic that collides with
	// a live one — Zulip would silently merge the two.
	Topics(ctx context.Context, streamID int64) ([]string, error)
	// UserByID resolves a user id to a user record. Used only by the
	// reaction path, which is handed an id and nothing else.
	UserByID(ctx context.Context, id int64) (zulipproto.User, error)
	Upload(ctx context.Context, filename, contentType string, r io.Reader) (string, error)
	// DownloadUpload fetches an inbound attachment's bytes with the
	// bot's credentials, capped at max bytes. See inbox.go: a Zulip
	// message carries a link, never a file, so this is the only way
	// the agent ever sees what a human attached.
	DownloadUpload(ctx context.Context, uploadPath string, max int64) ([]byte, string, error)
	AddReaction(ctx context.Context, messageID int64, emoji string) error
	RemoveReaction(ctx context.Context, messageID int64, emoji string) error
	// SetTyping raises or lowers the typing indicator for one
	// conversation. It is quiet mode's liveness signal — see
	// typing.go — and is never used while streaming, where the
	// placeholder already shows the relay is alive.
	SetTyping(ctx context.Context, op string, streamID int64, topic string, userIDs []int64) error
}

Poster is the Zulip surface the handler writes to.

type Sessions

type Sessions interface {
	GetOrCreate(ctx context.Context, key string, sink client.SessionUpdateSink) (*state.Session, error)
	Touch(s *state.Session)
	Cancel(ctx context.Context, key string)
	TakePendingSystemPrompt(s *state.Session) string
	// StateDir is the root the per-conversation working directories
	// live under. `!status` reports the conversation's directory so a
	// human can go and look at it.
	StateDir() string
	// Live reports a conversation's live session id and last use
	// without creating a session, so `!status` never spawns one.
	Live(key string) (sid acp.SessionId, lastUsed time.Time, ok bool)
}

Sessions is the subset of *state.Manager the handler uses.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL