+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index 11e108e7ca7c..df446cdb2b08 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -14,10 +14,12 @@ import * as Arr from "effect/Array";
import * as Result from "effect/Result";
import { useState, type ReactNode } from "react";
import {
+ HERMES_DRIVER_KIND,
isProviderDriverKind,
type ProviderInstanceConfig,
type ProviderInstanceEnvironmentVariable,
type ProviderInstanceId,
+ type EnvironmentId,
type ProviderDriverKind,
type ServerProvider,
type ServerProviderModel,
@@ -42,6 +44,7 @@ import { ProviderSettingsForm } from "./ProviderSettingsForm";
import { ProviderModelsSection } from "./ProviderModelsSection";
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
+import { HermesCompanionSection } from "./HermesCompanionSection";
import { RedactedSensitiveText } from "./RedactedSensitiveText";
import {
getProviderVersionAdvisoryPresentation,
@@ -319,6 +322,7 @@ function ProviderEnvironmentSection(props: {
}
interface ProviderInstanceCardProps {
+ readonly environmentId: EnvironmentId;
readonly instanceId: ProviderInstanceId;
readonly instance: ProviderInstanceConfig;
readonly driverOption: DriverOption | undefined;
@@ -376,6 +380,7 @@ interface ProviderInstanceCardProps {
* flows through the envelope.
*/
export function ProviderInstanceCard({
+ environmentId,
instanceId,
instance,
driverOption,
@@ -773,6 +778,14 @@ export function ProviderInstanceCard({
/>
) : null}
+ {instance.driver === HERMES_DRIVER_KIND ? (
+
+ ) : null}
+
{driverOption !== undefined ? (
{
readonly attachments?: ReadonlyArray | undefined;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index ae6840cd16ef..a2b54c9a6129 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -227,8 +227,9 @@ export default defineConfig(() => {
? {
// One entry per shared prefix; the server's dev catch-all 404s the
// same list, so the two sides cannot drift. `/ws` is the app's own
- // socket — Vite's HMR socket is matched separately and exactly
- // (path "/" plus a vite-hmr subprotocol), so the two upgrade
+ // socket and `/api` includes companion sockets such as the Hermes
+ // gateway — Vite's HMR socket is matched separately and exactly
+ // (path "/" plus a vite-hmr subprotocol), so these upgrade
// handlers don't collide.
proxy: Object.fromEntries(
DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
@@ -236,7 +237,7 @@ export default defineConfig(() => {
{
target: devProxyTarget,
changeOrigin: true,
- ...(prefix === "/ws" ? { ws: true } : {}),
+ ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}),
},
]),
),
diff --git a/integrations/hermes-t3-gateway/COMPATIBILITY.md b/integrations/hermes-t3-gateway/COMPATIBILITY.md
new file mode 100644
index 000000000000..aed76ae7d3df
--- /dev/null
+++ b/integrations/hermes-t3-gateway/COMPATIBILITY.md
@@ -0,0 +1,515 @@
+# Hermes event compatibility inventory
+
+The plugin deliberately uses only public Hermes plugin and platform-adapter
+surfaces. The compatibility shims were audited at Hermes Agent upstream commit
+`62e07223` (v0.19.0). The supported handoff callback shape was separately
+audited at official revision `d109785b`; v0.19.0 remains supported through the
+documented Home fallback.
+
+T3 interactive turns are intentionally outside the companion integration:
+`hermes-acp` owns them. The v4 validators and BasePlatformAdapter callbacks
+remain for API/wire compatibility, but companion `turn.start` and `turn.steer`
+dispatch fails recoverably before invoking Hermes.
+
+Scope note: this file inventories **upstream Hermes** surfaces only. T3-side
+machinery the plugin talks to over the wire — `withHermesConfig`, the broker's
+generation fencing, `getOrCreateHomeThread` — is not a Python concern and is
+documented on the T3 side; only the wire contract those produce appears here.
+
+Audited surfaces, all present at that commit:
+
+| Surface | Location at 62e07223 |
+| ---------------------------------------- | ------------------------------------ |
+| `save_env_value` / `get_env_path` | `hermes_cli/config.py:8137` / `:688` |
+| `load_config_readonly` | `hermes_cli/config.py:7415` |
+| `skills_list` (registered tool) | `tools/skills_tool.py:785` |
+| `skill_view` (registered tool) | `tools/skills_tool.py:961` |
+| `build_session_key` | `gateway/session.py:1029` |
+| `resolve_gateway_approval` | `tools/approval.py:2073` |
+| `resolve_gateway_clarify` | `tools/clarify_gateway.py:160` |
+| `register_platform` (`**entry_kwargs`) | `hermes_cli/plugins.py:931` |
+| `_mark_notify_metadata` (`notify` flag) | `gateway/platforms/base.py:89` |
+| Tool-hook `session_id` (= run id) | `agent/tool_executor.py:188` |
+| Run-id generation | `gateway/session.py:2388` |
+| `HERMES_SESSION_KEY` binding | `gateway/run.py:17367` |
+| `get_session_env` accessor | `gateway/session_context.py:303` |
+| Tool-thread context propagation | `agent/tool_executor.py:715` |
+| Final-delivery `notify` stamp | `gateway/platforms/base.py:5220` |
+| Streaming final `notify` stamp | `gateway/stream_consumer.py:328` |
+| `REQUIRES_EDIT_FINALIZE` declaration | `gateway/platforms/base.py:3128` |
+| Progress-loop `finalize` injection | `gateway/run.py:20777` |
+| Segment-break `finalize` (flag-agnostic) | `gateway/stream_consumer.py:938` |
+| Live tool-chrome delivery path | `gateway/run.py:20485` |
+| `tool_progress` display resolution | `gateway/display_config.py:187` |
+| `format_tool_event` (override hook) | `gateway/platforms/base.py:2740` |
+| Tool-chrome dispatch (`None` == eat) | `gateway/stream_dispatch.py:108` |
+| `/steer` active-run handler | `gateway/run.py:11280` |
+| Home-channel notice text | `gateway/run.py:13780` |
+| Active-command inline dispatch | `gateway/platforms/base.py:4926` |
+| User-plugin path `$HERMES_HOME/plugins/` | `hermes_cli/plugins.py:10`, `:1350` |
+
+Home-channel surfaces, added for protocol v3:
+
+| Surface | Location at 62e07223 |
+| ------------------------------------------ | ---------------------------------- |
+| `cron_deliver_env_var` registration flag | `gateway/platform_registry.py:143` |
+| `standalone_sender_fn` registration flag | `gateway/platform_registry.py:159` |
+| Standalone-sender invocation | `tools/send_message_tool.py:741` |
+| `_home_target_env_var` fallback convention | `gateway/run.py:1541` |
+| `_resolve_home_env_var` (plugin lookup) | `cron/scheduler.py:1025` |
+| `env_enablement_fn` `home_channel` promote | `gateway/config.py:2648` |
+| `HomeChannel` dataclass | `gateway/config.py:421` |
+| `get_home_channel` | `gateway/config.py:1022` |
+| `get_hermes_home` (queue/state base) | `hermes_constants.py:106` |
+| `get_hermes_home` re-export | `hermes_cli/config.py:686` |
+| Cron run-id shape (`cron_*`) | `cron/scheduler.py:3017`, `:3484` |
+| Cron session-var clearing | `cron/scheduler.py:3066-3091` |
+| Cron `job_id` in routed metadata | `cron/scheduler.py:1782` |
+| Cron metadata reaching `adapter.send` | `gateway/delivery.py:606` |
+| Cron wrap header (`Cronjob Response: …`) | `cron/scheduler.py:1513` |
+| Gateway online notice | `gateway/run.py:17277` |
+| Gateway restart notice | `gateway/run.py:17236` |
+| Gateway shutdown/restarting notice | `gateway/run.py:6599` |
+| `/handoff` synthetic source identity | `gateway/run.py:8854` |
+| `HERMES_SESSION_USER_ID` binding | `gateway/run.py:17372` |
+| Session-context lifetime around a turn | `gateway/run.py:12972` → `:14626` |
+
+Hermes v0.19.0 had no public handoff-thread callback. Current official Hermes
+main (audited at `d109785b`) exposes
+`BasePlatformAdapter.create_handoff_thread(parent_chat_id, name) -> Optional[str>`
+and invokes it from the handoff watcher before the synthetic transfer turn. The
+T3 adapter implements that exact public method: it sends correlated
+`handoff.create`, returns the T3-created thread id, and reads the public
+`metadata["thread_id"]` Hermes supplies on the resulting send. Disconnect,
+timeout, protocol rejection, duplicate, and late-response paths all clean up the
+pending request. `None` remains the official fallback, so Hermes v0.19.0 and
+older T3 servers continue delivering the handoff summary to Home. No private
+handoff imports or monkey patches are used.
+
+This inventory describes gateway wire protocol v4. Protocol v2 added active-turn
+recovery in `session.ready` and authoritative `content.snapshot` replacement; v3
+added `role` on `connection.hello`, `homeThreadId` on `connection.accepted`, and
+the `home.deliver` / `home.deliver.ack` pair; v4 adds media — optional inline
+`attachments` on `turn.start` / `turn.steer`, the `media.deliver` /
+`media.deliver.ack` pair, and the `attachments` capability flipping to the
+literal `true`. `handoff.create` / `handoff.created` are an additive v4 exchange:
+an older peer never initiates it, while a new plugin receiving an
+`unsupported-message` response returns Hermes' documented Home fallback. Other
+version mismatches are rejected during the handshake — the version policy stays
+fail-closed.
+
+## Mapped in the initial scope
+
+| Hermes surface | T3 gateway event |
+| ----------------------------------------------------- | ------------------------------------------------- |
+| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` |
+| Final stream edit | `item.completed`, `turn.completed` |
+| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` |
+| Live adapter status text | `status_text` activity item |
+| `load_config_readonly()["model"]["default"]` | Optional `model` on `connection.hello` |
+| `send_exec_approval` | `request.opened` / `request.resolved` |
+| `send_clarify` | `user-input.requested` / `user-input.resolved` |
+| `/steer` gateway command | `turn.steer` |
+| Adapter interrupt event | `turn.interrupt` |
+| `load_config_readonly()["agent"]["reasoning_effort"]` | Optional `reasoningEffort` on `describe.response` |
+| `skills_list()` metadata | `skills` on `describe.response` |
+| `skill_view(name, preprocess=False)` | `markdown` on `skill.body.response` |
+| Cron `deliver=t3`, `send_message t3`, lifecycle | `home.deliver` / `home.deliver.ack` |
+| `create_handoff_thread(parent_chat_id, name)` | `handoff.create` / `handoff.created` |
+
+## Known limitations
+
+- The platform adapter receives cumulative rendered text, not the underlying
+ token stream category. The current adapter maps it to `assistant_text`; Hermes reasoning,
+ plan, and command-output stream categories are not publicly exposed here.
+- Prefix-extending cumulative edits emit `content.delta`; edits that revise or
+ clear already-emitted text emit an authoritative `content.snapshot`.
+- A handoff's thread creation and synthetic summary delivery use the companion,
+ because those are gateway semantics. A user reply typed in T3 remains an ACP
+ turn and does not continue the gateway's transferred CLI session. This is the
+ deliberate interactive-data-plane boundary, not an implicit fallback.
+- Hermes' exact first-chat T3 home-channel notice is suppressed at the adapter
+ output boundary. The plugin does not assign a home channel or redirect
+ proactive delivery; other Hermes platform notices pass through unchanged.
+ This match is **exact string equality**, which is fragile: Hermes builds the
+ notice inline from an f-string (`gateway/run.py:13780`) rather than exporting
+ a constant, so any wording change upstream silently stops the suppression and
+ the notice reaches the transcript. Re-verified byte-for-byte at 62e07223 by
+ reconstructing the f-string with `platform_name="t3"` (`Platform("t3").value`
+ → `"t3"`, `.title()` → `"T3"`) and the non-Slack `/sethome` branch; it still
+ matches. A regression test pins the literal.
+- Hermes' documented tool hook surface exposes a `task_id`, tool name,
+ arguments, string result, and duration. Verified at 62e07223: the runtime
+ additionally supplies `session_id`, `tool_call_id`, `turn_id`,
+ `api_request_id`, and `middleware_trace` on both hooks
+ (`hermes_cli/plugins.py:2146` for `pre_tool_call`, `model_tools.py:1050` for
+ `post_tool_call`), and `post_tool_call` also supplies `status`, `error_type`,
+ and `error_message`. The adapter consumes `session_id`, `tool_call_id`, and
+ `status` when present and falls back to the documented IDs for older
+ versions. It projects only canonical, whitelisted fields (command/cwd, file
+ path, search query, image path, or MCP server/operation); arbitrary arguments
+ and raw results never cross the wire.
+- `post_tool_call` passes `result` as `Any`, not a guaranteed `str` — the
+ adapter never forwards it, so the looser type is inert here.
+- **The tool hooks' `session_id` is not this plugin's session id.** Hermes
+ passes `agent.session_id` (`agent/tool_executor.py:188`, `:305`, `:341`),
+ which the gateway sets from `SessionEntry.session_id` — a timestamped run id
+ like `20260725_143012_ab12cd34` (`gateway/session.py:2388`,
+ `agent/agent_init.py:1446-1453`). This plugin's session ids come from
+ `build_session_key` (`gateway/session.py:1029`) and are shaped
+ `agent:main:t3:dm:`. The two namespaces never intersect, so keying
+ the thread lookup on the hook's value alone matched nothing and silently
+ dropped every tool activity item. This is the same class of defect as the
+ `finalize` bug — keying behaviour off a Hermes-supplied value whose meaning
+ was assumed rather than verified. `_turn_for_tool_hook` now resolves in three
+ steps: the raw `session_id` as a routing key (free, and correct if upstream
+ ever passes the gateway key here), then `HERMES_SESSION_KEY` from Hermes'
+ session context (`gateway/run.py:17367` →
+ `gateway/session_context.py:200`, read via `get_session_env` at `:303`),
+ which IS the `build_session_key` value and is propagated into the tool worker
+ threads by `propagate_context_to_thread` (`agent/tool_executor.py:715`), then
+ the sole active turn when exactly one exists. With two or more concurrent
+ turns and no routing key it emits nothing rather than misattributing activity
+ to the wrong thread. Every step is best-effort and cannot raise: tool
+ activity is decorative and must never break a turn.
+
+ Regression shape if upstream changes: if `HERMES_SESSION_KEY` stops being
+ bound or stops propagating into tool threads, a **multi-thread** Hermes loses
+ tool activity rows (single-thread still works via the sole-turn fallback).
+ Turn lifecycle is unaffected either way — tool items are decorative.
+
+- Approval resolution is session-FIFO in Hermes. T3 request IDs identify the UI
+ prompt, then resolve the oldest matching Hermes approval for that session.
+- The public `clarify` hook is a single question. The wire protocol supports an
+ array so richer structured input can be added without a protocol break.
+- Hermes session completion has no dedicated platform-adapter callback. The
+ plugin uses `notify=True` metadata on `send` as the authoritative completion
+ boundary (`_mark_notify_metadata`, `gateway/platforms/base.py:89`). It
+ explicitly does **not** use `finalize=True` on `edit_message`, which upstream
+ sets on every mid-turn tool-progress edit and every stream segment break —
+ see "Turn completion is keyed off `notify`, never `finalize`" below.
+- Active `/steer` dispatch returns a textual Hermes control acknowledgement
+ through the normal platform `send(..., notify=True)` path
+ (`gateway/platforms/base.py:4926`). The plugin captures that response in the
+ originating steering request's async context and suppresses it from the
+ transcript. Because a steer targets a _running_ turn, the capture is
+ correlated by the steering `requestId` — which the base adapter passes back
+ as `reply_to` via `_reply_anchor_for_event` — and not by `chat_id`. Genuine
+ assistant output emitted on the same thread during the steer window carries a
+ different correlation id and reaches the transcript untouched.
+- The plugin acknowledges T3 only when the audited Hermes success response
+ begins with `⏩ Steer queued`. That prefix is likewise matched against an
+ inline f-string (`gateway/run.py:11280`) rather than an exported constant, so
+ it carries the same drift risk as the home-channel notice. Confirmed present
+ at 62e07223. Unknown future response shapes fail closed with `protocol.error`
+ rather than completing the turn.
+- Hermes' configured default model is read once per handshake from the
+ documented read-only accessor `load_config_readonly()["model"]["default"]`.
+ That accessor returns the shared process-wide config cache and its docstring
+ forbids mutation, so the plugin copies out only a trimmed string. Any failure
+ — missing key, import error, older Hermes — omits the optional `model` field
+ from `connection.hello` rather than sending null or empty.
+- Hermes' configured reasoning effort is read from
+ `load_config_readonly()["agent"]["reasoning_effort"]` on every
+ `describe.request`, with the same discipline as the model read above: a
+ trimmed string copy, no mutation of the shared cache, and any failure omits
+ the optional `reasoningEffort` field rather than sending null or empty. Note
+ this is the _global_ effort. Hermes also supports
+ `agent.reasoning_overrides` (per-model) and `delegation.reasoning_effort`
+ (subagents); neither is resolved here, so a user with a per-model override
+ active sees the global value on the Agent page.
+- Skills are enumerated through the registered `skills_list()` tool surface
+ (`tools/skills_tool.py:785`), not the private `_find_all_skills()` scanner
+ behind it. Consequences of that choice, all verified at 62e07223:
+ - `skills_list()` already applies Hermes' disabled-skill, platform, and
+ environment filters, so **disabled skills are absent from the list rather
+ than reported with `enabled: false`**. The wire field is always `true`.
+ Reporting disabled skills would require `_find_all_skills(skip_disabled=True)`
+ plus `hermes_cli.skills_config.get_disabled_skills()` — a private scanner
+ and a config-mutating module — so T3 shows what this Hermes would actually
+ load, not the full on-disk inventory.
+ - The surface publishes only `name`, `description`, and `category`. There is
+ **no path or install-source field**: `category` is the nearest published
+ analogue and is sent as `source`. The real on-disk path is available only
+ from `skill_view()` per skill, so it is not eagerly fetched.
+ - The list reflects `~/.hermes/skills/` plus configured `skills.external_dirs`,
+ and is served from a 30s in-process cache keyed on a directory-mtime and
+ disabled-set signature. A skill added seconds before a `describe.request`
+ may be one refresh late.
+ - MCP servers are not reported at all. Hermes has no public enumeration
+ surface for them at this commit, and the T3 contract omits the field in v1.
+- Skill bodies are read with `skill_view(name, preprocess=False)`. Preprocessing
+ is disabled deliberately: T3 renders the skill for a human to read, so the
+ literal authored markdown is wanted rather than Hermes' template and
+ inline-shell rendering of it — the latter executes shell fragments embedded in
+ the skill, which must not happen merely because a user expanded a row. Bodies
+ are truncated at 512 KiB. Any failure — unknown name, ambiguous name across
+ `external_dirs`, unreadable file, older Hermes — replies with `markdown: null`
+ rather than an error, so the UI renders "no body available" instead of a
+ protocol failure. The plugin calls `skill_view` directly rather than the
+ registered `_skill_view_with_bump` handler, so a T3 body fetch does **not**
+ bump that skill's view/use counters (`tools/skill_usage.py`) — browsing an
+ agent's skills in T3 must not look like the agent loading one, since
+ `last_used_at` is what Hermes' curator keys its stale-skill timer off.
+- Neither describe frame can fail the connection over a _Hermes_ problem.
+ Every Hermes-sourced read degrades — omitted optional field, empty skill
+ list, or null markdown — so a `describe.request` against an older or
+ partially-broken Hermes yields a thinner reply, never a `protocol.error`.
+ The one exception is a malformed request: `skill.body.request` with no
+ `skillName` cannot be answered, because the response echoes the name back
+ and the wire type is non-empty. That takes the ordinary correlated
+ `protocol.error` path.
+- Attachments are part of protocol v4; the capability is fixed to `true`
+ (T3's schema pins the literal, so a plugin that cannot handle them is a v3
+ plugin and is rejected at the version gate). Inbound, `turn.start` /
+ `turn.steer` may carry inline base64 files (≤25MiB each): turn-start files
+ are written to private temp files and ride `MessageEvent.media_urls` /
+ `media_types` into Hermes' own enrichment pipeline; steer files are
+ appended to the injected `/steer` text as path notes, because Hermes'
+ steer handler injects only text between tool iterations
+ (`gateway/run.py:11254`). Outbound, the adapter overrides
+ `send_image_file` / `send_video` / `send_voice` / `send_document` to emit
+ `media.deliver` frames (raw bytes ≤25MiB, base64 on the wire) with the same
+ durable queue-then-ack lifecycle as `home.deliver`; the
+ `standalone_sender_fn` sends `media_files` the same way, and
+ `force_document` remains signature parity only — T3 derives rendering from
+ `mimeType`, so there is no document/photo distinction to force.
+- **Kind/label classification is heuristic.** `adapter.send()` carries no
+ structured "this is a cron delivery" marker on every path, so the plugin
+ reads what does exist (see "Home-channel delivery" below). A
+ misclassification costs a wrong badge — and, for `lifecycle`, a delivery that
+ raises its hand when it should have landed quietly — never a lost delivery.
+ If upstream ever exposes delivery provenance in metadata, adopt it and
+ replace the heuristics here.
+
+## Turn completion is keyed off `notify`, never `finalize`
+
+**A previous revision of this document blamed `format_tool_event` for the
+early-turn-truncation bug. That diagnosis was wrong.** It is corrected here;
+the real cause and the real completion signal are documented below.
+
+### The signal that ends a turn: `notify=True` on `send`
+
+`_mark_notify_metadata` (`gateway/platforms/base.py:89`) stamps `notify: True`
+onto the metadata of a send, and the gateway applies it **only** for genuine
+user-visible replies:
+
+- the final response delivery (`gateway/platforms/base.py:5220`, consumed at
+ `:5261`, `:5330`, `:5376`, `:5418`, `:5433`-`:5469`),
+- slash-command acknowledgements (`:4827`, `:4934`, `:4987`),
+- and, in the streaming path, `StreamConsumer._metadata_for_send(final=True)`
+ (`gateway/stream_consumer.py:328-329`).
+
+`send(..., metadata={"notify": True})` is therefore the plugin's completion
+boundary, and `_complete_turn` is reached from nowhere else on the output path.
+
+### The signal that does NOT end a turn: `finalize=True` on `edit_message`
+
+`finalize` reads like "last edit of the response", and the base class documents
+it that way (`gateway/platforms/base.py:3176-3183`). It is **not** a turn
+boundary. Two upstream paths set it mid-turn:
+
+1. **The tool-progress loop.** When an adapter declares
+ `REQUIRES_EDIT_FINALIZE`, `_edit_progress_message` passes `finalize=True` on
+ **every** progress-bubble edit (`gateway/run.py:20777-20780`) — once per tool
+ event, for the whole turn. Nothing about that edit is final.
+2. **The stream consumer's segment breaks.** `_send_or_edit` is called with
+ `finalize=(got_done or got_segment_break)`
+ (`gateway/stream_consumer.py:938-940`), so every mid-turn tool/segment
+ boundary finalizes the current content message. This path is
+ **independent of `REQUIRES_EDIT_FINALIZE`** — setting the flag to `False`
+ does not suppress it.
+
+This plugin previously declared `REQUIRES_EDIT_FINALIZE = True` and treated
+`finalize=True` in `edit_message` as "turn finished", calling `_complete_turn`.
+Consequently the **first tool call ended the T3 turn while Hermes was still
+working**: the transcript kept the progress chrome ("📚 Reading skill
+hermes-agent 🔍 Searching the web for …") as the assistant's entire answer, and
+every subsequent send failed with `Send failed: no active T3 turn — trying
+plain-text fallback` in the gateway log. The real answer never arrived.
+
+The fix is twofold, and both halves are needed because of path (2) above:
+
+- `REQUIRES_EDIT_FINALIZE = False` — declaring it only arms path (1). T3 closes
+ an item on `item.completed`, which this plugin emits itself; it has no
+ rich-card streaming state that needs an explicit close.
+- `edit_message` ignores `finalize` outright (`del metadata, finalize`) and
+ never calls `_complete_turn` — this is what defends against path (2).
+
+`test_tool_progress_bubble_edits_never_complete_the_turn` pins both legs:
+it replays the gateway's `_edit_progress_message` closure verbatim and a
+segment-break finalize, asserts the turn survives every one, then asserts a
+single `notify=True` send completes it exactly once.
+
+**Regression shape if upstream changes.** If a future Hermes makes `finalize`
+genuinely mean "turn over" and removes the mid-turn uses, this plugin will
+simply never see a completion via that route — harmless, since `notify` still
+fires. The dangerous direction is the inverse: if `_mark_notify_metadata` stops
+being applied to the final delivery (or the streaming path stops calling
+`_metadata_for_send(final=True)`), turns would **never complete** — T3 threads
+would hang in the running state with the full answer streamed but no
+`turn.completed`. That is the opposite failure mode from the original bug and
+would show up as spinners that never resolve, not truncated answers.
+
+## Home-channel delivery: the gate is provenance, not turn absence
+
+Hermes-initiated output — cron results, `send_message` with a bare `t3` target,
+gateway lifecycle notices, `/handoff t3` — has no T3-issued turn to stream into.
+It is emitted as `home.deliver` against the instance's durable home thread.
+
+### The deadlock this design exists to avoid
+
+The naive rule — "no active turn for this thread → deliver" — is wrong, and
+wrong in the same keyed-off-the-wrong-signal way as the `finalize` bug above.
+When the home thread itself has a live user turn, a cron delivery targeting it
+would fall into the active-turn path, stream as that turn's assistant content,
+and — because final cron deliveries arrive notify-stamped via
+`_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete the user's
+live turn with the cron output as its answer**.
+
+The discriminator is `HERMES_SESSION_KEY`. The gateway binds it onto the turn's
+context for the whole handler (`gateway/run.py:12972` → `:14626`, read via
+`get_session_env`), and every send a turn produces — streamed or final — happens
+inside that scope, so a genuine turn reply resolves to this plugin's
+`build_session_key` id for its thread. Cron runs under its own
+`cron__` session with the gateway routing keys explicitly
+cleared (`cron/scheduler.py:3066-3091`), and lifecycle broadcasts run in no
+session at all.
+
+`_is_proactive_delivery` therefore decides in this order:
+
+1. Session key matches an active turn on this thread → **turn content**, never
+ a delivery. Checked first, so a turn reply can never be rerouted.
+2. Not the home thread → never a delivery. "Message any thread unprompted"
+ stays out of scope and the existing `"no active T3 turn"` error is returned
+ verbatim.
+3. Home thread, no active turn → delivery. There is nothing it could belong to.
+4. Home thread **with** a non-matching active turn → delivery only when
+ provenance is positively established. This is the conservative half: an
+ unattributable send in that window stays with the turn (at worst misplaced
+ inside the same thread) rather than being torn out of a turn it may belong
+ to. So an unclassifiable send can never steal a live answer, and a
+ recognisable cron/lifecycle/handoff delivery never completes one.
+
+Ordering inside `send()` is load-bearing: `_capture_steer_control_response`
+stays first, because steer acknowledgements arrive with `notify=True` and must
+never be read as deliveries.
+
+`edit_message` has **no** proactive branch and keeps returning `"no active T3
+turn"` outside a turn. A delivery is an atomic document, not a streaming
+surface. If an upstream path ever streams a home delivery, revisit with a
+`home.deliver`-supersedes-by-`deliveryId` scheme rather than edit frames.
+
+### What classification keys off
+
+All best-effort, in precedence order, all degrading to
+`("message", "Hermes", uncertain)`:
+
+- `metadata["job_id"]` — the only structured signal. The cron scheduler stamps
+ it into the routed metadata (`cron/scheduler.py:1782`) and
+ `DeliveryRouter._deliver_to_platform` passes the dict through to
+ `adapter.send` unchanged (`gateway/delivery.py:606`).
+- The cron wrap header `Cronjob Response: ` (`cron/scheduler.py:1513`),
+ present whenever `cron.wrap_response` is on (the default), which also
+ supplies the human job name for the badge.
+- Lifecycle literals: `gateway/run.py:17277`, `:17236`, `:6599`. These are
+ inline f-strings upstream, not exported constants, so they carry the same
+ drift risk as the `/sethome` notice and the `⏩ Steer queued` prefix.
+- `HERMES_SESSION_USER_ID == "system:handoff"`, the synthetic source identity
+ `/handoff` dispatches under (`gateway/run.py:8854`, bound at `:17372`).
+
+### Registration contracts
+
+- **`cron_deliver_env_var="T3_HOME_CHANNEL"`.** The name is not free-form.
+ `_home_target_env_var` (`gateway/run.py:1541`) consults built-ins, then the
+ plugin registry via `_resolve_home_env_var` (`cron/scheduler.py:1025`), then
+ falls back to `f"{PLATFORM.upper()}_HOME_CHANNEL"` — exactly this string for
+ platform `t3`. Matching the fallback means `send_message`'s error hints and
+ cron's env-only resolution agree with what the plugin writes, with no
+ upstream override-table entry. Without the flag, `deliver=t3` is silently
+ dropped by cron.
+- **`env_enablement_fn` seeds `home_channel`.** That key is magic: core pops it
+ out of the returned dict and promotes it to a real `HomeChannel` dataclass
+ (`gateway/config.py:2648-2660`, reading only `chat_id` / `name` /
+ `thread_id`). The promotion is what makes `get_home_channel("t3")`
+ (`gateway/config.py:1022`) resolve, which is what makes `send_message`,
+ lifecycle broadcasts, and `/handoff` work — core hardcodes env promotion only
+ for built-ins. T3 threads are the addressing unit, so `chat_id` **is** the
+ thread id and `thread_id` stays unset.
+- **`standalone_sender_fn`.** Out-of-process cron has no live adapter
+ (`tools/send_message_tool.py:741`). The plugin dials T3 itself over a
+ short-lived socket announcing `role: "delivery"`. That role is load-bearing:
+ T3's broker registers a `gateway` connection under generation fencing and
+ displaces its predecessor, so a cron dial-in announcing the default role
+ would kick the live gateway socket off its own instance mid-turn.
+
+### Designation is a synced cache, not local state
+
+`T3_HOME_CHANNEL` is written by the plugin, never by the user. T3's settings
+blob is authoritative and republishes `homeThreadId` on every
+`connection.accepted`; the plugin compares and persists via `save_env_value`
+(the same profile-aware helper enrollment uses) and mirrors into `os.environ`
+so a running gateway needs no restart. A hand-edited value is overwritten on
+the next reconnect — documented in the README. A read-only or managed `.env`
+degrades to the in-process mirror only: routing works for the life of the
+process and re-reconciles on the next connect.
+
+### Queue and state location
+
+The plugin previously persisted nothing to disk. It now keeps one JSONL outbox
+at `/gateway/t3_home_delivery_queue.jsonl`, using
+`get_hermes_home()` (`hermes_constants.py:106`, re-exported at
+`hermes_cli/config.py:686`) as the base — the same accessor and the same
+`gateway/` subdirectory Hermes' own Discord adapter uses for per-profile
+adapter state (`plugins/platforms/discord/adapter.py:52`, `:272`, `:1694`).
+Resolving through that accessor rather than `~/.hermes` makes the queue
+profile-scoped: a second profile cannot replay another profile's deliveries
+into its own home thread.
+
+Correctness rests on one rule: an entry is removed **only** on the matching ack
+(`home.deliver.ack` for text or `media.deliver.ack` for media). Everything else
+— a socket that dropped mid-send, a server
+that died before writing, a plugin restart — leaves the entry to be replayed,
+which is safe because T3 dedupes on `deliveryId`. Acking before the durable
+write on the server side would break this. The queue is capped at 300 entries
+and 256MiB total, dropping oldest-first with a logged warning; retention beyond
+those bounds is therefore not guaranteed. One flush replays
+at most 50 entries or 100MiB so a reconnect does not stall live traffic.
+
+### Cron tool-hook misattribution
+
+`_turn_for_tool_hook`'s sole-active-turn fallback is now skipped for cron runs.
+The hooks are process-global, so a cron job running tools while exactly one T3
+turn happens to be live would resolve through that fallback and paint the cron
+job's tool calls into an unrelated live conversation. Cron runs are identifiable
+by the `cron__` session id the scheduler mints
+(`cron/scheduler.py:3017`, passed to the agent at `:3484`) — the exact value the
+hooks receive. Upstream treats the same routing hazard as real, clearing the
+process-global session env vars for it (`cron/scheduler.py:3066-3091`). A cron
+job's activity belongs to the eventual `home.deliver`, never to a live turn.
+
+Prefix matching carries the usual drift risk: if upstream renames the shape,
+this degrades to the previous behaviour (cron tool rows may again be
+misattributed to a sole live turn) rather than breaking anything.
+
+## Tool-progress chrome: the `format_tool_event` override is not the defence
+
+The plugin overrides `format_tool_event` to return `None`
+(`gateway/platforms/base.py:2740`), which `gateway/stream_dispatch.py:108`
+documents as "adapter chose to eat this event". T3 already renders tool calls as
+typed `item.started` / `item.completed` activity from the `pre_tool_call` /
+`post_tool_call` hooks, so the text line is a strictly poorer duplicate.
+
+**At 62e07223 this hook is dead code on the live path.** Its only caller is
+`GatewayEventDispatcher` (`gateway/stream_dispatch.py:40`, dispatch at `:108`),
+and that class is referenced nowhere in the shipped gateway — only from
+`tests/gateway/test_stream_events.py`. The path that actually runs is
+`gateway/run.py:20485+`, which builds the same emoji lines itself and delivers
+them via `adapter.send` / `adapter.edit_message`, with **no adapter hook to
+suppress them**. Chrome visibility there is governed by the platform's
+`tool_progress` display setting (`gateway/display_config.py:187`), not by this
+override.
+
+The override is kept as documented-contract defence: it costs nothing and
+becomes load-bearing again if upstream routes chrome through the dispatcher. But
+it never protected the turn — ignoring `finalize` does.
diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md
new file mode 100644
index 000000000000..cb59b31eb634
--- /dev/null
+++ b/integrations/hermes-t3-gateway/README.md
@@ -0,0 +1,208 @@
+# Hermes T3 Code Gateway
+
+Optional companion for connecting one already-running Hermes process to T3
+Code. It makes an outbound WebSocket connection; Hermes does not listen on a
+public port.
+
+## ACP versus companion boundary
+
+Ordinary interactive conversations use T3's built-in **`hermes-acp`** provider.
+This companion only handles enrollment and proactive Home delivery (cron,
+`send_message`, lifecycle notices, media, and handoff). Current Hermes releases
+call the plugin's public `BasePlatformAdapter.create_handoff_thread` callback;
+T3 creates a dedicated thread and the companion delivers Hermes' synthetic
+handoff response there. Gateway `turn.start` and `turn.steer` commands receive a
+recoverable error and never invoke Hermes. The platform callbacks remain
+implemented because public Hermes delivery APIs use the adapter; they are not
+an alternative interactive runtime.
+
+The gateway wire protocol is v4. The T3 server and Hermes plugin must be updated
+together; mismatched versions fail the connection handshake closed.
+
+The T3 server owns the companion's Home and handoff-delivery threads.
+`hermes-acp` independently owns interactive thread and session identity. In
+particular, a reply typed in T3 after a handoff is an ordinary ACP turn; it does
+not travel back over the companion socket or mutate the CLI session Hermes
+handed to its gateway. The handoff's companion-routed operation is thread
+creation plus Hermes' synthetic transfer/summary delivery.
+
+## Install from this repository
+
+Run the install script. It symlinks this directory into the active Hermes
+profile's user-plugin directory and enables the plugin:
+
+```bash
+./integrations/hermes-t3-gateway/install.sh
+```
+
+The script is safe to re-run: an existing correct symlink is left in place, and
+enabling an already-enabled plugin is a no-op. It installs into
+`$HERMES_HOME/plugins/` when `HERMES_HOME` is set, and `~/.hermes/plugins/`
+otherwise. It fails with instructions if `hermes` is not on `PATH`, and refuses
+to replace a real directory already sitting at the target path.
+
+In T3 Code, add or open a Hermes provider instance, expand its **Hermes
+companion** section, create a one-time enrollment, and copy the generated
+command. It has this shape:
+
+```bash
+hermes t3 connect \
+ --url https://t3.example.com \
+ --token
+```
+
+`--url` accepts an HTTP(S) browser origin or an explicit WS(S) URL. The command
+normalizes it to `/api/hermes-gateway/ws`, enrolls over the first authenticated
+`connection.hello` frame, and saves these values with Hermes'
+profile-aware `save_env_value` helper:
+
+Use **HTTPS/WSS for every connection that leaves the local machine**. The
+one-time enrollment token and long-lived instance credential authenticate the
+companion and must not cross an untrusted network over cleartext HTTP/WS.
+Plain HTTP/WS is intended only for loopback development or a separately secured
+private tunnel.
+
+- `HERMES_T3_GATEWAY_URL`
+- `HERMES_T3_GATEWAY_INSTANCE_ID`
+- `HERMES_T3_GATEWAY_CREDENTIAL`
+- `HERMES_T3_GATEWAY_NICKNAME`
+
+The long-lived credential is never printed. Run `hermes gateway restart` after
+enrollment. `hermes t3 status` reports the local enrollment without revealing
+the credential.
+
+The handshake also reports Hermes' configured default model so T3 can show a
+truthful label in its picker. It is read-only — Hermes owns model selection —
+and is omitted entirely if it cannot be read.
+
+## The Home thread
+
+Every enrolled instance gets one **Home** thread in T3, created automatically —
+there is nothing to set up and nothing to choose. It receives all of Hermes'
+proactive output: cron results (`deliver=t3`), the agent's `send_message` tool
+with a bare `t3` target, gateway online/shutdown notices, and `/handoff t3`.
+Use a `hermes-acp` thread to converse with Hermes; Home is a delivery inbox.
+
+On Hermes versions exposing the documented
+`create_handoff_thread(parent_chat_id, name)` callback, `/handoff t3` asks T3 to
+create a fresh thread under the same synthetic agent project and sends the
+handoff summary there. T3 accepts the request only when `parent_chat_id` is the
+instance's authoritative Home thread, and accepts subsequent handoff delivery
+only for a thread owned by that instance's agent project. Duplicate creation
+requests resolve to the same deterministic thread. If the connection drops,
+the request times out, or an older Hermes/T3 peer lacks the additive callback,
+the plugin returns the official `None` fallback and Hermes delivers to Home
+instead; no handoff watcher is left waiting.
+
+**T3 owns the designation, and `T3_HOME_CHANNEL` is a synced cache of it.** The
+plugin writes that variable itself: T3 republishes the home thread id on every
+successful handshake, and the plugin compares and persists it with Hermes'
+profile-aware `save_env_value` helper. A hand-edited `T3_HOME_CHANNEL` will
+therefore be **overwritten on the next reconnect** — to move the Home thread,
+change it in T3, not in `.env`. (`/sethome` is likewise inert for this platform:
+the designation is fixed, so Hermes' "set a home channel" nudge is suppressed.)
+
+Deliveries use a bounded durable outbox. Each one is written to a JSONL
+queue at `/gateway/t3_home_delivery_queue.jsonl` before it is sent
+and removed only once T3 acknowledges its matching frame type
+(`home.deliver.ack` for text, `media.deliver.ack` for media). Queued deliveries
+survive restarts and flush on the next connect, and T3 deduplicates replays.
+The queue drops oldest-first with a logged warning at its bounds, so retention
+beyond those bounds is not guaranteed. The defaults are
+300 entries and 256MiB total; one reconnect flushes at most 50 entries or
+100MiB so backlog replay cannot starve liveness traffic.
+
+Cron works whether or not the gateway is co-resident. When `hermes cron` runs in
+its own process there is no live adapter, so the plugin dials T3 over a
+short-lived delivery connection — authenticated the same way, but never
+registered as the instance's primary connection, so it cannot disturb a running
+`hermes gateway`. If T3 is unreachable the delivery is queued and the cron job
+still reports success.
+
+Attachments ride the same queue-then-ack durability as text: one `media.deliver`
+frame per file, each carrying the file's bytes rather than its path, so a
+delivery that flushes after an outage still works when the original temp file is
+long gone. The raw ceiling is 25MiB per file. A file that cannot be read or that
+exceeds the ceiling is reported in the result's `detail` and skipped rather than
+queued — a frame T3 would reject forever must not sit in the outbox forever.
+Every successful send result also reports `media_count`, `acked_count`, and
+`delivery_ids`.
+
+## Companion scope
+
+- Reconnect with bounded backoff
+- Version-incompatible and revoked credentials fail closed
+- Proactive delivery into the Home thread: cron, `send_message`, lifecycle
+ notices — with a durable queue and out-of-process cron support
+- `/handoff` thread creation through the official platform callback, with
+ correlated timeout/reconnect cleanup and deterministic server idempotency
+- Outbound attachments: `MEDIA:` files from cron, `send_message`, and `/handoff`
+ are delivered as `media.deliver` frames
+
+MIME-typed attachments on ordinary interactive prompts belong to the separate
+ACP transport, not this companion socket.
+
+Except for a server-created handoff destination, non-Home T3 threads remain
+session-only: Hermes cannot message them unprompted, and an unsolicited send to
+one still fails with `no active T3 turn`.
+
+Attachments are pinned to `true`. It is part of the v4 contract rather than a
+negotiated option — T3's schema fixes the capability at that literal, so a plugin
+that cannot handle attachments is by definition a v3 plugin and is rejected at
+the version gate. The retained gateway turn validators materialize inbound
+files privately for API compatibility, but T3 does not issue interactive turn
+frames to the companion. Outbound companion files leave as `media.deliver`
+frames.
+
+## Upstream core bugs this plugin works around
+
+Hermes core decides media support for `send_message` from a hard-coded list of
+platform names rather than from a platform capability, so a plugin platform that
+delivers media perfectly well is still treated as if it cannot. Two consequences,
+both against **v0.19.0**:
+
+- **A false warning.** `tools/send_message_tool.py:1108` builds `"MEDIA
+attachments were omitted for t3; ..."` whenever a send carries files and the
+ platform is off that list, and line 1154 appends it to _any_ successful result
+ without checking whether anything was actually dropped. Left alone, the tool
+ output tells the agent the files were lost immediately after T3 acknowledged
+ them — which is exactly how a live agent came to report a delivery failure for
+ files the user could already see.
+- **A silent drop.** `tools/send_message_tool.py:711`, taken when the gateway is
+ co-resident with the caller, invokes `adapter.send(chat_id, content, metadata)`
+ and returns. `media_files` is never passed, and the `MEDIA:` directives were
+ already stripped out of `content` upstream at line 442, so the attachments are
+ simply gone — no error, no warning. Out-of-process sends escape this only
+ because they fall through to the plugin's standalone sender instead.
+
+`coreshim.py` compensates for both in-process at plugin load: co-resident `t3`
+sends carrying media are rerouted through the plugin's own sender, media-only
+sends are rescued from the related hard error at line 1101, and the false warning
+is stripped by stable prefix. Everything else — every other platform, every
+text-only send — reaches the original untouched.
+
+**Residual caveat.** The shim is deliberately fail-open: it feature-detects each
+target function and, on any signature or shape mismatch, logs one warning and
+leaves core alone rather than risking a crash on an upstream upgrade. When that
+happens the two bugs return as described above. The accounting keys on every
+send result (`media_count`, `acked_count`, and a note naming the delivered file
+count) are the backstop — they sit in the same JSON as any stale warning and
+contradict it directly. Grep the logs for `leaving it unpatched` to detect it.
+The whole module is removable once upstream drives media handling from platform
+capabilities instead of the hard-coded list.
+
+See [COMPATIBILITY.md](./COMPATIBILITY.md) for public Hermes extension-surface
+limitations.
+
+## Tests
+
+The pure protocol and transport tests do not require a live Hermes or T3 server:
+
+```bash
+python -m unittest discover \
+ integrations/hermes-t3-gateway/tests \
+ -p 'test_*.py'
+
+python -m ruff check integrations/hermes-t3-gateway
+sh -n integrations/hermes-t3-gateway/install.sh
+```
diff --git a/integrations/hermes-t3-gateway/__init__.py b/integrations/hermes-t3-gateway/__init__.py
new file mode 100644
index 000000000000..c8a5dac7356b
--- /dev/null
+++ b/integrations/hermes-t3-gateway/__init__.py
@@ -0,0 +1,105 @@
+"""T3 Code gateway plugin registration for Hermes Agent."""
+# ruff: noqa: N999 - Hermes loads hyphenated plugin directories dynamically.
+
+from __future__ import annotations
+
+from .adapter import (
+ T3PlatformAdapter,
+ check_requirements,
+ env_enablement,
+ validate_config,
+)
+from .cli import register_cli, t3_command
+from .coreshim import apply as apply_core_shim
+from .home import HOME_CHANNEL_ENV, standalone_send
+
+
+def _pre_tool_call(
+ tool_name: str,
+ args: dict,
+ task_id: str,
+ **kwargs,
+) -> None:
+ session_id = str(kwargs.get("session_id") or task_id)
+ tool_call_id = str(kwargs.get("tool_call_id") or "")
+ T3PlatformAdapter.route_tool_started(tool_name, args, session_id, tool_call_id)
+
+
+def _post_tool_call(
+ tool_name: str,
+ args: dict,
+ result: str,
+ task_id: str,
+ duration_ms: int | None = None,
+ **kwargs,
+) -> None:
+ del args
+ session_id = str(kwargs.get("session_id") or task_id)
+ tool_call_id = str(kwargs.get("tool_call_id") or "")
+ status = str(kwargs.get("status") or "")
+ T3PlatformAdapter.route_tool_completed(
+ tool_name, result, session_id, duration_ms, tool_call_id, status
+ )
+
+
+def register(ctx) -> None:
+ ctx.register_platform(
+ name="t3",
+ label="T3 Code",
+ adapter_factory=lambda config: T3PlatformAdapter(config),
+ check_fn=check_requirements,
+ validate_config=validate_config,
+ required_env=[
+ "HERMES_T3_GATEWAY_URL",
+ "HERMES_T3_GATEWAY_INSTANCE_ID",
+ "HERMES_T3_GATEWAY_CREDENTIAL",
+ ],
+ env_enablement_fn=env_enablement,
+ # Cron home-channel delivery. The name is not free-form: Hermes
+ # resolves a platform's cron home target through `_home_target_env_var`
+ # (`gateway/run.py:1541`), which falls back to
+ # f"{PLATFORM.upper()}_HOME_CHANNEL" for any platform without a
+ # built-in override entry — exactly this string for platform `t3`. So
+ # `send_message`'s error hints, `/sethome` messaging, and cron's
+ # env-only resolution all agree with the value the plugin writes, with
+ # no upstream override table entry. Without this, `deliver=t3` is
+ # silently dropped by cron.
+ cron_deliver_env_var=HOME_CHANNEL_ENV,
+ # Out-of-process cron delivery: when `hermes cron` runs in a separate
+ # process from `hermes gateway` there is no live adapter, and without
+ # this hook `deliver=t3` fails with "No live adapter for platform".
+ # Dials T3 over a short-lived `role: "delivery"` socket so it cannot
+ # displace the live gateway connection.
+ standalone_sender_fn=standalone_send,
+ max_message_length=120_000,
+ emoji="🔺",
+ pii_safe=True,
+ platform_hint=(
+ "You are chatting through T3 Code. Preserve normal Hermes behavior; "
+ "T3 renders streamed text, tool activity, approvals, and questions."
+ ),
+ )
+ ctx.register_cli_command(
+ name="t3",
+ help="Pair and inspect the T3 Code gateway",
+ setup_fn=register_cli,
+ handler_fn=t3_command,
+ description=(
+ "Connect this Hermes process to a named T3 Code provider instance."
+ ),
+ )
+ ctx.register_hook("pre_tool_call", _pre_tool_call)
+ ctx.register_hook("post_tool_call", _post_tool_call)
+ # Compensate two upstream `send_message` media defects in-process (see
+ # `coreshim.py` for the file:line analysis). Applied after the platform is
+ # registered because the Bug B wrapper routes through `standalone_send`,
+ # which resolves the same enrollment the entry above advertises. This runs
+ # in every process that loads plugins — `hermes gateway`, `hermes cron`, and
+ # the `hermes send` CLI, which reaches `register()` via
+ # `tools/send_message_tool.py:399` -> `gateway/config.py:2530` well before
+ # it routes a send. Never raises: on any mismatch it logs one warning and
+ # leaves core untouched.
+ apply_core_shim()
+
+
+__all__ = ["register"]
diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py
new file mode 100644
index 000000000000..db4db749857d
--- /dev/null
+++ b/integrations/hermes-t3-gateway/adapter.py
@@ -0,0 +1,2011 @@
+"""Hermes platform adapter that treats each T3 thread as one Hermes session."""
+
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import json
+import logging
+import os
+import re
+import tempfile
+import time
+import uuid
+import weakref
+from collections.abc import Coroutine
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+)
+from gateway.session import build_session_key
+
+from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV
+from .connection import T3GatewayConnection, dependency_available
+from .home import (
+ HOME_CHANNEL_ENV,
+ MAX_FLUSH_BYTES_PER_CONNECT,
+ MAX_FLUSH_PER_CONNECT,
+ HomeDeliveryQueue,
+ build_delivery,
+ build_media_delivery,
+ classify_delivery,
+ home_thread_id,
+ save_home_thread_id,
+)
+from .protocol import (
+ PROTOCOL_VERSION,
+ canonical_tool_data,
+ canonical_tool_item_type,
+ describe_response,
+ frame,
+ iso_now,
+ item_id,
+ protocol_error,
+ skill_body,
+ skill_body_response,
+ turn_attachments,
+ validate_server_frame,
+)
+
+logger = logging.getLogger(__name__)
+
+_T3_HOME_CHANNEL_NOTICE = (
+ "📬 No home channel is set for T3. "
+ "A home channel is where Hermes delivers cron job results "
+ "and cross-platform messages.\n\n"
+ "Type /sethome to make this chat your home channel, or ignore to skip."
+)
+
+# T3's canonical item type for a free-form provider status line. Deliberately
+# not `unknown`: that value is the "could not classify this" sentinel other
+# adapters rely on being inert, so routing status text through it made stray
+# activity rows appear in unrelated provider threads. T3 renders these rows
+# preferring `detail` over `title`, so the live status string is sent as
+# `detail`.
+_STATUS_ITEM_TYPE = "status_text"
+
+# How long a just-completed turn stays an acceptable scope for its own media.
+#
+# The window exists because the base adapter's delivery pipeline sends a
+# reply's final TEXT before the reply's media files, and that text is
+# notify-marked — so it completes the T3 turn, and every file of the same
+# reply then arrives against a thread with no active turn
+# (`gateway/platforms/base.py:5326` text, then `:5373+`/`:5424+` media).
+#
+# Sized against what actually separates the two: the live repro measured 36ms,
+# and the only deliberate spacing upstream inserts is `_get_human_delay()`
+# (`gateway/platforms/base.py:5051`), whose widest configured mode is 2.5s per
+# file. 30s covers a slow batch of large files with generous headroom while
+# staying far below any plausible human follow-up: the window closes long
+# before the user could read the answer and ask something new, and it is a
+# *scope* window only — it never keeps a turn alive or re-completes one.
+_RECENT_TURN_MEDIA_WINDOW_SECONDS = 30.0
+
+# `create_handoff_thread` is called inline by Hermes' handoff watcher. Bound
+# the correlated request below the server's own 30s request timeout so a lost
+# response cannot park that watcher forever; returning None is the documented
+# BasePlatformAdapter fallback to the configured parent chat.
+_HANDOFF_CREATE_TIMEOUT_SECONDS = 20.0
+
+
+def _hermes_version() -> str:
+ try:
+ from hermes_cli import __version__
+
+ return str(__version__)
+ except Exception: # noqa: BLE001 - version discovery must not block loading
+ return "unknown"
+
+
+# Characters allowed to survive from a client-supplied filename into a temp
+# file name. Everything else is dropped: the name arrived over the wire and
+# must never influence the directory the file lands in.
+_ATTACHMENT_NAME_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
+
+
+def _materialize_attachments(
+ attachments: list[dict[str, Any]],
+) -> tuple[list[str], list[str]]:
+ """Write inbound turn attachments to private temp files.
+
+ Returns `(paths, mime_types)` aligned by index — the exact shape
+ `MessageEvent.media_urls` / `media_types` expect.
+
+ Each turn gets its own `mkdtemp` directory (mode 0700) and each file is
+ created with `mkstemp` (mode 0600), so nothing is readable by other users
+ even mid-write. The extension is preserved from the wire `name` — after
+ sanitizing, because that name is client-supplied — since Hermes routes
+ files by suffix in several places (`should_send_media_as_audio`, the
+ text-document allowlist). The files are deliberately not deleted here:
+ Hermes reads them asynchronously during the turn (vision, STT, terminal
+ tools), there is no turn-end hook on this surface, and the OS tmp reaper
+ is the documented cleanup — the same pre-existing no-GC stance as T3's
+ attachment store.
+ """
+ if not attachments:
+ return [], []
+ directory = tempfile.mkdtemp(prefix="hermes-t3-attachments-")
+ paths: list[str] = []
+ mime_types: list[str] = []
+ for attachment in attachments:
+ wire_name = Path(str(attachment["name"])).name # strip any path parts
+ stem = _ATTACHMENT_NAME_SAFE_RE.sub("_", Path(wire_name).stem)[:48]
+ suffix = _ATTACHMENT_NAME_SAFE_RE.sub("", Path(wire_name).suffix)[:16]
+ if suffix and not suffix.startswith("."):
+ suffix = f".{suffix}"
+ if suffix == ".":
+ suffix = ""
+ handle, path = tempfile.mkstemp(
+ prefix=f"{stem or 'attachment'}-",
+ suffix=suffix,
+ dir=directory,
+ )
+ with os.fdopen(handle, "wb") as stream:
+ stream.write(attachment["data"])
+ paths.append(path)
+ mime_types.append(str(attachment["mimeType"]))
+ return paths, mime_types
+
+
+@dataclass
+class _TurnState:
+ thread_id: str
+ session_id: str
+ turn_id: str
+ request_id: str
+ message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ visible_text: str = ""
+ assistant_started: bool = False
+ tool_items: dict[str, str] = field(default_factory=dict)
+ generic_activity_id: str | None = None
+ generic_activity_detail: str | None = None
+ generic_activity_lock: asyncio.Lock = field(
+ default_factory=asyncio.Lock,
+ repr=False,
+ )
+ # Monotonic clock reading taken when this turn completed; None while live.
+ # Read only by `_media_turn_scope` to bound how long the completed turn
+ # remains an acceptable scope for its own trailing media
+ # (`_RECENT_TURN_MEDIA_WINDOW_SECONDS`). Monotonic deliberately: a wall
+ # clock adjustment mid-turn must not widen or collapse the window.
+ completed_at: float | None = None
+
+
+@dataclass
+class _SteerControlResponse:
+ thread_id: str
+ request_id: str
+ messages: list[str] = field(default_factory=list)
+
+ @property
+ def control_message_id(self) -> str:
+ """Synthetic id returned for captured control traffic.
+
+ `edit_message` correlates against this so a later edit of the control
+ acknowledgement is captured too, while genuine assistant edits (which
+ carry the stream's own message id) pass straight through.
+ """
+ return f"t3-steer-control-{self.request_id}"
+
+
+_steer_control_response = contextvars.ContextVar[_SteerControlResponse | None](
+ "hermes_t3_steer_control_response",
+ default=None,
+)
+
+
+class T3PlatformAdapter(BasePlatformAdapter):
+ """Companion delivery adapter; interactive turns are owned by hermes-acp."""
+
+ supports_code_blocks = True
+ supports_status_text = True
+ # Deliberately NOT set. It exists for rich-card surfaces that must be told
+ # when to leave the streaming state; T3 closes an item on `item.completed`,
+ # which this plugin emits itself. Declaring it only makes the gateway's
+ # progress loop pass `finalize=True` on every progress edit
+ # (`gateway/run.py:20777-20780`) — a signal we must ignore anyway.
+ REQUIRES_EDIT_FINALIZE = False
+ MAX_MESSAGE_LENGTH = 120_000
+ _instances: weakref.WeakSet[T3PlatformAdapter] = weakref.WeakSet()
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform("t3"))
+ extra = config.extra or {}
+ self._url = str(extra.get("url") or os.environ.get(URL_ENV, "")).strip()
+ self._instance_id = str(
+ extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "")
+ ).strip()
+ self._credential = str(
+ extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "")
+ ).strip()
+ self._nickname = str(
+ extra.get("nickname") or os.environ.get(NICKNAME_ENV, "") or "Hermes"
+ ).strip()
+ self._connection: T3GatewayConnection | None = None
+ self._event_loop: asyncio.AbstractEventLoop | None = None
+ self._sessions: dict[str, str] = {}
+ self._active_session_threads: set[str] = set()
+ self._thread_by_session: dict[str, str] = {}
+ self._active_turns: dict[str, _TurnState] = {}
+ # The most recently COMPLETED turn per thread. The base adapter's
+ # delivery pipeline sends the final text (notify-marked, which
+ # completes the turn here) BEFORE it sends the reply's media files
+ # (`gateway/platforms/base.py:5326` then `:5383+`), so a turn reply's
+ # media routinely arrives moments after its turn closed. This record
+ # lets that media still be delivered turn-scoped instead of erroring
+ # with "no active T3 turn".
+ self._recent_turns: dict[str, _TurnState] = {}
+ self._approval_requests: dict[str, tuple[str, str]] = {}
+ self._user_input_requests: dict[str, tuple[str, str]] = {}
+ self._pending_handoff_creates: dict[str, asyncio.Future[str | None]] = {}
+ self._home_queue = HomeDeliveryQueue()
+ # Strong references to fire-and-forget tasks. asyncio only holds a weak
+ # reference to a running task, so without this the GC may collect one
+ # mid-flight and its exception surfaces as a bare warning.
+ self._scheduled_tasks: set[asyncio.Task[Any]] = set()
+ # Keep public BasePlatformAdapter callbacks for Hermes compatibility,
+ # but never let the companion socket start agent work. Interactive T3
+ # conversations are exclusively the hermes-acp provider's concern.
+ self._gateway_interactive_turns_enabled = False
+ type(self)._instances.add(self)
+
+ @property
+ def name(self) -> str:
+ return f"T3 Code ({self._nickname})"
+
+ @property
+ def authorization_is_upstream(self) -> bool:
+ # The only source of inbound messages is T3's instance-authenticated
+ # socket. There is no separate Hermes-side user allowlist.
+ return True
+
+ async def connect(self, *, is_reconnect: bool = False) -> bool:
+ del is_reconnect
+ if not (self._url and self._instance_id and self._credential):
+ self._set_fatal_error(
+ "t3_not_enrolled",
+ "Run `hermes t3 connect --url --token ` first.",
+ retryable=False,
+ )
+ return False
+ self._event_loop = asyncio.get_running_loop()
+ self._connection = T3GatewayConnection(
+ url=self._url,
+ instance_id=self._instance_id,
+ credential=self._credential,
+ hermes_version=_hermes_version(),
+ on_message=self._handle_server_frame,
+ on_state=self._handle_connection_state,
+ on_accepted=self._handle_connection_accepted,
+ )
+ try:
+ connected = await self._connection.connect()
+ except Exception as exc: # noqa: BLE001 - transport supplies typed rejection details
+ self._set_fatal_error("t3_connection_rejected", str(exc), retryable=False)
+ return False
+ if connected:
+ self._mark_connected()
+ await self._send_status()
+ return connected
+
+ async def disconnect(self) -> None:
+ self._settle_pending_handoffs()
+ if self._connection is not None:
+ await self._connection.disconnect()
+ self._connection = None
+ self._mark_disconnected()
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ ) -> SendResult:
+ # `reply_to` is the base adapter's reply anchor. For the inline
+ # slash-command path it is `_reply_anchor_for_event(event)`, which for
+ # this platform resolves to the dispatched MessageEvent's `message_id`
+ # — the steering requestId. That is the only correlation identifier
+ # `send` receives, so it is the capture discriminator here.
+ captured = self._capture_steer_control_response(chat_id, content, reply_to)
+ if captured is not None:
+ return captured
+ thread_id = str(chat_id)
+ turn = self._active_turns.get(thread_id)
+ if self._is_proactive_delivery(thread_id, turn, content, metadata):
+ return await self._deliver_to_home(thread_id, content, metadata)
+ if turn is None:
+ return SendResult(success=False, error="no active T3 turn")
+ try:
+ if content == _T3_HOME_CHANNEL_NOTICE:
+ if bool((metadata or {}).get("notify")):
+ await self._complete_turn(turn)
+ return SendResult(success=True, message_id=turn.message_id)
+ await self._emit_assistant_content(turn, content)
+ if bool((metadata or {}).get("notify")):
+ await self._complete_turn(turn)
+ return SendResult(success=True, message_id=turn.message_id)
+ except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
+ return SendResult(success=False, error=str(exc))
+
+ async def edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ content: str,
+ *,
+ finalize: bool = False,
+ metadata: dict[str, Any] | None = None,
+ ) -> SendResult:
+ # `finalize` is deliberately ignored as a completion signal.
+ #
+ # It reads like "this is the final edit of the response", and that is
+ # what the base class documents it as — but the gateway's tool-progress
+ # loop sets it unconditionally on EVERY progress-bubble edit whenever
+ # the adapter declares `REQUIRES_EDIT_FINALIZE`
+ # (`gateway/run.py:20777-20780`). Treating it as "turn finished" ended
+ # the turn on the first tool call; every later send then failed with
+ # "no active T3 turn" and the real answer was dropped.
+ #
+ # `notify=True` on `send()` is the signal that actually means "the
+ # user-visible reply is delivered": the gateway applies it via
+ # `_mark_notify_metadata` (`gateway/platforms/base.py:89`) only on
+ # final replies, and the progress path never sets it (verified across
+ # every `adapter.send`/`edit_message` call in the progress loop).
+ del metadata, finalize
+ # `edit_message` never carries the reply anchor; its correlation
+ # identifier is the id of the message being edited. Only an edit of a
+ # message this adapter already reported as captured control traffic is
+ # control traffic itself.
+ captured = self._capture_steer_control_response(chat_id, content, message_id)
+ if captured is not None:
+ return captured
+ turn = self._active_turns.get(str(chat_id))
+ if turn is None:
+ return SendResult(success=False, error="no active T3 turn")
+ try:
+ if content == _T3_HOME_CHANNEL_NOTICE:
+ return SendResult(success=True, message_id=message_id)
+ await self._emit_assistant_content(turn, content)
+ return SendResult(success=True, message_id=message_id)
+ except Exception as exc: # noqa: BLE001 - adapter edit must return SendResult
+ return SendResult(success=False, error=str(exc))
+
+ async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
+ return {"name": f"T3 thread {chat_id}", "type": "dm"}
+
+ async def create_handoff_thread(
+ self,
+ parent_chat_id: str,
+ name: str,
+ ) -> str | None:
+ """Create a T3 destination through Hermes' public handoff callback.
+
+ Current Hermes calls this with its configured T3 Home chat as the
+ parent, then places the returned id in ordinary ``thread_id`` send
+ metadata. T3 validates that parent against the enrolled instance's
+ authoritative Home designation and creates the child in that
+ instance's synthetic agent project.
+
+ ``None`` is the official fallback contract. It is returned while
+ offline, on timeout, or when an older T3 server rejects the additive
+ frame, allowing Hermes to deliver the handoff notice to Home instead
+ of deadlocking its watcher.
+ """
+ parent = str(parent_chat_id or "").strip()
+ if not parent:
+ logger.warning("T3 handoff has no parent Home thread")
+ return None
+ connection = self._connection
+ if connection is None or not connection.connected:
+ logger.warning("T3 handoff thread creation skipped while the gateway is offline")
+ return None
+
+ correlation_id = str(uuid.uuid4())
+ pending = asyncio.get_running_loop().create_future()
+ self._pending_handoff_creates[correlation_id] = pending
+ try:
+ await self._send_frame(
+ frame(
+ "handoff.create",
+ requestId=correlation_id,
+ parentThreadId=parent,
+ name=(str(name or "").strip() or "Hermes handoff")[:200],
+ )
+ )
+ return await asyncio.wait_for(
+ pending,
+ timeout=_HANDOFF_CREATE_TIMEOUT_SECONDS,
+ )
+ except TimeoutError:
+ logger.warning("T3 did not answer handoff thread creation in time")
+ return None
+ except Exception as exc: # noqa: BLE001 - None is the public fallback contract
+ logger.warning("T3 handoff thread creation failed: %s", exc)
+ return None
+ finally:
+ current = self._pending_handoff_creates.get(correlation_id)
+ if current is pending:
+ self._pending_handoff_creates.pop(correlation_id, None)
+
+ def _settle_pending_handoffs(self) -> None:
+ """Release all handoff waiters when their transport generation dies."""
+ pending, self._pending_handoff_creates = self._pending_handoff_creates, {}
+ for waiter in pending.values():
+ if not waiter.done():
+ waiter.set_result(None)
+
+ def _resolve_handoff_create(self, message: dict[str, Any]) -> None:
+ """Resolve one response; duplicate and late frames are inert."""
+ request_id_value = str(message.get("requestId") or "").strip()
+ pending = self._pending_handoff_creates.pop(request_id_value, None)
+ if pending is None or pending.done():
+ logger.debug("Ignoring duplicate or late T3 handoff response %s", request_id_value)
+ return
+ thread_id = str(message.get("threadId") or "").strip()
+ pending.set_result(thread_id or None)
+
+ # ── proactive home delivery ────────────────────────────────────────
+
+ def _is_proactive_delivery(
+ self,
+ thread_id: str,
+ turn: _TurnState | None,
+ content: str,
+ metadata: dict[str, Any] | None,
+ ) -> bool:
+ """Decide whether this send is Hermes-initiated home delivery.
+
+ **The gate is provenance, not turn absence.** The naive rule ("no
+ active turn for this thread → deliver") deadlocks against the
+ notify-completion contract the moment the home thread has a live turn:
+ a cron result or `send_message` targeting the home chat mid-conversation
+ would take the active-turn path, stream as that turn's assistant
+ content, and — because final cron deliveries arrive notify-stamped via
+ `_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete
+ the user's live turn with the cron output as its answer.** That is the
+ same keyed-off-the-wrong-signal defect class as the `finalize` bug.
+
+ The discriminator is `_gateway_session_key()`: the gateway binds
+ `HERMES_SESSION_KEY` onto the turn's context for the whole handler
+ (`gateway/run.py:12972` → `:14626`), and every send a turn produces —
+ streamed or final — happens inside that scope. A genuine turn reply
+ therefore resolves to this plugin's `build_session_key` id for its
+ thread. Cron runs in its own `cron_*` session with the gateway keys
+ explicitly cleared (`cron/scheduler.py:3066-3091`), and lifecycle
+ broadcasts run in no session at all, so neither resolves to the live
+ turn's key.
+
+ The rules, in order:
+
+ * A send whose session key matches an active turn on this thread is
+ that turn's own output. Never a delivery — checked first so a turn
+ reply can never be rerouted.
+ * A send to a non-home thread is never a delivery: "message any thread
+ unprompted" is deliberately out of scope, and the existing
+ `"no active T3 turn"` error stays verbatim for it.
+ * On the home thread with no active turn, any send is a delivery.
+ There is nothing it could belong to.
+ * On the home thread **with** an active turn whose session key does not
+ match, provenance must be positively established (`classify_delivery`
+ returning certain) before the send bypasses the turn. This is the
+ conservative half of the gate: an unattributable send in that window
+ falls through to the turn path — a possible misplacement inside the
+ same thread — rather than being torn out of a turn it may belong to.
+ An unclassifiable send can therefore never steal a live answer, and a
+ recognisable cron/lifecycle/handoff delivery never completes one.
+ """
+ if turn is not None and self._gateway_session_key() == turn.session_id:
+ return False
+ home = home_thread_id()
+ if not home or thread_id != home:
+ return False
+ if turn is None:
+ return True
+ _kind, _label, certain = classify_delivery(
+ content,
+ metadata,
+ session_user_id=self._session_user_id(),
+ )
+ return certain
+
+ async def _deliver_to_home(
+ self,
+ thread_id: str,
+ content: str,
+ metadata: dict[str, Any] | None,
+ ) -> SendResult:
+ """Emit one `home.deliver`, queueing it until T3 acknowledges it.
+
+ Deliberately touches none of the turn machinery. `_active_turns` is not
+ read or written, no turn/item frame is emitted, and `notify` — which
+ arrives True on every final cron delivery — is consumed only as a
+ classification hint. A delivery landing while the user has a live turn
+ in this same thread must leave that turn running.
+ """
+ kind, label, _certain = classify_delivery(
+ content,
+ metadata,
+ session_user_id=self._session_user_id(),
+ )
+ destination_thread_id = thread_id
+ if kind == "handoff":
+ handoff_thread_id = str((metadata or {}).get("thread_id") or "").strip()
+ if handoff_thread_id:
+ destination_thread_id = handoff_thread_id
+ delivery = build_delivery(
+ thread_id=destination_thread_id,
+ text=str(content or ""),
+ kind=kind,
+ label=label,
+ )
+ delivery_id_value = str(delivery["deliveryId"])
+ # Persist BEFORE sending. The queue is the durability guarantee: if the
+ # socket dies between here and the ack, the entry survives to be
+ # replayed on the next connect, and T3's `deliveryId` dedupe makes the
+ # replay harmless.
+ queued = await asyncio.to_thread(self._home_queue.append, delivery)
+ sent = True
+ try:
+ await self._send_frame(delivery)
+ except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
+ sent = False
+ logger.warning(
+ "T3 home delivery %s could not be sent (%s); it is queued for "
+ "the next connect",
+ delivery_id_value,
+ exc,
+ )
+ # Success needs EITHER leg to have held. Queued-and-unsent arrives on
+ # the next connect; sent-but-unqueued is already at T3 (the ack simply
+ # finds nothing to purge). Neither means the content is gone, and
+ # reporting success then would tell a cron job its brief was delivered
+ # when nothing on this machine still holds it.
+ if not (queued or sent):
+ return SendResult(
+ success=False,
+ message_id=delivery_id_value,
+ error="T3 home delivery could not be sent or queued",
+ )
+ return SendResult(success=True, message_id=delivery_id_value)
+
+ async def _handle_connection_accepted(self, accepted: dict[str, Any]) -> None:
+ """Reconcile the home designation, then flush the delivery queue.
+
+ T3's settings blob is the authoritative designation and it republishes
+ it on every successful handshake, so the plugin's `T3_HOME_CHANNEL` is
+ a synced cache: a differing local value — including a hand-edited one —
+ is overwritten. Reconciling on every accept bounds drift to a single
+ reconnect.
+ """
+ thread_id = str(accepted.get("homeThreadId") or "").strip()
+ if thread_id and thread_id != home_thread_id():
+ logger.info("T3 designated home thread %s", thread_id)
+ save_home_thread_id(thread_id)
+ elif thread_id:
+ save_home_thread_id(thread_id)
+ await self._flush_home_queue()
+
+ async def _flush_home_queue(self) -> None:
+ """Replay unacknowledged deliveries oldest-first.
+
+ Entries are NOT removed here — only a `home.deliver.ack` purges one.
+ Re-sending an entry T3 already durably wrote is harmless (it dedupes on
+ `deliveryId`); dropping one it never wrote is not.
+
+ Frames are restamped to the CURRENT protocol version before sending: an
+ entry queued by an older plugin carries the version it was built under,
+ and T3's strict-lockstep decoder closes the socket on any other version
+ — turning one stale queued frame into a reconnect loop that outlives
+ the upgrade. The delivery fields themselves are version-stable (the
+ v3→v4 change only added frame types), so restamping is honest.
+ """
+ pending = await asyncio.to_thread(self._home_queue.entries)
+ if not pending:
+ return
+ logger.info("Flushing %d queued T3 home deliver(y|ies)", len(pending))
+ sent_bytes = 0
+ for entry in pending[:MAX_FLUSH_PER_CONNECT]:
+ encoded_bytes = len(
+ json.dumps(entry, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+ )
+ if sent_bytes > 0 and sent_bytes + encoded_bytes > MAX_FLUSH_BYTES_PER_CONNECT:
+ logger.info(
+ "Stopping this T3 home delivery flush at %d bytes; the remainder "
+ "will ride the next reconnect",
+ sent_bytes,
+ )
+ return
+ try:
+ await self._send_frame({**entry, "protocolVersion": PROTOCOL_VERSION})
+ except Exception as exc: # noqa: BLE001 - the rest rides the next connect
+ logger.warning("T3 home delivery flush stopped: %s", exc)
+ return
+ sent_bytes += encoded_bytes
+
+ async def _acknowledge_home_delivery(self, message: dict[str, Any]) -> None:
+ """Purge a delivery T3 has durably written.
+
+ Serves `home.deliver.ack` and `media.deliver.ack` alike: both frame
+ types live in the same queue keyed on `deliveryId`, so the purge does
+ not care which kind of delivery was acknowledged.
+ """
+ delivery_id_value = str(message.get("deliveryId") or "").strip()
+ if not delivery_id_value:
+ raise ValueError("a delivery ack requires a deliveryId")
+ await asyncio.to_thread(self._home_queue.purge, delivery_id_value)
+
+ # ── outbound media ─────────────────────────────────────────────────
+
+ def _media_turn_scope(
+ self,
+ thread_id: str,
+ content: str,
+ metadata: dict[str, Any] | None,
+ ) -> _TurnState | None:
+ """Resolve the turn a media send belongs to, or None for home delivery.
+
+ Same provenance gate as `_is_proactive_delivery`, with one addition:
+ the base adapter's delivery pipeline sends a reply's final text —
+ notify-marked, which completes the turn here — BEFORE it dispatches
+ the reply's media files (`gateway/platforms/base.py:5326` then
+ `:5373+`), so turn media routinely arrives moments after its turn
+ closed and must still be able to reach back to it.
+
+ **The session key is NOT available on the media dispatch path**, and
+ that is structural, not a race. The gateway binds `HERMES_SESSION_KEY`
+ inside `_handle_message_with_agent` and clears it in that method's own
+ `finally` (`gateway/run.py:12972` → `:14626`); the delivery pipeline
+ that sends the text and then the files lives one frame further out, in
+ `BasePlatformAdapter._process_message_background`, and runs entirely
+ AFTER the handler returned. `clear_session_vars` sets the vars to `""`
+ rather than resetting them, deliberately suppressing the `os.environ`
+ fallback — so every send the pipeline makes, text and media alike,
+ reads `""`. Verified against the real gateway package: inside the
+ handler the key resolves; on return it is `""`.
+
+ The text path never noticed because it does not consult the key when a
+ live turn exists — `send()` reaches `_is_proactive_delivery`, which for
+ a non-home thread returns False on the thread check alone and falls
+ through to `_active_turns`. Media had no such fallback: it required the
+ key to match, so on a non-home thread the file was dropped with
+ "no active T3 turn" (live repro 2026-07-27 18:47:06, 36ms after the
+ turn's own text completed the turn).
+
+ So the reach-back cannot be keyed on the session key. It is keyed on
+ the two signals that ARE trustworthy here:
+
+ * **Recency.** A completed turn is a scope only within
+ `_RECENT_TURN_MEDIA_WINDOW_SECONDS` of completing. Turn media follows
+ its text by milliseconds; anything later is not this turn's output.
+ * **Provenance.** `classify_delivery` must NOT positively identify the
+ send as proactive. This is the same discriminator the home half of
+ the gate uses, applied with the opposite default — and it is what
+ contains the collision this window would otherwise open.
+
+ The collision to contain is `send_message`, the one thing besides a
+ turn that can dispatch media to a NON-home thread
+ (`tools/send_message_tool.py:1880+` → `adapter.send_image_file` with a
+ caller-chosen `chat_id`). Cron cannot: it delivers to the home channel
+ and is excluded by the thread check. But `send_message` runs INSIDE a
+ turn's own handler — it is a tool the agent calls — so it is not a
+ cross-turn intruder arriving during someone else's live turn; it is
+ this session's own agent choosing a destination. Two cases follow. If
+ it targets this thread, scoping the file to the turn that produced it
+ is exactly right. If it targets a *different* thread, that thread's
+ `_recent_turns` entry is stale by far more than the window unless the
+ user was mid-conversation there seconds ago — and in that narrow case
+ the file still lands in the thread the agent addressed, attributed to a
+ turn that just ended in it. A slightly-wrong turn attribution on a
+ message row, never a stolen answer.
+
+ That asymmetry is the whole reason this is safe where the text gate is
+ strict. `send()` completes turns; a misattributed text send ends a live
+ turn with the wrong output — the `finalize` defect class. Media touches
+ no turn machinery at all: `media.deliver` carries `turnId` purely as a
+ sequencing hint, emits no turn or item frame, and cannot complete,
+ interrupt, or alter a turn. The worst outcome here is a file sequenced
+ next to the wrong neighbour.
+
+ A live turn whose session key matches still wins outright and is
+ checked first, so nothing about the ordinary in-handler path changes.
+ """
+ turn = self._active_turns.get(thread_id)
+ recent = self._recent_turns.get(thread_id)
+ session_key = self._gateway_session_key()
+ if turn is not None and session_key == turn.session_id:
+ return turn
+ if turn is None and recent is not None and session_key == recent.session_id:
+ return recent
+ home = home_thread_id()
+ if not home or thread_id != home:
+ # Not home. A live turn takes the media exactly as the text path
+ # would. Otherwise the just-completed turn may claim it, bounded by
+ # recency and refused to a positively-proactive send — see above.
+ if turn is not None:
+ return turn
+ if not self._within_media_reachback(recent):
+ return None
+ _kind, _label, certain = classify_delivery(
+ content,
+ metadata,
+ session_user_id=self._session_user_id(),
+ )
+ return None if certain else recent
+ if turn is None:
+ return None
+ _kind, _label, certain = classify_delivery(
+ content,
+ metadata,
+ session_user_id=self._session_user_id(),
+ )
+ # Conservative half of the gate, mirroring text: an unattributable
+ # media send during a live home turn stays with the turn.
+ return None if certain else turn
+
+ @staticmethod
+ def _within_media_reachback(turn: _TurnState | None) -> bool:
+ """True while a completed turn may still claim its own trailing media.
+
+ A turn with no `completed_at` never went through `_complete_turn`, so
+ nothing is known about when it ended — treated as out of the window
+ rather than assumed fresh.
+ """
+ if turn is None or turn.completed_at is None:
+ return False
+ return (
+ time.monotonic() - turn.completed_at
+ ) <= _RECENT_TURN_MEDIA_WINDOW_SECONDS
+
+ async def _deliver_media_file(
+ self,
+ chat_id: str,
+ path: str,
+ *,
+ caption: str | None = None,
+ name: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ ) -> SendResult:
+ """Emit one `media.deliver`, queueing it until T3 acknowledges it.
+
+ The same durable lifecycle as `_deliver_to_home`: persist BEFORE
+ sending, report success once queued, purge only on the ack. The one
+ divergence is a payload that cannot be built at all — unreadable file,
+ empty, over the 25MiB ceiling — which fails the send immediately
+ instead of queueing a frame T3 would reject on every future flush.
+
+ **An unscopeable file goes to Home rather than being dropped.** When
+ `_media_turn_scope` finds nothing on a non-home thread, the old
+ behaviour returned `"no active T3 turn"` — and upstream's only
+ response to that is `logger.error("Failed to send image: %s")`
+ (`gateway/platforms/base.py:3471`) before moving on. The file is gone,
+ silently from the user's side, after Hermes spent a generation call
+ producing it. Text can afford that (the agent can restate it, the user
+ can ask again); a produced artifact cannot.
+
+ Routing it to Home is safe in the way the thread route is not. The
+ frame goes out turnless, so T3 re-resolves the instance's home thread
+ server-side and writes only there — a plugin cannot address an
+ arbitrary thread on this path even in principle
+ (`apps/server/src/provider/hermesGatewayHttp.ts:207-215`) — and it
+ carries `classify_delivery` provenance, so it renders as a badged
+ notification exactly like a cron artifact rather than impersonating a
+ thread reply. With no home designated there is genuinely nowhere to put
+ it, and the original error stands.
+ """
+ thread_id = str(chat_id)
+ content = str(caption or "")
+ turn = self._media_turn_scope(thread_id, content, metadata)
+ home = home_thread_id()
+ delivery_thread_id = thread_id
+ if turn is None and (not home or thread_id != home):
+ if not home:
+ return SendResult(success=False, error="no active T3 turn")
+ logger.info(
+ "T3 media for thread %s has no turn to attach to; delivering "
+ "it to the home thread instead of dropping it",
+ thread_id,
+ )
+ delivery_thread_id = home
+ kind, label, _certain = classify_delivery(
+ content,
+ metadata,
+ session_user_id=self._session_user_id(),
+ )
+ if turn is None and kind == "handoff":
+ handoff_thread_id = str((metadata or {}).get("thread_id") or "").strip()
+ if handoff_thread_id:
+ delivery_thread_id = handoff_thread_id
+ try:
+ delivery = build_media_delivery(
+ thread_id=delivery_thread_id,
+ path=str(path),
+ kind=kind,
+ label=label,
+ turn_id=turn.turn_id if turn is not None else None,
+ caption=caption,
+ name=name,
+ )
+ except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
+ logger.warning("T3 media delivery for %s failed to build: %s", path, exc)
+ return SendResult(success=False, error=str(exc))
+ delivery_id_value = str(delivery["deliveryId"])
+ queued = await asyncio.to_thread(self._home_queue.append, delivery)
+ sent = True
+ try:
+ await self._send_frame(delivery)
+ except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
+ sent = False
+ logger.warning(
+ "T3 media delivery %s could not be sent (%s); it is queued for "
+ "the next connect",
+ delivery_id_value,
+ exc,
+ )
+ # Neither queued nor sent means the file is gone — the only copy was
+ # the bytes in this frame, and Hermes' temp file may be reaped before
+ # anyone could retry. Fail before the completion below, so the turn is
+ # not closed on media that never arrived. See `_deliver_to_home` for
+ # why either leg alone is honest success.
+ if not (queued or sent):
+ return SendResult(
+ success=False,
+ message_id=delivery_id_value,
+ error="T3 media delivery could not be sent or queued",
+ )
+ # The same notify-completion contract `send()` honors for text. The
+ # base adapter notify-marks every send of a reply's FINAL delivery
+ # batch (`_mark_notify_metadata`, `gateway/platforms/base.py:5220`) —
+ # text and media alike — and an image-only reply produces no text
+ # send at all, so this is the only place its turn can complete.
+ # Guarded to the still-live turn: the common text-then-media ordering
+ # completes the turn on the text, and re-completing a `_recent_turns`
+ # entry would emit a second `turn.completed` for a turn T3 already
+ # folded.
+ if (
+ turn is not None
+ and bool((metadata or {}).get("notify"))
+ and self._active_turns.get(thread_id) is turn
+ ):
+ await self._complete_turn(turn)
+ return SendResult(success=True, message_id=delivery_id_value)
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: str | None = None,
+ reply_to: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> SendResult:
+ del reply_to, kwargs
+ return await self._deliver_media_file(
+ chat_id, image_path, caption=caption, metadata=metadata
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: str | None = None,
+ reply_to: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> SendResult:
+ del reply_to, kwargs
+ return await self._deliver_media_file(
+ chat_id, video_path, caption=caption, metadata=metadata
+ )
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: str | None = None,
+ reply_to: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> SendResult:
+ # T3 renders audio as a download card (no native player in v1), which
+ # is still strictly better than the base fallback's "couldn't deliver
+ # the audio attachment" notice.
+ del reply_to, kwargs
+ return await self._deliver_media_file(
+ chat_id, audio_path, caption=caption, metadata=metadata
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: str | None = None,
+ file_name: str | None = None,
+ reply_to: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> SendResult:
+ del reply_to, kwargs
+ return await self._deliver_media_file(
+ chat_id,
+ file_path,
+ caption=caption,
+ name=file_name,
+ metadata=metadata,
+ )
+
+ @staticmethod
+ def _session_user_id() -> str:
+ """Read the bound session's user id, for `/handoff` classification.
+
+ Returns `""` on any failure, exactly like `_gateway_session_key`.
+ """
+ try:
+ from gateway.session_context import get_session_env
+
+ return str(get_session_env("HERMES_SESSION_USER_ID", "") or "")
+ except Exception: # noqa: BLE001 - classification must never raise
+ return ""
+
+ def format_tool_event(
+ self, event: Any, *, mode: str = "all", preview_max_len: int = 40
+ ) -> str | None:
+ """Drop textual tool-progress chrome.
+
+ T3 already renders tool calls as typed `item.started` / `item.completed`
+ activity from the `pre_tool_call` / `post_tool_call` hooks, so a text
+ line duplicating them is strictly worse than what T3 already shows.
+
+ NOTE: at Hermes 62e07223 this hook is NOT on the live delivery path —
+ `GatewayEventDispatcher` (`gateway/stream_dispatch.py:108`, its only
+ caller) is referenced solely by upstream tests. The path that actually
+ runs is `gateway/run.py:20485+`, which builds the same lines and
+ delivers them through `adapter.send` / `adapter.edit_message` with no
+ adapter hook to suppress them; it is silenced by the platform's
+ `tool_progress` display setting instead. This override is kept because
+ it is the documented contract and costs nothing if upstream routes
+ through the dispatcher again — but it is not what protects the turn.
+ The turn is protected by ignoring `finalize` in `edit_message`.
+ """
+ del event, mode, preview_max_len
+ return None
+
+ async def send_typing(
+ self, chat_id: str, metadata: dict[str, Any] | None = None
+ ) -> None:
+ del metadata
+ turn = self._active_turns.get(str(chat_id))
+ if turn is None:
+ return
+ status = getattr(self, "_status_text", {}).get(str(chat_id))
+ if status:
+ await self._emit_generic_activity(turn, status)
+
+ def set_status_text(self, chat_id: str, text: str | None) -> None:
+ super().set_status_text(chat_id, text)
+ if not text:
+ return
+ turn = self._active_turns.get(str(chat_id))
+ if turn is not None:
+ self._schedule(self._emit_generic_activity(turn, text))
+
+ async def send_exec_approval(
+ self,
+ chat_id: str,
+ command: str,
+ session_key: str,
+ description: str,
+ metadata: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> SendResult:
+ del metadata, kwargs
+ turn = self._active_turns.get(str(chat_id))
+ if turn is None:
+ return SendResult(success=False, error="no active T3 turn")
+ approval_id = str(uuid.uuid4())
+ self._approval_requests[approval_id] = (session_key, turn.turn_id)
+ await self._send_frame(
+ frame(
+ "request.opened",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ requestId=approval_id,
+ requestType="command_execution_approval",
+ detail=description or "Hermes requests permission to run a command",
+ args={"command": command},
+ )
+ )
+ return SendResult(success=True, message_id=approval_id)
+
+ async def send_clarify(
+ self,
+ chat_id: str,
+ question: str,
+ choices: list[Any] | None,
+ clarify_id: str,
+ session_key: str,
+ metadata: dict[str, Any] | None = None,
+ ) -> SendResult:
+ del metadata
+ turn = self._active_turns.get(str(chat_id))
+ if turn is None:
+ return SendResult(success=False, error="no active T3 turn")
+ options = []
+ for choice in choices or []:
+ label = str(choice.get("label") if isinstance(choice, dict) else choice)
+ description = (
+ str(choice.get("description") or label)
+ if isinstance(choice, dict)
+ else label
+ )
+ options.append({"label": label, "description": description})
+ self._user_input_requests[clarify_id] = (session_key, turn.turn_id)
+ await self._send_frame(
+ frame(
+ "user-input.requested",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ requestId=clarify_id,
+ questions=[
+ {
+ "id": clarify_id,
+ "header": "Hermes",
+ "question": question,
+ "options": options,
+ "multiSelect": False,
+ }
+ ],
+ )
+ )
+ return SendResult(success=True, message_id=clarify_id)
+
+ async def _handle_server_frame(self, raw: dict[str, Any]) -> None:
+ request = raw.get("requestId")
+ try:
+ message = validate_server_frame(raw)
+ frame_type = message["type"]
+ if frame_type == "session.ensure":
+ await self._ensure_session(message)
+ elif frame_type == "turn.start":
+ if not self._gateway_interactive_turns_enabled:
+ raise ValueError(
+ "interactive turns are disabled on the Hermes companion; "
+ "use T3's hermes-acp provider"
+ )
+ await self._start_turn(message)
+ elif frame_type == "turn.steer":
+ if not self._gateway_interactive_turns_enabled:
+ raise ValueError(
+ "interactive turns are disabled on the Hermes companion; "
+ "use T3's hermes-acp provider"
+ )
+ await self._steer_turn(message)
+ elif frame_type == "turn.interrupt":
+ await self._interrupt_turn(message)
+ elif frame_type == "approval.respond":
+ await self._resolve_approval(message)
+ elif frame_type == "user-input.respond":
+ await self._resolve_user_input(message)
+ elif frame_type == "session.stop":
+ await self._stop_session(message)
+ elif frame_type == "ping":
+ await self._send_frame(
+ frame(
+ "pong",
+ requestId=message["requestId"],
+ sentAt=message.get("sentAt") or iso_now(),
+ )
+ )
+ elif frame_type == "describe.request":
+ await self._describe(message)
+ elif frame_type == "skill.body.request":
+ await self._send_skill_body(message)
+ elif frame_type in {"home.deliver.ack", "media.deliver.ack"}:
+ await self._acknowledge_home_delivery(message)
+ elif frame_type == "handoff.created":
+ self._resolve_handoff_create(message)
+ elif frame_type == "protocol.error":
+ request_id_value = str(message.get("requestId") or "").strip()
+ pending = self._pending_handoff_creates.pop(request_id_value, None)
+ if pending is not None and not pending.done():
+ logger.warning("T3 rejected handoff thread creation: %s", message["message"])
+ pending.set_result(None)
+ else:
+ logger.warning("T3 gateway protocol error: %s", message["message"])
+ except ValueError as exc:
+ await self._send_frame(
+ protocol_error(
+ "unsupported-message",
+ str(exc),
+ recoverable=True,
+ related_request_id=str(request) if request else None,
+ )
+ )
+ except Exception as exc:
+ logger.exception("T3 gateway command failed")
+ await self._send_frame(
+ protocol_error(
+ "internal-error",
+ str(exc) or type(exc).__name__,
+ recoverable=True,
+ related_request_id=str(request) if request else None,
+ )
+ )
+
+ async def _describe(self, message: dict[str, Any]) -> None:
+ """Answer `describe.request` with what this plugin knows about itself.
+
+ Correlated by the request's own `requestId`, exactly like `ping` →
+ `pong`. Every Hermes-sourced field degrades to omitted inside
+ `describe_response`, so this branch has no failure path of its own:
+ an unreadable config or an older Hermes yields a thinner reply, never
+ a `protocol.error` and never a dropped connection.
+ """
+ await self._send_frame(
+ describe_response(
+ request_id_value=str(message["requestId"]),
+ hermes_version=_hermes_version(),
+ )
+ )
+
+ async def _send_skill_body(self, message: dict[str, Any]) -> None:
+ """Answer `skill.body.request` with one skill's markdown.
+
+ Fired on row expand, never eagerly — bodies are the reason skills are
+ reported as metadata only. An unknown or unreadable skill replies with
+ `markdown: null` rather than an error, so the UI can render "no body
+ available" instead of showing the user a protocol failure.
+
+ A *missing* skill name is different from an unreadable skill: the
+ response carries `skillName` back for the client to key on, and an
+ empty one would not decode. That case takes the ordinary correlated
+ `protocol.error` path instead of echoing a name that was never sent.
+ """
+ skill_name = str(message.get("skillName") or "").strip()
+ if not skill_name:
+ raise ValueError("skill.body.request requires a skillName")
+ await self._send_frame(
+ skill_body_response(
+ request_id_value=str(message["requestId"]),
+ skill_name=skill_name,
+ markdown=skill_body(skill_name),
+ )
+ )
+
+ async def _ensure_session(self, message: dict[str, Any]) -> None:
+ thread_id = str(message["threadId"])
+ source = self._source(thread_id, str(message["requestId"]))
+ session_id = build_session_key(source)
+ resume_id = str(message.get("resumeSessionId") or "")
+ self._sessions[thread_id] = session_id
+ self._active_session_threads.add(thread_id)
+ self._thread_by_session[session_id] = thread_id
+ active_turn = self._active_turns.get(thread_id)
+ await self._send_frame(
+ frame(
+ "session.ready",
+ requestId=message["requestId"],
+ threadId=thread_id,
+ sessionId=session_id,
+ resumed=bool(resume_id and resume_id == session_id),
+ **(
+ {"activeTurnId": active_turn.turn_id}
+ if active_turn is not None
+ else {}
+ ),
+ )
+ )
+ await self._send_status()
+
+ async def _start_turn(self, message: dict[str, Any]) -> None:
+ thread_id = str(message["threadId"])
+ session_id = self._sessions.get(thread_id)
+ if not session_id or session_id != str(message["sessionId"]):
+ await self._send_frame(
+ protocol_error(
+ "session-not-found",
+ "Call session.ensure before starting a turn.",
+ recoverable=True,
+ related_request_id=str(message["requestId"]),
+ )
+ )
+ return
+ if thread_id in self._active_turns:
+ await self._send_frame(
+ protocol_error(
+ "invalid-message",
+ "This Hermes session already has an active turn; use turn.steer.",
+ recoverable=True,
+ related_request_id=str(message["requestId"]),
+ )
+ )
+ return
+ # Decode and materialize attachments BEFORE any turn state exists: a
+ # malformed attachment raises ValueError into the correlated
+ # `protocol.error` path with no half-started turn to clean up.
+ #
+ # Surfacing choice: the temp file paths ride the MessageEvent's own
+ # `media_urls` / `media_types` fields — Hermes' structured channel for
+ # exactly this (`gateway/platforms/base.py:1800`). The gateway's
+ # enrichment pipeline then does everything a bundled platform gets:
+ # vision routing for images, STT for voice, and path-pointing context
+ # notes for documents (`gateway/run.py:12420+`). No prompt-text
+ # injection is needed on this path.
+ media_paths, media_types = _materialize_attachments(
+ turn_attachments(message)
+ )
+ turn = _TurnState(
+ thread_id=thread_id,
+ session_id=session_id,
+ turn_id=str(message["turnId"]),
+ request_id=str(message["requestId"]),
+ )
+ self._active_turns[thread_id] = turn
+ # Roll the registration back if starting the turn raises. Without this
+ # a failed `turn.started` send (a socket that dropped between the
+ # decode and the write) leaves a phantom turn no completion path will
+ # ever reach, and the `thread_id in self._active_turns` guard above
+ # then rejects every future `turn.start` on this thread for the life of
+ # the process. Guarded on identity: an error handler that already
+ # replaced the entry owns it now, and clobbering that would strand the
+ # replacement instead.
+ try:
+ await self._send_frame(
+ frame(
+ "turn.started",
+ requestId=turn.request_id,
+ threadId=thread_id,
+ sessionId=session_id,
+ turnId=turn.turn_id,
+ )
+ )
+ await self._send_status()
+ await self.handle_message(
+ MessageEvent(
+ text=str(message["text"]),
+ message_type=(
+ MessageType.COMMAND
+ if str(message["text"]).lstrip().startswith("/")
+ else MessageType.TEXT
+ ),
+ source=self._source(thread_id, turn.request_id),
+ message_id=turn.request_id,
+ metadata={"t3_turn_id": turn.turn_id},
+ media_urls=media_paths,
+ media_types=media_types,
+ )
+ )
+ except BaseException:
+ if self._active_turns.get(thread_id) is turn:
+ del self._active_turns[thread_id]
+ raise
+
+ async def _steer_turn(self, message: dict[str, Any]) -> None:
+ turn = self._active_turns.get(str(message["threadId"]))
+ if turn is None or turn.turn_id != str(message["turnId"]):
+ await self._send_frame(
+ protocol_error(
+ "turn-not-active",
+ "The requested Hermes turn is no longer active.",
+ recoverable=True,
+ related_request_id=str(message["requestId"]),
+ )
+ )
+ return
+ # Attachments on a steer cannot ride `media_urls`: Hermes' `/steer`
+ # handler injects only the command's text between tool iterations
+ # (`gateway/run.py:11254`) and never reads the event's media fields.
+ # The paths are appended to the injected text instead — mid-turn the
+ # agent reaches files through its tools anyway, so a path note is the
+ # natural (and only) channel here.
+ steer_text = str(message["text"])
+ media_paths, media_types = _materialize_attachments(
+ turn_attachments(message)
+ )
+ for path, mime in zip(media_paths, media_types):
+ steer_text += f"\n[The user attached a file ({mime}): {path}]"
+ # `/steer` is Hermes' official active-run injection surface. The base
+ # adapter dispatches active slash commands inline, then sends the
+ # command's textual acknowledgement back through this adapter with
+ # `notify=True`. Capture that one command response by request context:
+ # it is control traffic, not assistant output and not a turn boundary.
+ control = _SteerControlResponse(
+ thread_id=turn.thread_id,
+ request_id=str(message["requestId"]),
+ )
+ context_token = _steer_control_response.set(control)
+ command_error: Exception | None = None
+ try:
+ await self.handle_message(
+ MessageEvent(
+ text=f"/steer {steer_text}",
+ message_type=MessageType.COMMAND,
+ source=self._source(turn.thread_id, control.request_id),
+ message_id=control.request_id,
+ metadata={"t3_turn_id": turn.turn_id, "t3_steer": True},
+ )
+ )
+ except Exception as exc: # noqa: BLE001 - translate command failures to the wire
+ command_error = exc
+ finally:
+ _steer_control_response.reset(context_token)
+
+ if command_error is not None:
+ await self._send_frame(
+ protocol_error(
+ "internal-error",
+ str(command_error) or "Hermes steering failed.",
+ recoverable=True,
+ related_request_id=control.request_id,
+ )
+ )
+ return
+
+ response = control.messages[-1] if control.messages else ""
+ if not response.startswith("⏩ Steer queued"):
+ if response.startswith(("Agent still starting", "No active agent")):
+ error_code = "turn-not-active"
+ elif response.startswith("⚠️ Steer failed"):
+ error_code = "internal-error"
+ else:
+ error_code = "invalid-message"
+ await self._send_frame(
+ protocol_error(
+ error_code,
+ response or "Hermes did not acknowledge the steering request.",
+ recoverable=True,
+ related_request_id=control.request_id,
+ )
+ )
+ return
+
+ # Correlated command acknowledgement. It intentionally reuses the
+ # existing turnId: this is not a second runtime turn. T3 consumes the
+ # steering requestId as its broker acknowledgement and suppresses the
+ # duplicate turn-start lifecycle projection.
+ await self._send_frame(
+ frame(
+ "turn.started",
+ requestId=control.request_id,
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ )
+ )
+
+ def _capture_steer_control_response(
+ self,
+ chat_id: str,
+ content: str,
+ correlation_id: str | None,
+ ) -> SendResult | None:
+ """Capture only the steering command's own acknowledgement.
+
+ A steer targets a RUNNING turn, so Hermes can legitimately emit
+ assistant output on the same thread while the steering command is
+ still awaited. Matching on `chat_id` alone would swallow that output
+ and drop it from the transcript, so the capture is keyed on the
+ steering `requestId` the plugin stamped on the dispatched
+ `MessageEvent` (and, for follow-up edits, on the synthetic control
+ message id this method returns). Everything else falls through to the
+ normal assistant-content path.
+ """
+ control = _steer_control_response.get()
+ if control is None or control.thread_id != str(chat_id):
+ return None
+ if correlation_id is None:
+ return None
+ correlation = str(correlation_id)
+ if correlation not in {control.request_id, control.control_message_id}:
+ return None
+ control.messages.append(str(content))
+ return SendResult(success=True, message_id=control.control_message_id)
+
+ async def _interrupt_turn(self, message: dict[str, Any]) -> None:
+ thread_id = str(message["threadId"])
+ turn = self._active_turns.get(thread_id)
+ if turn is None or turn.turn_id != str(message["turnId"]):
+ await self._send_frame(
+ protocol_error(
+ "turn-not-active",
+ "The requested Hermes turn is no longer active.",
+ recoverable=True,
+ related_request_id=str(message["requestId"]),
+ )
+ )
+ return
+ await self.interrupt_session_activity(turn.session_id, thread_id)
+ await self._send_frame(
+ frame(
+ "turn.aborted",
+ threadId=thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ reason="Interrupted by T3 Code",
+ )
+ )
+ self._active_turns.pop(thread_id, None)
+ await self._send_status()
+
+ async def _resolve_approval(self, message: dict[str, Any]) -> None:
+ request_id = str(message["requestId"])
+ pending = self._approval_requests.pop(request_id, None)
+ if pending is None:
+ await self._send_frame(
+ protocol_error(
+ "request-not-found",
+ "The Hermes approval request is no longer pending.",
+ recoverable=True,
+ related_request_id=request_id,
+ )
+ )
+ return
+ session_key, _turn_id = pending
+ decision = str(message["decision"])
+ choice = {
+ "accept": "once",
+ "acceptForSession": "session",
+ "decline": "deny",
+ "cancel": "deny",
+ }.get(decision, "deny")
+ from tools.approval import resolve_gateway_approval
+
+ resolved = resolve_gateway_approval(session_key, choice)
+ await self._send_frame(
+ frame(
+ "request.resolved",
+ threadId=message["threadId"],
+ sessionId=message["sessionId"],
+ turnId=message["turnId"],
+ requestId=request_id,
+ requestType="command_execution_approval",
+ decision=decision,
+ resolution={"resolvedCount": resolved},
+ )
+ )
+
+ async def _resolve_user_input(self, message: dict[str, Any]) -> None:
+ request_id = str(message["requestId"])
+ pending = self._user_input_requests.pop(request_id, None)
+ if pending is None:
+ await self._send_frame(
+ protocol_error(
+ "request-not-found",
+ "The Hermes user-input request is no longer pending.",
+ recoverable=True,
+ related_request_id=request_id,
+ )
+ )
+ return
+ answers = message.get("answers") or {}
+ answer = answers.get(request_id) if isinstance(answers, dict) else None
+ if answer is None and isinstance(answers, dict) and answers:
+ answer = next(iter(answers.values()))
+ if isinstance(answer, list):
+ response = ", ".join(str(value) for value in answer)
+ else:
+ response = str(answer or "")
+ from tools.clarify_gateway import resolve_gateway_clarify
+
+ resolved = resolve_gateway_clarify(request_id, response)
+ await self._send_frame(
+ frame(
+ "user-input.resolved",
+ threadId=message["threadId"],
+ sessionId=message["sessionId"],
+ turnId=message["turnId"],
+ requestId=request_id,
+ answers=answers,
+ )
+ )
+ if not resolved:
+ logger.warning(
+ "Hermes clarify request %s was no longer pending", request_id
+ )
+
+ async def _stop_session(self, message: dict[str, Any]) -> None:
+ thread_id = str(message["threadId"])
+ session_id = self._sessions.get(thread_id)
+ if session_id is None:
+ await self._send_frame(
+ protocol_error(
+ "session-not-found",
+ "The requested Hermes session is not active in this connection.",
+ recoverable=True,
+ related_request_id=str(message["requestId"]),
+ )
+ )
+ return
+ turn = self._active_turns.pop(thread_id, None)
+ if turn is not None:
+ await self.interrupt_session_activity(session_id, thread_id)
+ await self._send_frame(
+ frame(
+ "turn.aborted",
+ threadId=thread_id,
+ sessionId=session_id,
+ turnId=turn.turn_id,
+ reason="Hermes session stopped by T3 Code",
+ )
+ )
+ await self._send_frame(
+ frame(
+ "session.exited",
+ threadId=thread_id,
+ sessionId=session_id,
+ reason="Stopped by T3 Code",
+ recoverable=True,
+ )
+ )
+ # Deliberately retain the deterministic mapping and Hermes transcript.
+ # A later session.ensure resumes this same thread/session identity.
+ self._active_session_threads.discard(thread_id)
+ await self._send_status()
+
+ async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None:
+ visible = str(content or "").replace(" ▉", "").replace("▉", "")
+ if not turn.assistant_started:
+ await self._send_frame(
+ frame(
+ "item.started",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ itemId=turn.message_id,
+ itemType="assistant_message",
+ status="inProgress",
+ title="Hermes response",
+ )
+ )
+ turn.assistant_started = True
+ if visible.startswith(turn.visible_text):
+ delta = visible[len(turn.visible_text) :]
+ if delta:
+ await self._send_frame(
+ frame(
+ "content.delta",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ itemId=turn.message_id,
+ streamKind="assistant_text",
+ delta=delta,
+ contentIndex=0,
+ )
+ )
+ turn.visible_text = visible
+ elif visible != turn.visible_text:
+ await self._send_frame(
+ frame(
+ "content.snapshot",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ itemId=turn.message_id,
+ streamKind="assistant_text",
+ text=visible,
+ contentIndex=0,
+ )
+ )
+ turn.visible_text = visible
+
+ async def _complete_turn(self, turn: _TurnState) -> None:
+ if self._active_turns.get(turn.thread_id) is not turn:
+ return
+ # Close the live status line BEFORE the assistant message.
+ #
+ # T3 orders the timeline by item timestamp and folds a settled turn's
+ # activity behind the "Worked for …" row — but only the entries that
+ # precede the turn's terminal assistant message. Completing the status
+ # item after that message stamped it milliseconds later, so it sorted
+ # below the answer, escaped the fold, and rendered as a stray "Work
+ # Log" section under the reply instead of joining the collapsed
+ # activity above it.
+ async with turn.generic_activity_lock:
+ if self._active_turns.get(turn.thread_id) is not turn:
+ return
+ if turn.generic_activity_id is not None:
+ await self._send_frame(
+ frame(
+ "item.completed",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ itemId=turn.generic_activity_id,
+ itemType=_STATUS_ITEM_TYPE,
+ status="completed",
+ title="Hermes activity",
+ **(
+ {"detail": turn.generic_activity_detail}
+ if turn.generic_activity_detail
+ else {}
+ ),
+ )
+ )
+ if turn.assistant_started:
+ await self._send_frame(
+ frame(
+ "item.completed",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ itemId=turn.message_id,
+ itemType="assistant_message",
+ status="completed",
+ title="Hermes response",
+ )
+ )
+ async with turn.generic_activity_lock:
+ if self._active_turns.get(turn.thread_id) is not turn:
+ return
+ await self._send_frame(
+ frame(
+ "turn.completed",
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ state="completed",
+ stopReason=None,
+ )
+ )
+ self._active_turns.pop(turn.thread_id, None)
+ # Remembered for media scoping: the base adapter sends a reply's
+ # media files AFTER its notify-marked text, i.e. after this point.
+ # The stamp bounds that reach-back — see `_media_turn_scope`.
+ turn.completed_at = time.monotonic()
+ self._recent_turns[turn.thread_id] = turn
+ await self._send_status()
+
+ async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None:
+ if not detail:
+ return
+ normalized_detail = str(detail)[:2_000]
+ async with turn.generic_activity_lock:
+ if self._active_turns.get(turn.thread_id) is not turn:
+ return
+ if turn.generic_activity_detail == normalized_detail:
+ return
+ activity_id = turn.generic_activity_id
+ if activity_id is None:
+ activity_id = item_id()
+ event_type = "item.started"
+ else:
+ event_type = "item.updated"
+ await self._send_frame(
+ frame(
+ event_type,
+ threadId=turn.thread_id,
+ sessionId=turn.session_id,
+ turnId=turn.turn_id,
+ itemId=activity_id,
+ itemType=_STATUS_ITEM_TYPE,
+ status="inProgress",
+ title="Hermes activity",
+ detail=normalized_detail,
+ )
+ )
+ turn.generic_activity_id = activity_id
+ turn.generic_activity_detail = normalized_detail
+
+ def _turn_for_tool_hook(self, session_id: str) -> _TurnState | None:
+ """Resolve the active turn a tool hook belongs to.
+
+ The tool hooks' `session_id` is NOT this plugin's session id. Hermes
+ passes `agent.session_id` (`agent/tool_executor.py:188`, `:305`,
+ `:341`), which the gateway sets to `SessionEntry.session_id` — a
+ timestamped run id like `20260725_143012_ab12cd34`
+ (`gateway/session.py:2388`, `agent/agent_init.py:1446-1453`). This
+ plugin's session ids come from `build_session_key`
+ (`gateway/session.py:1029`), shaped `agent:main:t3:dm:`. The two
+ never match, so `_thread_by_session` alone silently drops every tool
+ activity item.
+
+ The gateway's stable routing key is available separately: it is bound
+ onto `HERMES_SESSION_KEY` for the turn's context
+ (`gateway/run.py:17367` → `gateway/session_context.py:200`) and
+ propagated into the tool worker threads
+ (`agent/tool_executor.py:715`, `propagate_context_to_thread`). That key
+ IS `build_session_key(...)`, so it matches `_thread_by_session`.
+
+ Resolution order, all best-effort:
+ 1. `session_id` as a direct routing key (correct if a future Hermes
+ passes the gateway key here, and free to check).
+ 2. `HERMES_SESSION_KEY` from the Hermes session context.
+ 3. The sole active turn, when exactly one exists — a single-threaded
+ Hermes process has no ambiguity to resolve, and dropping the
+ activity would be strictly worse. **Cron runs are excluded from
+ this step** (see below).
+ Anything unresolved returns `None` and the item is simply not emitted;
+ tool activity is decorative, so this must never raise or misroute.
+
+ The cron exclusion: these hooks are process-global, so a cron job
+ running tools while exactly one T3 turn happens to be live would
+ resolve through the sole-turn fallback and paint the cron job's tool
+ calls into an unrelated live conversation. Cron runs are identifiable —
+ the scheduler builds its agent with
+ `session_id=f"cron_{job_id}_{timestamp}"` (`cron/scheduler.py:3017`,
+ passed at `:3484`), which is exactly the value these hooks receive as
+ `session_id`. Upstream treats the same routing hazard as real: the
+ scheduler deliberately clears the process-global session env vars for
+ it (`cron/scheduler.py:3066-3091`). A cron job's activity belongs to
+ the eventual `home.deliver`, never to a live turn, so it is dropped
+ rather than guessed at.
+ """
+ thread_id = self._thread_by_session.get(str(session_id))
+ if thread_id is None:
+ thread_id = self._thread_by_session.get(self._gateway_session_key())
+ if thread_id is not None:
+ return self._active_turns.get(thread_id)
+ if self._is_cron_session(session_id):
+ return None
+ if len(self._active_turns) == 1:
+ return next(iter(self._active_turns.values()))
+ return None
+
+ @staticmethod
+ def _is_cron_session(session_id: str) -> bool:
+ """True when this hook call belongs to a cron run, not a gateway turn.
+
+ Keyed on the `cron_` prefix the scheduler mints at
+ `cron/scheduler.py:3017`. Matching a prefix rather than an exported
+ constant carries the usual drift risk: if upstream renames the shape,
+ this degrades to today's behaviour (cron tool rows may again be
+ misattributed to a sole live turn) rather than breaking anything.
+ """
+ return str(session_id or "").startswith("cron_")
+
+ @staticmethod
+ def _gateway_session_key() -> str:
+ """Read the turn's gateway routing key from Hermes' session context.
+
+ Returns `""` on any failure (older Hermes, no context bound, import
+ error) so callers fall through to their next resolution step.
+ """
+ try:
+ from gateway.session_context import get_session_env
+
+ return str(get_session_env("HERMES_SESSION_KEY", "") or "")
+ except Exception: # noqa: BLE001 - decorative activity must not raise
+ return ""
+
+ def emit_tool_started(
+ self,
+ session_id: str,
+ tool_name: str,
+ args: dict[str, Any],
+ tool_call_id: str = "",
+ ) -> None:
+ turn = self._turn_for_tool_hook(session_id)
+ if turn is None:
+ return
+ tool_item_id = item_id()
+ correlation_key = tool_call_id or tool_name
+ turn.tool_items[correlation_key] = tool_item_id
+ data = canonical_tool_data(tool_name, args)
+ payload: dict[str, Any] = {
+ "threadId": turn.thread_id,
+ "sessionId": turn.session_id,
+ "turnId": turn.turn_id,
+ "itemId": tool_item_id,
+ "itemType": canonical_tool_item_type(tool_name),
+ "status": "inProgress",
+ "title": tool_name,
+ }
+ if data is not None:
+ payload["data"] = data
+ self._schedule(
+ self._send_frame(
+ frame(
+ "item.started",
+ **payload,
+ )
+ )
+ )
+
+ def emit_tool_completed(
+ self,
+ session_id: str,
+ tool_name: str,
+ result: str,
+ duration_ms: int | None,
+ tool_call_id: str = "",
+ status: str = "",
+ ) -> None:
+ turn = self._turn_for_tool_hook(session_id)
+ if turn is None:
+ return
+ correlation_key = tool_call_id or tool_name
+ tool_item_id = turn.tool_items.pop(correlation_key, None) or item_id()
+ del result
+ payload: dict[str, Any] = {
+ "threadId": turn.thread_id,
+ "sessionId": turn.session_id,
+ "turnId": turn.turn_id,
+ "itemId": tool_item_id,
+ "itemType": canonical_tool_item_type(tool_name),
+ "status": "failed" if status == "error" else "completed",
+ "title": tool_name,
+ }
+ if duration_ms is not None:
+ payload["detail"] = f"Completed in {duration_ms} ms"
+ payload["data"] = {"durationMs": duration_ms}
+ self._schedule(
+ self._send_frame(
+ frame(
+ "item.completed",
+ **payload,
+ )
+ )
+ )
+
+ @classmethod
+ def route_tool_started(
+ cls,
+ tool_name: str,
+ args: dict[str, Any],
+ session_id: str,
+ tool_call_id: str = "",
+ ) -> None:
+ for instance in list(cls._instances):
+ instance.emit_tool_started(session_id, tool_name, args, tool_call_id)
+
+ @classmethod
+ def route_tool_completed(
+ cls,
+ tool_name: str,
+ result: str,
+ session_id: str,
+ duration_ms: int | None,
+ tool_call_id: str = "",
+ status: str = "",
+ ) -> None:
+ for instance in list(cls._instances):
+ instance.emit_tool_completed(
+ session_id,
+ tool_name,
+ result,
+ duration_ms,
+ tool_call_id,
+ status,
+ )
+
+ def _source(self, thread_id: str, message_id: str):
+ return self.build_source(
+ chat_id=thread_id,
+ chat_name=f"T3 thread {thread_id}",
+ chat_type="dm",
+ user_id="t3-code",
+ user_name="T3 Code",
+ message_id=message_id,
+ )
+
+ async def _send_frame(self, message: dict[str, Any]) -> None:
+ connection = self._connection
+ if connection is None:
+ raise ConnectionError("T3 Code gateway is offline")
+ await connection.send(message)
+
+ async def _send_status(self) -> None:
+ if self._connection is None or not self._connection.connected:
+ return
+ await self._send_frame(
+ frame(
+ "connection.status",
+ activeSessionCount=len(self._active_session_threads),
+ )
+ )
+
+ async def _handle_connection_state(
+ self, connected: bool, reason: str | None
+ ) -> None:
+ if connected:
+ self._mark_connected()
+ await self._send_status()
+ return
+ self._settle_pending_handoffs()
+ self._mark_disconnected()
+ if reason:
+ logger.warning("T3 gateway offline: %s", reason)
+
+ def _schedule(self, coroutine: Coroutine[Any, Any, Any]) -> None:
+ """Run a coroutine on the adapter's bound loop from any thread.
+
+ Hermes calls the tool hooks from the agent thread, so this is the
+ boundary back onto the gateway loop. `create_task` is only valid when
+ the *running* loop is the adapter's own loop — checking merely for "a
+ loop is running" would schedule onto whichever unrelated loop happens
+ to be current. Created tasks are held in a strong-reference set (asyncio
+ only holds a weak one) and their exceptions are logged rather than
+ surfacing as bare "task exception was never retrieved" warnings.
+ """
+ loop = self._event_loop
+ if loop is None or loop.is_closed():
+ coroutine.close()
+ return
+ try:
+ running_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+ if running_loop is loop:
+ task = loop.create_task(coroutine)
+ self._scheduled_tasks.add(task)
+ task.add_done_callback(self._finish_scheduled_task)
+ return
+ try:
+ asyncio.run_coroutine_threadsafe(coroutine, loop)
+ except RuntimeError: # loop closed between the check and the submit
+ coroutine.close()
+
+ def _finish_scheduled_task(self, task: asyncio.Task[Any]) -> None:
+ self._scheduled_tasks.discard(task)
+ if task.cancelled():
+ return
+ error = task.exception()
+ if error is not None:
+ logger.error("T3 gateway background task failed: %s", error, exc_info=error)
+
+
+def check_requirements() -> bool:
+ return dependency_available()
+
+
+def validate_config(config: PlatformConfig) -> bool:
+ extra = getattr(config, "extra", {}) or {}
+ return (
+ bool(extra.get("url") or os.environ.get(URL_ENV, ""))
+ and bool(extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, ""))
+ and bool(extra.get("credential") or os.environ.get(CREDENTIAL_ENV, ""))
+ )
+
+
+def env_enablement() -> dict[str, Any] | None:
+ """Seed `PlatformConfig.extra` from the environment at config-load time.
+
+ Called by the platform registry's env-enablement hook before the adapter is
+ constructed, so `gateway status` and `get_connected_platforms()` reflect an
+ env-only enrollment without instantiating a connection.
+
+ `home_channel` is a **magic key**, not an ordinary extra: core pops it out
+ of the returned dict and promotes it to a real `HomeChannel` dataclass on
+ the `PlatformConfig` (`gateway/config.py:2648-2660`, reading only
+ `chat_id` / `name` / `thread_id`). That promotion is what makes
+ `get_home_channel("t3")` resolve, which is in turn what makes
+ `send_message` with a bare `t3` target, the gateway's lifecycle broadcasts,
+ and `/handoff t3` work at all — core hardcodes env promotion only for
+ built-in platforms, so a plugin must supply it here. Pattern copied from
+ IRC (`plugins/platforms/irc/adapter.py:653-701`).
+
+ The thread id comes from `T3_HOME_CHANNEL`, which T3 owns: the plugin
+ rewrites it from `homeThreadId` on every `connection.accepted`. Before the
+ first accept there is nothing to seed and the key is simply absent — Hermes
+ then behaves exactly as it did pre-home-channel, which is why the
+ `/sethome` nudge suppression is still needed for that window.
+ """
+ url = os.environ.get(URL_ENV, "").strip()
+ instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()
+ credential = os.environ.get(CREDENTIAL_ENV, "").strip()
+ if not (url and instance_id and credential):
+ return None
+ seed: dict[str, Any] = {
+ "url": url,
+ "instance_id": instance_id,
+ "credential": credential,
+ "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes",
+ }
+ home = os.environ.get(HOME_CHANNEL_ENV, "").strip()
+ if home:
+ # T3 threads are the addressing unit end to end: `chat_id` IS the
+ # thread id, and the separate `thread_id` field stays unset. Setting
+ # both would make Hermes route `chat_id` + `thread_id` metadata at a
+ # platform whose `send(chat_id, ...)` already resolves the thread.
+ seed["home_channel"] = {"chat_id": home, "name": "Home"}
+ return seed
diff --git a/integrations/hermes-t3-gateway/cli.py b/integrations/hermes-t3-gateway/cli.py
new file mode 100644
index 000000000000..383f8dbbe966
--- /dev/null
+++ b/integrations/hermes-t3-gateway/cli.py
@@ -0,0 +1,125 @@
+"""`hermes t3 connect` enrollment command."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import os
+import sys
+
+from .connection import ConnectionRejected, enroll_once, websocket_url
+
+URL_ENV = "HERMES_T3_GATEWAY_URL"
+INSTANCE_ID_ENV = "HERMES_T3_GATEWAY_INSTANCE_ID"
+CREDENTIAL_ENV = "HERMES_T3_GATEWAY_CREDENTIAL"
+NICKNAME_ENV = "HERMES_T3_GATEWAY_NICKNAME"
+
+
+def _hermes_version() -> str:
+ try:
+ from hermes_cli import __version__
+
+ return str(__version__)
+ except Exception: # noqa: BLE001 - version discovery must not block enrollment
+ return "unknown"
+
+
+def register_cli(parser: argparse.ArgumentParser) -> None:
+ commands = parser.add_subparsers(dest="t3_command")
+ connect = commands.add_parser(
+ "connect",
+ help="Pair this Hermes process with a named T3 Code provider instance",
+ )
+ connect.add_argument(
+ "--url",
+ required=True,
+ help="T3 browser origin or explicit ws(s) gateway URL",
+ )
+ connect.add_argument(
+ "--token",
+ required=True,
+ help="Short-lived, one-time enrollment token generated by T3 Code",
+ )
+ connect.set_defaults(func=t3_command)
+
+ status = commands.add_parser("status", help="Show local T3 enrollment state")
+ status.set_defaults(func=t3_command)
+
+
+def t3_command(args) -> None:
+ command = getattr(args, "t3_command", None)
+ if command == "status":
+ _print_status()
+ return
+ if command != "connect":
+ print("Usage: hermes t3 connect --url --token ")
+ return
+
+ url = str(getattr(args, "url", "") or "").strip()
+ token = str(getattr(args, "token", "") or "").strip()
+ try:
+ normalized_url = websocket_url(url)
+ accepted = asyncio.run(
+ enroll_once(
+ url=normalized_url,
+ token=token,
+ hermes_version=_hermes_version(),
+ )
+ )
+ except ConnectionRejected as exc:
+ print(f"✗ T3 enrollment rejected ({exc.code}): {exc}")
+ raise SystemExit(1) from exc
+ except Exception as exc:
+ print(f"✗ Could not enroll with T3 Code: {exc}")
+ raise SystemExit(1) from exc
+
+ values = {
+ URL_ENV: normalized_url,
+ INSTANCE_ID_ENV: str(accepted["instanceId"]),
+ CREDENTIAL_ENV: str(accepted["credential"]),
+ NICKNAME_ENV: str(accepted.get("nickname") or "Hermes"),
+ }
+ try:
+ from hermes_cli.config import get_env_path, save_env_value
+
+ for key, value in values.items():
+ save_env_value(key, value)
+ env_path = get_env_path()
+ except Exception as exc:
+ print(f"✗ Enrollment succeeded, but credentials could not be saved: {exc}")
+ print(" The credential was not printed. Revoke and re-enroll this instance.")
+ raise SystemExit(1) from exc
+
+ # Mirror the newly written values into this process for status output and
+ # tests. A running gateway still needs a restart to construct the adapter.
+ os.environ.update(values)
+ print(f'✓ Connected Hermes to T3 Code as "{values[NICKNAME_ENV]}"')
+ print(f" Instance: {values[INSTANCE_ID_ENV]}")
+ print(f" Gateway: {normalized_url}")
+ print(f" Saved the credential securely in {env_path} (value hidden).")
+ print(" Restart `hermes gateway` to activate the connection.")
+
+
+def _print_status() -> None:
+ url = os.environ.get(URL_ENV, "").strip()
+ instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()
+ credential = os.environ.get(CREDENTIAL_ENV, "").strip()
+ nickname = os.environ.get(NICKNAME_ENV, "").strip() or "Hermes"
+ if not (url and instance_id and credential):
+ print("T3 Code: not enrolled")
+ print("Run: hermes t3 connect --url --token ")
+ return
+ print(f"T3 Code: enrolled as {nickname}")
+ print(f" Instance: {instance_id}")
+ print(f" Gateway: {url}")
+ print(" Credential: configured (hidden)")
+
+
+if __name__ == "__main__": # pragma: no cover - executable fallback
+ parser = argparse.ArgumentParser(prog="python -m hermes_t3_gateway.cli")
+ register_cli(parser)
+ parsed = parser.parse_args()
+ if not hasattr(parsed, "func"):
+ parser.print_help()
+ sys.exit(2)
+ parsed.func(parsed)
diff --git a/integrations/hermes-t3-gateway/connection.py b/integrations/hermes-t3-gateway/connection.py
new file mode 100644
index 000000000000..9c669dc0f302
--- /dev/null
+++ b/integrations/hermes-t3-gateway/connection.py
@@ -0,0 +1,379 @@
+"""Outbound authenticated WebSocket connection to a T3 Code server."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from collections.abc import Awaitable, Callable
+from contextlib import suppress
+from typing import Any
+from urllib.parse import urlsplit, urlunsplit
+
+from .protocol import PROTOCOL_VERSION, WEBSOCKET_PATH, connection_hello, iso_now
+
+logger = logging.getLogger(__name__)
+
+try:
+ import websockets
+except ImportError: # pragma: no cover - Hermes currently installs websockets
+ websockets = None
+
+MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]
+StateHandler = Callable[[bool, str | None], Awaitable[None] | None]
+AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]]
+
+
+class ConnectionRejected(RuntimeError):
+ def __init__(self, code: str, message: str):
+ super().__init__(message)
+ self.code = code
+
+
+def websocket_url(url: str) -> str:
+ """Normalize an HTTP(S) browser origin or WS(S) URL to the gateway route."""
+ raw = (url or "").strip()
+ parsed = urlsplit(raw)
+ scheme = parsed.scheme.lower()
+ if scheme == "https":
+ scheme = "wss"
+ elif scheme == "http":
+ scheme = "ws"
+ if scheme not in {"ws", "wss"} or not parsed.netloc:
+ raise ValueError("URL must use http://, https://, ws://, or wss://")
+ path = parsed.path.rstrip("/")
+ if path != WEBSOCKET_PATH:
+ path = WEBSOCKET_PATH
+ return urlunsplit((scheme, parsed.netloc, path, "", ""))
+
+
+def dependency_available() -> bool:
+ return websockets is not None
+
+
+async def _open_socket(url: str):
+ if websockets is None:
+ raise RuntimeError(
+ "The `websockets` package is unavailable. Install the standard "
+ "Hermes Agent dependencies and retry."
+ )
+ return await websockets.connect( # type: ignore[union-attr]
+ websocket_url(url),
+ open_timeout=20,
+ ping_interval=20,
+ ping_timeout=20,
+ close_timeout=5,
+ # Protocol v4 turn frames may carry inline base64 attachments up to
+ # 25MB raw (~34MB encoded, `protocol.MAX_MEDIA_BYTES`). 64MB leaves
+ # room for the JSON envelope and T3's per-turn total while still
+ # bounding a pathological frame.
+ max_size=64 * 1024 * 1024,
+ )
+
+
+async def authenticate_socket(
+ socket: Any,
+ *,
+ authentication: dict[str, str],
+ hermes_version: str,
+ timeout: float = 20,
+ role: str = "gateway",
+) -> dict[str, Any]:
+ hello = connection_hello(
+ hermes_version=hermes_version,
+ authentication=authentication,
+ role=role,
+ )
+ await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False))
+
+ # Read until the reply to THIS hello arrives. The handshake is not
+ # guaranteed to be the only frame in flight — the server may already be
+ # probing liveness — and treating whatever arrives first as the reply
+ # tears down the connection that was just established, in a loop.
+ deadline = asyncio.get_running_loop().time() + timeout
+ while True:
+ remaining = deadline - asyncio.get_running_loop().time()
+ if remaining <= 0:
+ raise TimeoutError("T3 did not answer the gateway handshake in time")
+ raw = await asyncio.wait_for(socket.recv(), timeout=remaining)
+ message = json.loads(raw)
+ if not isinstance(message, dict):
+ raise TypeError("T3 returned a non-object handshake frame")
+ if message.get("type") == "ping":
+ # Answer inline: the read loop that normally handles this has not
+ # started yet, and an unanswered ping counts against liveness.
+ await socket.send(
+ json.dumps(
+ {
+ "type": "pong",
+ "protocolVersion": PROTOCOL_VERSION,
+ "requestId": message.get("requestId"),
+ "sentAt": message.get("sentAt") or iso_now(),
+ },
+ separators=(",", ":"),
+ ensure_ascii=False,
+ )
+ )
+ continue
+ if message.get("requestId") != hello["requestId"]:
+ # Some other correlated frame raced the handshake; keep waiting for
+ # ours rather than failing the whole connection.
+ logger.debug(
+ "Ignoring a non-handshake frame while authenticating: %s",
+ message.get("type"),
+ )
+ continue
+ break
+ if message.get("type") == "connection.rejected":
+ raise ConnectionRejected(
+ str(message.get("code") or "internal-error"),
+ str(message.get("message") or "T3 rejected the gateway connection"),
+ )
+ if message.get("type") != "connection.accepted":
+ raise RuntimeError(
+ f"expected connection.accepted, received {message.get('type')!r}"
+ )
+ if message.get("protocolVersion") != PROTOCOL_VERSION:
+ raise RuntimeError("T3 accepted the connection with an incompatible version")
+ return message
+
+
+async def enroll_once(
+ *,
+ url: str,
+ token: str,
+ hermes_version: str,
+) -> dict[str, Any]:
+ socket = await _open_socket(url)
+ try:
+ accepted = await authenticate_socket(
+ socket,
+ authentication={"type": "enrollment-token", "token": token},
+ hermes_version=hermes_version,
+ )
+ if not accepted.get("instanceId") or not accepted.get("credential"):
+ raise RuntimeError(
+ "T3 accepted enrollment without returning an instance credential"
+ )
+ return accepted
+ finally:
+ await socket.close()
+
+
+class T3GatewayConnection:
+ """Reconnectable runtime connection authenticated by an instance credential."""
+
+ def __init__(
+ self,
+ *,
+ url: str,
+ instance_id: str,
+ credential: str,
+ hermes_version: str,
+ on_message: MessageHandler,
+ on_state: StateHandler | None = None,
+ on_accepted: AcceptedHandler | None = None,
+ ):
+ self.url = websocket_url(url)
+ self.instance_id = instance_id
+ self.credential = credential
+ self.hermes_version = hermes_version
+ self._on_message = on_message
+ self._on_state = on_state
+ self._on_accepted = on_accepted
+ self._socket: Any = None
+ self._supervisor: asyncio.Task[None] | None = None
+ self._send_lock = asyncio.Lock()
+ self._connected = asyncio.Event()
+ self._first_result: asyncio.Future[bool] | None = None
+ self._handlers: set[asyncio.Task[None]] = set()
+ self._stopping = False
+
+ @property
+ def connected(self) -> bool:
+ return self._connected.is_set()
+
+ async def connect(self, timeout: float = 30) -> bool:
+ if self._supervisor is not None and not self._supervisor.done():
+ return self.connected
+ self._stopping = False
+ self._first_result = asyncio.get_running_loop().create_future()
+ self._supervisor = asyncio.create_task(
+ self._supervise(), name="hermes-t3-gateway"
+ )
+ try:
+ return await asyncio.wait_for(asyncio.shield(self._first_result), timeout)
+ except TimeoutError:
+ await self.disconnect()
+ return False
+
+ async def disconnect(self) -> None:
+ self._stopping = True
+ self._connected.clear()
+ if self._socket is not None:
+ with suppress(Exception):
+ await self._socket.close()
+ self._socket = None
+ if self._supervisor is not None:
+ self._supervisor.cancel()
+ with suppress(asyncio.CancelledError):
+ await self._supervisor
+ self._supervisor = None
+ # Command handlers can outlive the read loop (a turn handler commonly
+ # waits on Hermes for minutes). They must not continue mutating adapter
+ # state after consumers have observed the disconnected notification.
+ handlers = tuple(self._handlers)
+ for task in handlers:
+ task.cancel()
+ if handlers:
+ await asyncio.gather(*handlers, return_exceptions=True)
+ await self._notify_state(False, None)
+
+ async def send(self, message: dict[str, Any]) -> None:
+ if not self.connected or self._socket is None:
+ raise ConnectionError("T3 Code gateway is offline")
+ encoded = json.dumps(message, separators=(",", ":"), ensure_ascii=False)
+ async with self._send_lock:
+ await self._socket.send(encoded)
+
+ def _spawn_handler(self, message: dict[str, Any]) -> None:
+ """Run one command handler off the read loop.
+
+ asyncio only holds a weak reference to tasks, so the handle is kept
+ until completion — otherwise a long turn can be garbage collected
+ mid-flight. Failures are logged rather than surfacing as bare
+ "task exception was never retrieved" warnings.
+ """
+ task = asyncio.create_task(self._on_message(message))
+ self._handlers.add(task)
+
+ def _finished(completed: asyncio.Task[None]) -> None:
+ self._handlers.discard(completed)
+ if completed.cancelled():
+ return
+ error = completed.exception()
+ if error is not None:
+ logger.warning(
+ "T3 gateway command handler failed: %s", error, exc_info=error
+ )
+
+ task.add_done_callback(_finished)
+
+ async def _send_pong(self, ping: dict[str, Any]) -> None:
+ """Answer a liveness probe without going through command dispatch."""
+ request_id = ping.get("requestId")
+ if not request_id:
+ return
+ try:
+ await self.send(
+ {
+ "type": "pong",
+ "protocolVersion": PROTOCOL_VERSION,
+ "requestId": request_id,
+ "sentAt": ping.get("sentAt") or iso_now(),
+ }
+ )
+ except Exception: # noqa: BLE001 - a failed pong must not kill the read loop
+ logger.debug("Failed to answer a T3 liveness ping", exc_info=True)
+
+ async def _supervise(self) -> None:
+ delay = 1.0
+ while not self._stopping:
+ reason: str | None = None
+ accepted_task: asyncio.Task[None] | None = None
+ try:
+ socket = await _open_socket(self.url)
+ self._socket = socket
+ accepted = await authenticate_socket(
+ socket,
+ authentication={
+ "type": "instance-credential",
+ "instanceId": self.instance_id,
+ "credential": self.credential,
+ },
+ hermes_version=self.hermes_version,
+ )
+ self._connected.set()
+ if self._first_result is not None and not self._first_result.done():
+ self._first_result.set_result(True)
+ # Deliberately after `_connected.set()`: the accepted callback
+ # reconciles the home designation and flushes the durable
+ # delivery queue, and both send frames back over this socket.
+ # It must not run *before* the read loop, though: a large media
+ # backlog can apply send backpressure while its acknowledgements
+ # and liveness pings wait unread on the same socket. Running it
+ # as a generation-local task lets the loop consume both.
+ accepted_task = asyncio.create_task(
+ self._notify_accepted(accepted), name="hermes-t3-accepted"
+ )
+ await self._notify_state(True, None)
+ delay = 1.0
+ async for raw in socket:
+ message = json.loads(raw)
+ if not isinstance(message, dict):
+ continue
+ # Liveness is answered inline; it never touches Hermes.
+ if message.get("type") == "ping":
+ await self._send_pong(message)
+ continue
+ # Commands are dispatched WITHOUT awaiting them. A handler
+ # awaits Hermes — `turn.start` blocks for the whole agent
+ # turn — and awaiting it here would stop reading the
+ # socket, so a ping sent mid-turn would not even be read,
+ # let alone answered, and T3 would close a healthy
+ # connection as half-open. Ordering within a session is
+ # still preserved by the plugin's own per-thread state.
+ self._spawn_handler(message)
+ except asyncio.CancelledError:
+ raise
+ except ConnectionRejected as exc:
+ reason = f"{exc.code}: {exc}"
+ if self._first_result is not None and not self._first_result.done():
+ self._first_result.set_exception(exc)
+ # Revoked credentials and version mismatches need operator
+ # action; reconnecting the same secret can never recover.
+ if exc.code in {
+ "instance-revoked",
+ "invalid-authentication",
+ "version-incompatible",
+ }:
+ self._stopping = True
+ except Exception as exc: # noqa: BLE001 - reconnect every transient transport failure
+ reason = str(exc)
+ logger.warning("T3 gateway connection dropped: %s", exc)
+ finally:
+ if accepted_task is not None and not accepted_task.done():
+ accepted_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await accepted_task
+ self._connected.clear()
+ self._socket = None
+ await self._notify_state(False, reason)
+ if self._stopping:
+ break
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, 30.0)
+
+ async def _notify_accepted(self, accepted: dict[str, Any]) -> None:
+ """Hand the `connection.accepted` frame to the adapter, best-effort.
+
+ A failure here — a read-only `.env`, an unwritable queue file — must
+ not tear down a connection that authenticated successfully, so it is
+ logged and swallowed exactly like the state callback.
+ """
+ if self._on_accepted is None:
+ return
+ try:
+ await self._on_accepted(accepted)
+ except Exception: # noqa: BLE001 - reconciliation must not fail a good handshake
+ logger.warning("T3 connection accepted callback failed", exc_info=True)
+
+ async def _notify_state(self, connected: bool, reason: str | None) -> None:
+ if self._on_state is None:
+ return
+ try:
+ result = self._on_state(connected, reason)
+ if asyncio.iscoroutine(result):
+ await result
+ except Exception:
+ logger.debug("T3 connection state callback failed", exc_info=True)
diff --git a/integrations/hermes-t3-gateway/coreshim.py b/integrations/hermes-t3-gateway/coreshim.py
new file mode 100644
index 000000000000..6ce3deca3ca0
--- /dev/null
+++ b/integrations/hermes-t3-gateway/coreshim.py
@@ -0,0 +1,328 @@
+"""In-process compensation for two upstream `send_message` media defects.
+
+Both defects live in Hermes core's `tools/send_message_tool.py` and both are
+about plugin platforms that *can* carry media but are not on core's hard-coded
+prose list. Line numbers below are against **Hermes v0.19.0**.
+
+**Bug A — the false warning (cosmetic, but it lies to the agent).**
+`tools/send_message_tool.py:1108-1113` precomputes::
+
+ warning = f"MEDIA attachments were omitted for {platform.value}; ..."
+
+whenever `media_files` is non-empty and the platform is not one of the nine
+names spelled out in that string. Line 1154-1157 then appends it to *any*
+successful result without ever consulting whether the send actually dropped
+anything. Our standalone sender delivers media as `media.deliver` frames and
+waits for the acks — and is then told, in the same JSON blob, that the files
+were omitted. A live agent read that warning and reported a delivery failure to
+the user for files T3 had already rendered.
+
+**Bug B — the silent drop (the one that actually loses data).**
+`tools/send_message_tool.py:711-732`: when the gateway is co-resident, the
+runner weakref resolves and `runner.adapters.get(platform)` returns our live
+adapter, so core calls::
+
+ result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata)
+
+and returns. `media_files` is never passed. It cannot be recovered from
+`content` either, because `BasePlatformAdapter.extract_media`
+(`tools/send_message_tool.py:442`) already stripped the `MEDIA:` directives out
+of the text before chunking. So `send_message` / `hermes send` with attachments,
+run in the same process as `hermes gateway`, loses the files with no error and
+no warning. This went unnoticed in live testing only because `hermes send` ran
+out-of-process, where the runner weakref is empty and core falls through to the
+`standalone_sender_fn` path (line 731+), which does pass `media_files`.
+
+**What this module does.** At plugin load it wraps the two functions in the
+already-imported `tools.send_message_tool` module object:
+
+* `_send_via_adapter` — for platform `t3` with non-empty `media_files`, skip
+ core's live-adapter shortcut entirely and call our own `standalone_send`
+ (from `.home`), which handles media correctly. Text-only `t3` sends and every
+ other platform reach the original function untouched.
+* `_send_to_platform` — post-process the result, dropping the Bug A warning from
+ `result["warnings"]`. Matched by the stable prefix `"MEDIA attachments were
+ omitted for t3"`, never the full prose: the nine-platform list inside that
+ sentence changes between releases, and matching it exactly would silently
+ stop working on the next upgrade.
+
+**Removable.** Both wrappers become dead weight the moment upstream grows
+capability-driven media handling — i.e. once `PlatformEntry` can advertise
+"this platform delivers media" and core consults it instead of the hard-coded
+list, and once the co-resident branch forwards `media_files` to `adapter.send`.
+Delete this module and its `register()` call at that point; nothing else in the
+plugin depends on it.
+
+**Fail-open contract.** This module never raises into the plugin's `register()`
+and never makes a working Hermes worse:
+
+* Every patch feature-detects its target first — the module must be importable,
+ the attribute must exist, it must be a coroutine function, and its signature
+ must carry the parameters we rely on. Any mismatch means no patch.
+* On any failure we log exactly one warning and leave core untouched. The
+ fallback is the current upstream behaviour: buggy, but working and known.
+* An upstream upgrade that renames, re-signatures, or restructures these
+ functions therefore degrades to "unpatched", never to a crash.
+* Applying twice is a no-op — the wrappers carry a marker attribute.
+
+Even fully failed open, the residual damage is bounded: `standalone_send`
+stamps `media_count` / `acked_count` / a delivered-count `note` onto every
+success result (see `home.py`), so the false warning always sits next to
+counter-evidence. The un-compensated Bug B remains a real silent drop, which is
+why the patch is attempted at all.
+"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+from typing import Any, Callable
+
+logger = logging.getLogger(__name__)
+
+PLATFORM = "t3"
+
+# Bug A's warning text, matched by prefix only. The remainder of the sentence
+# names the platforms core believes support media, and that list has changed
+# across releases — pinning the full prose would silently stop matching. The
+# trailing ";" is part of the prefix and load-bearing: without it the match is
+# also satisfied by a platform whose name merely *starts with* "t3".
+_OMISSION_WARNING_PREFIX = f"MEDIA attachments were omitted for {PLATFORM};"
+
+# Marker set on every wrapper we install, so a second `register()` (or a
+# `discover_plugins(force=True)` rescan) does not stack wrappers on wrappers.
+_MARKER = "_t3_gateway_shim"
+
+
+def _platform_name(platform: Any) -> str:
+ """Core passes a `Platform` enum member; be liberal about what we accept."""
+ value = getattr(platform, "value", platform)
+ return str(value or "").strip().lower()
+
+
+def _target_module() -> Any | None:
+ """Return the upstream module, or None when it cannot be imported.
+
+ Imported lazily and defensively: this plugin is loaded by Hermes itself, so
+ the module is normally already in `sys.modules` and this is a dict hit, but
+ a stripped or restructured install must degrade to "no patch" rather than
+ breaking plugin registration.
+ """
+ try:
+ import tools.send_message_tool as module
+
+ return module
+ except Exception: # noqa: BLE001 - any import failure means "do not patch"
+ logger.warning(
+ "T3 gateway: tools.send_message_tool is unavailable; leaving core "
+ "send_message unpatched (media may be dropped on the co-resident "
+ "path and a false omission warning may appear)",
+ exc_info=True,
+ )
+ return None
+
+
+def _usable(module: Any, name: str, required_params: tuple[str, ...]) -> Callable | None:
+ """Feature-detect one patch target. Returns the function, or None.
+
+ Checks, in order: the attribute exists, it is a coroutine function (we wrap
+ it with `async def`, so a sync target would break every caller), it is not
+ already wrapped, and its signature exposes the parameters this shim reads by
+ name. A `*args, **kwargs`-style signature is accepted only if the named
+ parameters are genuinely present — we never guess positionally.
+ """
+ original = getattr(module, name, None)
+ if original is None:
+ logger.warning(
+ "T3 gateway: %s.%s is missing; leaving it unpatched", module.__name__, name
+ )
+ return None
+ if getattr(original, _MARKER, False):
+ return None # already patched; idempotent no-op, nothing to report
+ if not inspect.iscoroutinefunction(original):
+ logger.warning(
+ "T3 gateway: %s.%s is not a coroutine function; leaving it unpatched",
+ module.__name__,
+ name,
+ )
+ return None
+ try:
+ parameters = inspect.signature(original).parameters
+ except (TypeError, ValueError):
+ logger.warning(
+ "T3 gateway: %s.%s has an unreadable signature; leaving it unpatched",
+ module.__name__,
+ name,
+ exc_info=True,
+ )
+ return None
+ missing = [param for param in required_params if param not in parameters]
+ if missing:
+ logger.warning(
+ "T3 gateway: %s.%s no longer takes %s; leaving it unpatched (upstream "
+ "may have fixed this, or changed shape)",
+ module.__name__,
+ name,
+ ", ".join(missing),
+ )
+ return None
+ return original
+
+
+def _patch_send_via_adapter(module: Any) -> bool:
+ """Bug B: route co-resident `t3` media sends through our own sender."""
+ original = _usable(
+ module,
+ "_send_via_adapter",
+ ("platform", "pconfig", "chat_id", "chunk", "thread_id", "media_files"),
+ )
+ if original is None:
+ return False
+
+ from .home import standalone_send
+ signature = inspect.signature(original)
+
+ async def _send_via_adapter(*args, **kwargs):
+ bound = signature.bind(*args, **kwargs)
+ bound.apply_defaults()
+ values = bound.arguments
+ platform = values["platform"]
+ media_files = values["media_files"]
+ if _platform_name(platform) == PLATFORM and media_files:
+ # Core would hand this to `adapter.send(chat_id, content, metadata)`
+ # and drop `media_files` on the floor. Our sender takes the whole
+ # send — text frame plus one media frame per file — over a
+ # short-lived `role: "delivery"` socket, which by design cannot
+ # displace the live gateway connection sitting in the same process.
+ return await standalone_send(
+ values["pconfig"],
+ values["chat_id"],
+ values["chunk"],
+ thread_id=values.get("thread_id"),
+ media_files=media_files,
+ force_document=values.get("force_document", False),
+ )
+ return await original(*args, **kwargs)
+
+ setattr(_send_via_adapter, _MARKER, True)
+ _send_via_adapter.__wrapped__ = original
+ module._send_via_adapter = _send_via_adapter
+ return True
+
+
+def _strip_false_warning(result: Any) -> Any:
+ """Drop Bug A's warning from a result dict, in place. Anything else passes."""
+ if not isinstance(result, dict):
+ return result
+ warnings = result.get("warnings")
+ if not isinstance(warnings, list):
+ return result
+ kept = [
+ warning
+ for warning in warnings
+ if not (
+ isinstance(warning, str)
+ and warning.startswith(_OMISSION_WARNING_PREFIX)
+ )
+ ]
+ if len(kept) == len(warnings):
+ return result
+ if kept:
+ result["warnings"] = kept
+ else:
+ # An empty list would still read as "this send had warnings" to a
+ # skimming agent; the key is optional upstream, so remove it.
+ result.pop("warnings", None)
+ return result
+
+
+def _patch_send_to_platform(module: Any) -> bool:
+ """Bug A: strip the unconditional omission warning, and rescue media-only sends.
+
+ Two interceptions, both scoped to media-bearing `t3` sends:
+
+ *Before* the original, one narrow bypass. A send with attachments and no
+ text hard-errors at `tools/send_message_tool.py:1101-1107` (v0.19.0)::
+
+ if media_files and not message.strip():
+ return {"error": "... target t3 had only media attachments"}
+
+ That check sits above the chunk loop, so the send never reaches
+ `_send_via_adapter` and the Bug B patch cannot see it. `MEDIA:/tmp/x.png`
+ with no prose — a perfectly ordinary agent send — fails outright. We route
+ it straight to our sender, which already handles an empty message by
+ emitting media frames only. Chunking is not skipped in any meaningful sense:
+ there is no text to chunk.
+
+ *After* the original, warning removal. Post-processing is the least invasive
+ seam available: `_send_to_platform` is a ~380-line router whose warning is
+ computed at line 1111 and attached at line 1154 with nothing interceptable
+ in between. The original runs in full and we drop only the one warning we
+ know to be false; every other warning it may add survives, and non-`t3`
+ results are returned without even being inspected.
+
+ A success from our sender means the frames were handed over — acked, or
+ durably queued for the next connect (`media_count` / `acked_count` on the
+ result say which). Nothing deliverable is reported as a hard `error`.
+ """
+ original = _usable(
+ module,
+ "_send_to_platform",
+ ("platform", "pconfig", "chat_id", "message", "thread_id", "media_files"),
+ )
+ if original is None:
+ return False
+
+ from .home import standalone_send
+ signature = inspect.signature(original)
+
+ async def _send_to_platform(*args, **kwargs):
+ bound = signature.bind(*args, **kwargs)
+ bound.apply_defaults()
+ values = bound.arguments
+ platform = values["platform"]
+ media_files = values["media_files"]
+ message = values["message"]
+ is_t3_media = _platform_name(platform) == PLATFORM and bool(media_files)
+ if is_t3_media and not str(message or "").strip():
+ return await standalone_send(
+ values["pconfig"],
+ values["chat_id"],
+ message or "",
+ thread_id=values.get("thread_id"),
+ media_files=media_files,
+ force_document=values.get("force_document", False),
+ )
+ result = await original(*args, **kwargs)
+ if not is_t3_media:
+ return result
+ if isinstance(result, dict) and result.get("success"):
+ return _strip_false_warning(result)
+ return result
+
+ setattr(_send_to_platform, _MARKER, True)
+ _send_to_platform.__wrapped__ = original
+ module._send_to_platform = _send_to_platform
+ return True
+
+
+def apply(module: Any | None = None) -> dict[str, bool]:
+ """Install both wrappers. Never raises.
+
+ Returns a per-patch applied/not-applied map, which is what the tests assert
+ on. `module` is injectable so tests can drive a faithful fake of the
+ upstream shape without importing Hermes.
+ """
+ applied = {"_send_via_adapter": False, "_send_to_platform": False}
+ try:
+ target = module if module is not None else _target_module()
+ if target is None:
+ return applied
+ applied["_send_via_adapter"] = _patch_send_via_adapter(target)
+ applied["_send_to_platform"] = _patch_send_to_platform(target)
+ except Exception: # noqa: BLE001 - a broken shim must not break the plugin
+ logger.warning(
+ "T3 gateway: could not patch core send_message; leaving it unpatched",
+ exc_info=True,
+ )
+ return applied
diff --git a/integrations/hermes-t3-gateway/home.py b/integrations/hermes-t3-gateway/home.py
new file mode 100644
index 000000000000..4e660674307f
--- /dev/null
+++ b/integrations/hermes-t3-gateway/home.py
@@ -0,0 +1,950 @@
+"""Home-channel delivery: durable queue, classification, standalone sender.
+
+Hermes-initiated output — cron results, the agent's `send_message` tool with a
+bare `t3` target, gateway lifecycle notices, `/handoff t3` — has no T3-issued
+turn to stream into. It is delivered as a `home.deliver` frame against the
+instance's durable **home thread**, whose id T3 owns and republishes on every
+`connection.accepted`.
+
+Three concerns live here rather than in the adapter:
+
+* **The durable queue.** A delivery is written to disk before it is sent and
+ removed only when T3 acknowledges it, so nothing is lost when either side
+ restarts mid-flight. T3 dedupes on `deliveryId`, which is what makes
+ re-flushing the whole queue safe.
+* **Kind/label classification.** Best-effort provenance recovery from the send
+ context — see `classify_delivery`.
+* **The standalone sender.** Out-of-process cron has no live adapter, so it
+ dials T3 itself over a short-lived `role: "delivery"` socket.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import mimetypes
+import os
+import tempfile
+import threading
+from pathlib import Path
+from typing import Any
+
+from .protocol import (
+ HOME_DELIVERY_KINDS,
+ PROTOCOL_VERSION,
+ delivery_id,
+ home_deliver,
+ iso_now,
+ media_deliver,
+)
+
+logger = logging.getLogger(__name__)
+
+try: # POSIX advisory locking; absent on Windows.
+ import fcntl
+except ImportError: # pragma: no cover - Windows fallback
+ fcntl = None # type: ignore[assignment]
+
+# The env var carrying T3's home-thread designation.
+#
+# The name is NOT arbitrary. Hermes resolves a platform's cron home target with
+# `_home_target_env_var` (`gateway/run.py:1541`), which falls back to
+# `f"{PLATFORM.upper()}_HOME_CHANNEL"` for any platform without an override
+# entry. Platform `t3` therefore resolves to exactly this string, so the
+# `send_message` error hints, `/sethome` messaging, and cron's env-only
+# resolution path all agree with the value the plugin writes — with no upstream
+# override table entry required.
+HOME_CHANNEL_ENV = "T3_HOME_CHANNEL"
+
+# Queue location. `gateway/` under the Hermes home is the subdirectory Hermes'
+# own bundled plugins use for per-profile adapter state (the Discord adapter's
+# command-sync and non-conversational stores, `plugins/platforms/discord/
+# adapter.py:52`, `:272`, `:1694`). Using `get_hermes_home()` as the base means
+# the queue is profile-scoped for free: a second profile with its own
+# `HERMES_HOME` gets its own queue rather than replaying another profile's
+# deliveries into its own home thread.
+QUEUE_SUBDIR = "gateway"
+QUEUE_FILENAME = "t3_home_delivery_queue.jsonl"
+
+# Bound the queue. Deliveries are small (a cron brief, a lifecycle line), so a
+# few hundred is generous for any realistic outage while keeping the file
+# readable and the flush bounded. Overflow drops the OLDEST entries: a
+# fortnight-old cron brief is worth less than this morning's, and dropping
+# newest would make a wedged queue permanently swallow current output.
+MAX_QUEUE_ENTRIES = 300
+
+# Media frames carry base64 bytes, so an entry-count cap alone is not a disk
+# bound: 300 maximum-sized frames would exceed 10GiB. Keep enough room for
+# several full-size files and ordinary text history without letting a wedged
+# companion consume a Hermes host's disk indefinitely.
+MAX_QUEUE_BYTES = 256 * 1024 * 1024
+
+# Bound one flush attempt. A reconnect must not spend minutes replaying before
+# the connection is usable for live traffic; the remainder rides the next
+# reconnect.
+MAX_FLUSH_PER_CONNECT = 50
+MAX_FLUSH_BYTES_PER_CONNECT = 100 * 1024 * 1024
+
+
+def hermes_home() -> Path:
+ """Resolve Hermes' home directory, degrading to the documented default.
+
+ Prefers Hermes' own accessor so a context-local profile override
+ (`set_hermes_home_override`) is honoured, then `HERMES_HOME`, then the
+ platform default. The plugin must not create state outside the active
+ profile, but it also must not fail to queue a delivery merely because
+ Hermes could not be imported (the standalone cron path can run in a very
+ thin process).
+ """
+ try:
+ from hermes_cli.config import get_hermes_home
+
+ return Path(get_hermes_home())
+ except Exception: # noqa: BLE001 - queueing must never depend on Hermes importing
+ pass
+ try:
+ from hermes_constants import get_hermes_home
+
+ return Path(get_hermes_home())
+ except Exception: # noqa: BLE001 - same
+ pass
+ override = os.environ.get("HERMES_HOME", "").strip()
+ return Path(override) if override else Path.home() / ".hermes"
+
+
+def queue_path() -> Path:
+ return hermes_home() / QUEUE_SUBDIR / QUEUE_FILENAME
+
+
+def home_thread_id() -> str:
+ """Read the currently designated home thread from the environment."""
+ return os.environ.get(HOME_CHANNEL_ENV, "").strip()
+
+
+def save_home_thread_id(thread_id: str) -> bool:
+ """Persist T3's home designation, mirroring it into this process.
+
+ T3's settings blob is authoritative and this env var is a synced cache, so
+ a differing local value is overwritten rather than merged — including one a
+ user hand-edited (documented in the plugin README).
+
+ Returns True when the value was durably written. A read-only or managed
+ `.env` degrades to the in-process mirror only: routing works for the life
+ of this gateway and re-reconciles on the next connect.
+ """
+ value = str(thread_id or "").strip()
+ if not value:
+ return False
+ saved = False
+ try:
+ from hermes_cli.config import save_env_value
+
+ save_env_value(HOME_CHANNEL_ENV, value)
+ saved = True
+ except Exception as exc: # noqa: BLE001 - a read-only .env must not break the handshake
+ # No stack trace: outside a Hermes install this is simply
+ # ModuleNotFoundError for hermes_cli, which is expected and noisy.
+ logger.warning(
+ "Could not persist %s (%s); T3 home delivery will use the "
+ "in-process value until the next reconnect",
+ HOME_CHANNEL_ENV,
+ exc,
+ )
+ # Mirror into this process exactly as enrollment does (`cli.py`): the
+ # running gateway resolves the home channel from the environment and must
+ # not need a restart to see a freshly designated thread.
+ os.environ[HOME_CHANNEL_ENV] = value
+ return saved
+
+
+class HomeDeliveryQueue:
+ """Append-only JSONL outbox of unacknowledged delivery frames.
+
+ Entries are stored as raw wire frames keyed on `deliveryId`, so the queue
+ carries `home.deliver` and `media.deliver` alike: flushing replays the
+ frame verbatim and T3 discriminates on `type`. A media entry is large (up
+ to ~34MB of base64 on one line), so the entry cap doubles as a coarse disk
+ bound; a genuinely wedged connection under heavy media output trades disk
+ for durability, which is the documented preference.
+
+ Correctness rests on one rule: an entry is removed **only** when T3 acks
+ its `deliveryId`. Everything else — a socket that dropped mid-send, a
+ server that died before writing, a plugin that restarted — leaves the entry
+ on disk to be replayed. Replay is safe because T3 dedupes on `deliveryId`,
+ so the failure mode of this design is a duplicate suppressed server-side,
+ never a lost delivery.
+
+ Two processes can hold the same queue: the gateway adapter and an
+ out-of-process cron run using the standalone sender. Writes take a POSIX
+ advisory lock on a sidecar file where `fcntl` is available; on Windows the
+ in-process lock alone applies and a concurrent cron process could in
+ principle interleave a rewrite. The consequence there is a duplicate
+ delivery (deduped by T3), not corruption of an already-acked entry.
+ """
+
+ def __init__(
+ self,
+ path: Path | None = None,
+ max_entries: int = MAX_QUEUE_ENTRIES,
+ max_bytes: int = MAX_QUEUE_BYTES,
+ ):
+ self._path = Path(path) if path is not None else None
+ self._max_entries = max(1, int(max_entries))
+ self._max_bytes = max(1, int(max_bytes))
+ self._lock = threading.RLock()
+
+ @property
+ def path(self) -> Path:
+ # Resolved lazily, not in __init__: `get_hermes_home()` honours a
+ # context-local profile override that may be installed after the
+ # adapter is constructed.
+ return self._path if self._path is not None else queue_path()
+
+ def _ensure_parent(self) -> None:
+ # 0700: queued frames carry message text and base64 media, so the
+ # outbox must not be readable by other users of the machine. Only a
+ # directory this call creates is affected — an existing `gateway/` is
+ # shared with Hermes' own plugin state and is not re-permissioned here.
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+
+ def _read_lines(self) -> list[dict[str, Any]]:
+ """Parse every queued entry. Raises `OSError` when the file is unreadable.
+
+ The distinction callers depend on: a MISSING file is an empty queue,
+ while an unreadable one is an unknown queue. Collapsing the two into
+ `[]` is what would let a rewrite-based caller overwrite live entries it
+ merely failed to read.
+ """
+ path = self.path
+ if not path.exists():
+ return []
+ raw = path.read_text(encoding="utf-8")
+ entries: list[dict[str, Any]] = []
+ lines = raw.splitlines(keepends=True)
+ for index, raw_line in enumerate(lines):
+ line = raw_line.strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ except ValueError:
+ # Only an unterminated final line can be a killed append that
+ # never returned success. Interior or newline-terminated
+ # corruption is an unknown queue, and callers must not rewrite
+ # it from a partial parse and destroy still-live deliveries.
+ if index == len(lines) - 1 and not raw_line.endswith(("\n", "\r")):
+ logger.warning("Ignoring a torn final T3 delivery queue record")
+ continue
+ raise OSError(f"corrupt T3 delivery queue record {index + 1}")
+ if not isinstance(entry, dict) or not entry.get("deliveryId"):
+ raise OSError(f"invalid T3 delivery queue record {index + 1}")
+ entries.append(entry)
+ return entries
+
+ @staticmethod
+ def _encode(entry: dict[str, Any]) -> str:
+ return json.dumps(entry, separators=(",", ":"), ensure_ascii=False) + "\n"
+
+ def _write_all(self, entries: list[dict[str, Any]]) -> None:
+ self._ensure_parent()
+ payload = "".join(self._encode(entry) for entry in entries)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent
+ )
+ temporary = Path(temporary_name)
+ try:
+ # `mkstemp` is exclusive and does not follow a predictable symlink.
+ # Flush before replace, then fsync the directory so returning True
+ # means the renamed queue survives a host crash, not only a process
+ # restart with a warm page cache.
+ os.fchmod(descriptor, 0o600)
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ descriptor = -1
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary, self.path)
+ self._fsync_parent()
+ finally:
+ if descriptor >= 0:
+ os.close(descriptor)
+ try:
+ temporary.unlink()
+ except FileNotFoundError:
+ pass
+
+ def _fsync_parent(self) -> None:
+ """Persist a queue rename on filesystems that support directory fsync."""
+ if os.name != "posix":
+ return
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
+ descriptor = os.open(self.path.parent, flags)
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
+
+ def _append_line(self, entry: dict[str, Any]) -> None:
+ """Append one entry without reading or rewriting the file.
+
+ The fallback when the queue could not be read: a single `O_APPEND`
+ write cannot destroy entries it cannot see, where the rewrite path
+ would replace all of them with this one.
+ """
+ self._ensure_parent()
+ flags = (
+ os.O_WRONLY
+ | os.O_CREAT
+ | os.O_APPEND
+ | getattr(os, "O_CLOEXEC", 0)
+ | getattr(os, "O_NOFOLLOW", 0)
+ )
+ descriptor = os.open(
+ self.path, flags, 0o600
+ )
+ with os.fdopen(descriptor, "a", encoding="utf-8") as handle:
+ os.fchmod(handle.fileno(), 0o600)
+ handle.write(self._encode(entry))
+ handle.flush()
+ os.fsync(handle.fileno())
+ self._fsync_parent()
+
+ def _file_lock(self):
+ """Advisory cross-process lock, or a no-op where unavailable."""
+
+ class _NoLock:
+ def __enter__(self):
+ return None
+
+ def __exit__(self, *args):
+ return False
+
+ if fcntl is None:
+ return _NoLock()
+
+ queue = self.path
+ try:
+ queue.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ except OSError:
+ return _NoLock()
+
+ class _FileLock:
+ def __init__(self, lock_path: Path):
+ self._lock_path = lock_path
+ self._handle: Any = None
+
+ def __enter__(self):
+ descriptor: int | None = None
+ try:
+ # 0600 like the queue itself: the sidecar carries no
+ # payload, but a world-writable lock is a way to stall
+ # another user's deliveries.
+ flags = (
+ os.O_RDWR
+ | os.O_CREAT
+ | getattr(os, "O_CLOEXEC", 0)
+ | getattr(os, "O_NOFOLLOW", 0)
+ )
+ descriptor = os.open(
+ self._lock_path, flags, 0o600
+ )
+ os.fchmod(descriptor, 0o600)
+ self._handle = os.fdopen(descriptor, "a+")
+ descriptor = None
+ fcntl.flock(self._handle, fcntl.LOCK_EX)
+ except OSError:
+ if self._handle is not None:
+ self._handle.close()
+ elif descriptor is not None:
+ os.close(descriptor)
+ self._handle = None
+ return None
+
+ def __exit__(self, *args):
+ if self._handle is not None:
+ try:
+ fcntl.flock(self._handle, fcntl.LOCK_UN)
+ finally:
+ self._handle.close()
+ self._handle = None
+ return False
+
+ return _FileLock(queue.with_suffix(".jsonl.lock"))
+
+ def append(self, frame: dict[str, Any]) -> bool:
+ """Persist one delivery. Returns False when it could not be written."""
+ if not isinstance(frame, dict) or not frame.get("deliveryId"):
+ return False
+ with self._lock, self._file_lock():
+ try:
+ entries = self._read_lines()
+ except OSError:
+ # The rewrite path is off the table: it would replace every
+ # entry we failed to read with just this one, destroying an
+ # arbitrary number of unacked deliveries over a transient
+ # error. Append blind instead. The costs are bounded and both
+ # self-correct — the cap goes unenforced until the next
+ # successful read (a too-long queue is trimmed then), and a
+ # duplicate of an entry already on disk is deduped by T3 on
+ # `deliveryId`.
+ logger.warning(
+ "Could not read the T3 home delivery queue at %s; appending "
+ "without rewriting it",
+ self.path,
+ exc_info=True,
+ )
+ try:
+ self._append_line(dict(frame))
+ except OSError:
+ logger.error(
+ "Could not persist a T3 home delivery to %s",
+ self.path,
+ exc_info=True,
+ )
+ return False
+ return True
+ if any(
+ entry.get("deliveryId") == frame["deliveryId"] for entry in entries
+ ):
+ return True
+ entries.append(dict(frame))
+ encoded_sizes = [len(self._encode(entry).encode("utf-8")) for entry in entries]
+ total_bytes = sum(encoded_sizes)
+ dropped = 0
+ while len(entries) > 1 and (
+ len(entries) > self._max_entries or total_bytes > self._max_bytes
+ ):
+ entries.pop(0)
+ total_bytes -= encoded_sizes.pop(0)
+ dropped += 1
+ if dropped:
+ logger.warning(
+ "T3 home delivery queue is full (%d entries or %d bytes); "
+ "dropping the %d oldest undelivered %s",
+ self._max_entries,
+ self._max_bytes,
+ dropped,
+ "delivery" if dropped == 1 else "deliveries",
+ )
+ try:
+ self._write_all(entries)
+ except OSError:
+ logger.error(
+ "Could not persist a T3 home delivery to %s",
+ self.path,
+ exc_info=True,
+ )
+ return False
+ return True
+
+ def entries(self) -> list[dict[str, Any]]:
+ """Every unacknowledged delivery, oldest first.
+
+ An unreadable queue reports empty rather than raising: the callers are
+ the flush loop and `__len__`, and skipping a flush cycle costs one
+ reconnect's delay while the entries stay on disk.
+ """
+ with self._lock:
+ try:
+ return self._read_lines()
+ except OSError:
+ logger.warning(
+ "Could not read the T3 home delivery queue at %s",
+ self.path,
+ exc_info=True,
+ )
+ return []
+
+ def purge(self, delivery_id_value: str) -> bool:
+ """Remove one acknowledged delivery. Returns True when it was present."""
+ target = str(delivery_id_value or "").strip()
+ if not target:
+ return False
+ with self._lock, self._file_lock():
+ try:
+ entries = self._read_lines()
+ except OSError:
+ # Purge only ever rewrites, so an unreadable queue means doing
+ # nothing. The entry stays and is replayed once — harmless,
+ # because T3 acked it and therefore dedupes the replay.
+ logger.warning(
+ "Could not read the T3 home delivery queue at %s to purge "
+ "%s; it will be replayed and deduped",
+ self.path,
+ target,
+ exc_info=True,
+ )
+ return False
+ remaining = [
+ entry for entry in entries if entry.get("deliveryId") != target
+ ]
+ if len(remaining) == len(entries):
+ return False
+ try:
+ self._write_all(remaining)
+ except OSError:
+ logger.error(
+ "Could not purge acknowledged T3 home delivery %s from %s",
+ target,
+ self.path,
+ exc_info=True,
+ )
+ return False
+ return True
+
+ def __len__(self) -> int:
+ return len(self.entries())
+
+
+# ── classification ────────────────────────────────────────────────────────
+
+# Hermes' lifecycle broadcasts, matched by their literal shapes at 62e07223.
+# These are inline f-strings upstream, not exported constants, so the same
+# drift risk documented for the `/sethome` notice applies: a wording change
+# upstream downgrades these to `kind: "message"`, which costs a badge and a
+# quiet-delivery classification, never the delivery itself.
+_LIFECYCLE_EXACT = frozenset(
+ {
+ # gateway/run.py:17277
+ "♻️ Gateway online — Hermes is back and ready.",
+ # gateway/run.py:17236
+ "♻ Gateway restarted successfully. Your session continues.",
+ }
+)
+_LIFECYCLE_PREFIXES = (
+ # gateway/run.py:6599 — f"⚠️ Gateway {action} — {hint}"
+ "⚠️ Gateway restarting — ",
+ "⚠️ Gateway shutting down — ",
+)
+
+# cron/scheduler.py:1513 builds this header when `cron.wrap_response` is on
+# (the default).
+_CRON_HEADER = "Cronjob Response: "
+
+# gateway/run.py:8854 — the handoff destination's synthetic source identity.
+_HANDOFF_USER_ID = "system:handoff"
+
+
+def classify_delivery(
+ content: str,
+ metadata: dict[str, Any] | None = None,
+ *,
+ session_user_id: str = "",
+) -> tuple[str, str, bool]:
+ """Best-effort `(kind, label, provenance_certain)` for a proactive send.
+
+ Hermes' `adapter.send()` contract carries no structured "this is a cron
+ delivery" marker on every path, so this reads the signals that do exist:
+
+ * **`metadata["job_id"]`** — the cron scheduler stamps it into the routed
+ metadata for every live-gateway delivery (`cron/scheduler.py:1782`), and
+ `DeliveryRouter._deliver_to_platform` passes that dict through to
+ `adapter.send` unchanged (`gateway/delivery.py:606`). This is the
+ strongest signal available and the only structured one.
+ * **The cron wrap header** — `cron.wrap_response` (default on) prefixes the
+ brief with `Cronjob Response: ` (`cron/scheduler.py:1513`), which
+ also supplies a human job name for the badge.
+ * **Lifecycle literals** — the gateway's online/restart/shutdown notices.
+ * **The bound session identity** — `/handoff` dispatches its synthetic turn
+ under `user_id="system:handoff"` (`gateway/run.py:8854`), bound onto the
+ session context by `_set_session_env` (`gateway/run.py:17372`).
+
+ The third element reports whether provenance was *positively* established.
+ The adapter uses it as the tiebreaker when a live turn exists in the home
+ thread: only a positively-identified proactive send may bypass that turn,
+ so an unclassifiable send can never steal a user's answer.
+
+ Worst case for the first two elements is a wrong badge, never a lost
+ delivery — an unrecognised send is `("message", "Hermes", False)`.
+ """
+ text = str(content or "")
+ meta = metadata or {}
+
+ job_id = str(meta.get("job_id") or "").strip()
+ job_name = _cron_job_name(text)
+ if job_id or job_name:
+ label = f"Cron: {job_name or job_id}"
+ return "cron", label, True
+
+ stripped = text.strip()
+ if stripped in _LIFECYCLE_EXACT or stripped.startswith(_LIFECYCLE_PREFIXES):
+ return "lifecycle", "Gateway", True
+
+ if str(session_user_id or "").strip() == _HANDOFF_USER_ID:
+ return "handoff", "Handoff", True
+
+ return "message", "Hermes", False
+
+
+def _cron_job_name(text: str) -> str:
+ """Recover the job name from the cron wrap header, if present."""
+ first_line = text.lstrip().split("\n", 1)[0]
+ if not first_line.startswith(_CRON_HEADER):
+ return ""
+ return first_line[len(_CRON_HEADER) :].strip()
+
+
+def build_delivery(
+ *,
+ thread_id: str,
+ text: str,
+ kind: str = "message",
+ label: str = "Hermes",
+ created_at: str | None = None,
+) -> dict[str, Any]:
+ """Mint one `home.deliver` frame, id and timestamp included.
+
+ `createdAt` is stamped here — when Hermes produced the content — not when
+ the frame reaches T3, so a delivery flushed after a two-hour outage still
+ reports the moment it was written.
+ """
+ normalized_kind = kind if kind in HOME_DELIVERY_KINDS else "other"
+ return home_deliver(
+ delivery_id_value=delivery_id(),
+ thread_id=thread_id,
+ kind=normalized_kind,
+ label=label,
+ text=text,
+ created_at=created_at or iso_now(),
+ )
+
+
+def build_media_delivery(
+ *,
+ thread_id: str,
+ path: str,
+ kind: str = "message",
+ label: str = "Hermes",
+ turn_id: str | None = None,
+ caption: str | None = None,
+ name: str | None = None,
+ created_at: str | None = None,
+) -> dict[str, Any]:
+ """Mint one `media.deliver` frame from a local file, id and timestamp included.
+
+ Reads the file eagerly so the queued copy is self-contained: Hermes' media
+ files live in temp/cache directories that may be gone by the time a queued
+ delivery flushes after an outage, and a queue entry pointing at a dead path
+ would be unsendable forever. `createdAt` is stamped here for the same
+ reason as `build_delivery`.
+
+ Raises `OSError` when the file cannot be read and `ValueError` when it is
+ empty or over the 25MB wire ceiling — the caller decides whether that is a
+ logged skip (adapter) or a reported error (standalone sender).
+ """
+ file_path = Path(path)
+ data = file_path.read_bytes()
+ display_name = str(name or "").strip() or file_path.name
+ mime, _encoding = mimetypes.guess_type(display_name)
+ return media_deliver(
+ delivery_id_value=delivery_id(),
+ thread_id=thread_id,
+ kind=kind if kind in HOME_DELIVERY_KINDS else "other",
+ label=label,
+ name=display_name,
+ mime_type=mime or "application/octet-stream",
+ data=data,
+ turn_id=turn_id,
+ caption=caption,
+ created_at=created_at or iso_now(),
+ )
+
+
+# ── standalone (out-of-process) sender ────────────────────────────────────
+
+
+async def standalone_send(
+ pconfig: Any,
+ chat_id: str,
+ message: str,
+ *,
+ thread_id: str | None = None,
+ media_files: list[str] | None = None,
+ force_document: bool = False,
+) -> dict[str, Any]:
+ """Deliver to the home thread with no live gateway adapter in this process.
+
+ Registered as `standalone_sender_fn` and called by
+ `tools/send_message_tool._send_via_adapter` when the in-process adapter
+ weakref is empty — the `hermes cron` process running separately from
+ `hermes gateway`. Without it, `deliver=t3` cron jobs fail with "No live
+ adapter for platform".
+
+ The socket announces `role: "delivery"`. That is load-bearing: T3's broker
+ registers a `gateway` connection under generation fencing and displaces the
+ previous one, so a naive cron dial-in would kick the live gateway socket
+ off its own instance mid-turn. A `delivery` connection is authenticated
+ identically, never becomes the primary, and is expected to close promptly.
+
+ A connection failure is **not** a cron failure. The delivery is already on
+ disk before the socket is opened, so the job reports success-with-queued
+ and the live gateway flushes it on its next `connection.accepted`.
+
+ `media_files` rides the v4 wire as one `media.deliver` frame per file
+ (upstream passes `(path, is_voice)` tuples; bare path strings are accepted
+ too). A file that cannot be read or exceeds the 25MB frame ceiling is
+ reported in `detail` and skipped — never queued, because a queued frame
+ that T3 will always reject would sit in the outbox forever. `force_document`
+ remains signature parity only: T3 derives rendering from `mimeType`, so
+ there is no document/photo distinction to force.
+
+ Every success result carries additive accounting keys alongside the
+ unchanged `message_id`: `delivery_ids` (every frame this call minted, text
+ and media), `media_count`, `acked_count`, and — when media was sent — a
+ `note` naming the delivered file count. Upstream consumers read this dict by
+ specific key and otherwise `json.dumps` it wholesale, so extra keys are
+ inert there while giving an agent reading the tool output direct evidence
+ against core's unconditional "MEDIA attachments were omitted" warning.
+ """
+ del force_document
+
+ extra = getattr(pconfig, "extra", None) or {}
+
+ def _setting(key: str, env: str) -> str:
+ return str(extra.get(key) or os.environ.get(env, "") or "").strip()
+
+ url = _setting("url", "HERMES_T3_GATEWAY_URL")
+ instance_id = _setting("instance_id", "HERMES_T3_GATEWAY_INSTANCE_ID")
+ credential = _setting("credential", "HERMES_T3_GATEWAY_CREDENTIAL")
+ if not (url and instance_id and credential):
+ return {
+ "error": (
+ "T3 standalone send: this Hermes is not enrolled. Run "
+ "`hermes t3 connect --url --token ` first."
+ )
+ }
+
+ target = str(chat_id or "").strip() or str(thread_id or "").strip()
+ if not target:
+ target = home_thread_id()
+ if not target:
+ return {
+ "error": (
+ "T3 standalone send: no home thread is designated. Start "
+ "`hermes gateway` once so T3 can publish one."
+ )
+ }
+
+ kind, label, _certain = classify_delivery(message, None)
+ frames: list[dict[str, Any]] = []
+ media_ids: list[str] = []
+ skipped: list[str] = []
+ # The text frame is skipped only when media makes the send non-empty
+ # anyway; a bare text send keeps today's behaviour (empty text normalizes
+ # inside `home_deliver`).
+ if str(message or "").strip() or not media_files:
+ frames.append(
+ build_delivery(thread_id=target, text=message, kind=kind, label=label)
+ )
+ for entry in media_files or []:
+ media_path = entry[0] if isinstance(entry, (tuple, list)) else entry
+ try:
+ media_frame = build_media_delivery(
+ thread_id=target,
+ path=str(media_path),
+ kind=kind,
+ label=label,
+ )
+ except Exception as exc: # noqa: BLE001 - one bad file must not sink the send
+ # Never queued: a frame T3 will always reject (unreadable then,
+ # oversized forever) would otherwise sit in the outbox for good.
+ logger.warning(
+ "T3 standalone send skipping media file %s: %s", media_path, exc
+ )
+ skipped.append(f"{media_path}: {exc}")
+ else:
+ frames.append(media_frame)
+ media_ids.append(str(media_frame["deliveryId"]))
+ if not frames:
+ return {
+ "error": "T3 standalone send: no deliverable content "
+ + "; ".join(skipped)
+ }
+ delivery = str(frames[0]["deliveryId"])
+ all_ids = [str(frame["deliveryId"]) for frame in frames]
+
+ def _observed(
+ result: dict[str, Any], acked_ids: set[str], *, queued_only: bool
+ ) -> dict[str, Any]:
+ """Attach the additive delivery-accounting keys to a success result.
+
+ `message_id` is deliberately untouched — upstream reads that key by
+ name. Everything here is new, and exists as **counter-evidence**: core
+ appends a hard-coded "MEDIA attachments were omitted for t3" warning to
+ any successful generic-path send (`tools/send_message_tool.py:1108` @
+ v0.19.0) without ever asking whether the sender delivered them. The
+ `coreshim` module strips that warning when it can, but if it has failed
+ open the numbers below still sit in the same JSON blob the agent reads,
+ so "2 media file(s) delivered and acknowledged" contradicts the stale
+ warning directly rather than leaving the agent to guess.
+ """
+ acked_media = [entry for entry in media_ids if entry in acked_ids]
+ result["delivery_ids"] = list(all_ids)
+ result["media_count"] = len(media_ids)
+ result["acked_count"] = len(acked_ids)
+ if media_ids:
+ verb = "queued for delivery" if queued_only else "delivered"
+ count = len(media_ids) if queued_only else len(acked_media)
+ note = f"{count} media file(s) {verb}"
+ if not queued_only:
+ note += " and acknowledged"
+ existing = str(result.get("note") or "").strip()
+ result["note"] = f"{existing}; {note}" if existing else note
+ return result
+
+ queue = HomeDeliveryQueue()
+ # Never short-circuit this batch. A failed first write must not prevent the
+ # later media frames from reaching the durable outbox, and success below is
+ # computed per delivery rather than from one misleading aggregate boolean.
+ append_results = []
+ for frame in frames:
+ append_results.append(await asyncio.to_thread(queue.append, frame))
+ queued_ids = {
+ str(frame["deliveryId"])
+ for frame, appended in zip(frames, append_results)
+ if appended
+ }
+ expected_ids = set(all_ids)
+
+ try:
+ acked = await _deliver_over_short_lived_socket(
+ url=url,
+ instance_id=instance_id,
+ credential=credential,
+ frames=frames,
+ )
+ except Exception as exc: # noqa: BLE001 - cron delivery must not raise
+ logger.debug("T3 standalone send raised", exc_info=True)
+ if queued_ids == expected_ids:
+ return _observed(
+ {
+ "success": True,
+ "message_id": delivery,
+ "queued": True,
+ "detail": f"T3 unreachable ({exc}); queued for the next connect",
+ },
+ set(),
+ queued_only=True,
+ )
+ missing = len(expected_ids - queued_ids)
+ return {
+ "error": (
+ f"T3 standalone send failed: {exc}; {missing} delivery frame(s) "
+ "were not durably queued"
+ )
+ }
+
+ for acked_id in acked:
+ await asyncio.to_thread(queue.purge, acked_id)
+ result_detail = "; ".join(f"skipped {entry}" for entry in skipped)
+ if len(acked) == len(frames):
+ result: dict[str, Any] = {"success": True, "message_id": delivery}
+ if result_detail:
+ result["detail"] = result_detail
+ return _observed(result, acked, queued_only=False)
+ unacknowledged = expected_ids - acked
+ if unacknowledged.issubset(queued_ids):
+ return _observed(
+ {
+ "success": True,
+ "message_id": delivery,
+ "queued": True,
+ "detail": (
+ "T3 did not acknowledge every delivery; queued for retry"
+ + (f" ({result_detail})" if result_detail else "")
+ ),
+ },
+ acked,
+ queued_only=len(acked) == 0,
+ )
+ return {"error": "T3 standalone send: the delivery was neither acked nor queued"}
+
+
+async def _deliver_over_short_lived_socket(
+ *,
+ url: str,
+ instance_id: str,
+ credential: str,
+ frames: list[dict[str, Any]],
+ timeout: float = 20.0,
+) -> set[str]:
+ """Open, authenticate as `delivery`, send, await the acks, close.
+
+ Returns the set of `deliveryId`s T3 acknowledged (`home.deliver.ack` and
+ `media.deliver.ack` are equivalent here). A timeout returns whatever was
+ acked so far — the caller purges exactly those and leaves the rest queued.
+ """
+ import asyncio
+
+ from .connection import _open_socket, authenticate_socket
+
+ hermes_version = _hermes_version()
+ expected = {str(frame["deliveryId"]) for frame in frames}
+ acked: set[str] = set()
+ socket = await _open_socket(url)
+ try:
+ await authenticate_socket(
+ socket,
+ authentication={
+ "type": "instance-credential",
+ "instanceId": instance_id,
+ "credential": credential,
+ },
+ hermes_version=hermes_version,
+ role="delivery",
+ )
+ for frame in frames:
+ await socket.send(
+ json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
+ )
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+ while acked != expected:
+ remaining = deadline - loop.time()
+ if remaining <= 0:
+ return acked
+ try:
+ raw = await asyncio.wait_for(socket.recv(), timeout=remaining)
+ except TimeoutError:
+ return acked
+ try:
+ reply = json.loads(raw)
+ except ValueError:
+ continue
+ if not isinstance(reply, dict):
+ continue
+ if reply.get("type") == "ping" and reply.get("requestId"):
+ await socket.send(
+ json.dumps(
+ {
+ "type": "pong",
+ "protocolVersion": PROTOCOL_VERSION,
+ "requestId": reply["requestId"],
+ "sentAt": reply.get("sentAt") or iso_now(),
+ },
+ separators=(",", ":"),
+ )
+ )
+ continue
+ if reply.get("type") in {"home.deliver.ack", "media.deliver.ack"}:
+ delivery_id_value = str(reply.get("deliveryId") or "")
+ if delivery_id_value in expected:
+ acked.add(delivery_id_value)
+ # Anything else (a liveness ping, an unrelated frame) is ignored:
+ # this socket exists only to hand over these deliveries.
+ return acked
+ finally:
+ try:
+ await socket.close()
+ except Exception: # noqa: BLE001 - a failed close must not fail the send
+ logger.debug("T3 standalone socket close failed", exc_info=True)
+
+
+def _hermes_version() -> str:
+ try:
+ from hermes_cli import __version__
+
+ return str(__version__)
+ except Exception: # noqa: BLE001 - version discovery must not block delivery
+ return "unknown"
diff --git a/integrations/hermes-t3-gateway/install.sh b/integrations/hermes-t3-gateway/install.sh
new file mode 100755
index 000000000000..991a2d322312
--- /dev/null
+++ b/integrations/hermes-t3-gateway/install.sh
@@ -0,0 +1,81 @@
+#!/bin/sh
+# Install the T3 Code gateway plugin into the active Hermes profile.
+#
+# Symlinks this directory into "$HERMES_HOME/plugins/hermes-t3-gateway" (the
+# documented user-plugin path, hermes_cli/plugins.py:10) and enables it.
+# Safe to re-run: an existing correct symlink is left alone, and enabling an
+# already-enabled plugin is a no-op in Hermes.
+
+set -eu
+
+PLUGIN_NAME="hermes-t3-gateway"
+SOURCE_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
+HERMES_HOME_DIR=${HERMES_HOME:-"$HOME/.hermes"}
+PLUGINS_DIR="$HERMES_HOME_DIR/plugins"
+TARGET="$PLUGINS_DIR/$PLUGIN_NAME"
+
+if ! command -v hermes >/dev/null 2>&1; then
+ cat >&2 < $SOURCE_DIR"
+ else
+ echo "• Relinking $TARGET (was -> $CURRENT)"
+ rm -f "$TARGET"
+ ln -s "$SOURCE_DIR" "$TARGET"
+ fi
+elif [ -e "$TARGET" ]; then
+ cat >&2 < $SOURCE_DIR"
+fi
+
+echo "• Enabling $PLUGIN_NAME"
+# --no-allow-tool-override: this plugin registers a platform adapter and two
+# observer hooks. It never replaces a built-in tool, and passing the flag keeps
+# the run non-interactive instead of stopping on the consent prompt.
+hermes plugins enable "$PLUGIN_NAME" --no-allow-tool-override
+
+cat < --token
+
+ 2. Restart the gateway so it picks up the new connection:
+
+ hermes gateway restart
+
+ Verify anytime with: hermes t3 status
+EOF
diff --git a/integrations/hermes-t3-gateway/plugin.yaml b/integrations/hermes-t3-gateway/plugin.yaml
new file mode 100644
index 000000000000..42ea5a34cc5f
--- /dev/null
+++ b/integrations/hermes-t3-gateway/plugin.yaml
@@ -0,0 +1,24 @@
+name: hermes-t3-gateway
+label: T3 Code
+kind: platform
+version: 0.5.0
+description: >
+ Optional outbound companion for durable T3 Code Home, cron, handoff, and
+ media delivery. Interactive conversations continue through hermes-acp.
+author: T3 Tools
+provides_hooks:
+ - pre_tool_call
+ - post_tool_call
+optional_env:
+ - name: HERMES_T3_GATEWAY_URL
+ description: "T3 Code server URL used by the outbound gateway connection"
+ prompt: "T3 Code URL"
+ password: false
+ - name: HERMES_T3_GATEWAY_INSTANCE_ID
+ description: "Opaque T3 Code Hermes instance identifier issued at enrollment"
+ prompt: "T3 Code Hermes instance ID"
+ password: false
+ - name: HERMES_T3_GATEWAY_CREDENTIAL
+ description: "Long-lived per-instance credential issued at enrollment"
+ prompt: "T3 Code Hermes credential"
+ password: true
diff --git a/integrations/hermes-t3-gateway/protocol.py b/integrations/hermes-t3-gateway/protocol.py
new file mode 100644
index 000000000000..d84695692d9c
--- /dev/null
+++ b/integrations/hermes-t3-gateway/protocol.py
@@ -0,0 +1,607 @@
+"""Pure-Python helpers for the T3 Code ↔ Hermes gateway wire contract."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+PROTOCOL_VERSION = 4
+PLUGIN_VERSION = "0.5.0"
+WEBSOCKET_PATH = "/api/hermes-gateway/ws"
+
+# What a connecting socket intends to be. `gateway` is the instance's one live
+# plugin connection; `delivery` is a short-lived socket (an out-of-process cron
+# run) that hands over a `home.deliver` and leaves. T3 never registers a
+# `delivery` socket as the primary connection, so it cannot displace a healthy
+# gateway connection under the broker's generation fencing.
+CONNECTION_ROLES = frozenset({"gateway", "delivery"})
+
+CAPABILITIES = {
+ "protocolVersion": PROTOCOL_VERSION,
+ "streaming": True,
+ "activity": True,
+ "approvals": True,
+ "userInput": True,
+ # Part of the v4 contract itself, not a negotiated option: the T3 schema
+ # pins `attachments` to the literal `true`, so a plugin that cannot handle
+ # them is a v3 plugin and is rejected at the version gate.
+ "attachments": True,
+}
+
+SERVER_COMMANDS = frozenset(
+ {
+ "session.ensure",
+ "turn.start",
+ "turn.steer",
+ "turn.interrupt",
+ "approval.respond",
+ "user-input.respond",
+ "session.stop",
+ "ping",
+ "describe.request",
+ "skill.body.request",
+ "home.deliver.ack",
+ "media.deliver.ack",
+ "handoff.created",
+ "protocol.error",
+ }
+)
+
+# Kinds a `home.deliver` may carry. Mirrors the T3 contract's
+# `HermesGatewayHomeDeliveryKind`; anything not classified lands on "message".
+HOME_DELIVERY_KINDS = frozenset({"cron", "message", "lifecycle", "handoff", "other"})
+
+# Wire bounds from the T3 contract (`HermesGatewayHomeDeliver`): `label` is a
+# trimmed non-empty string of at most 200 chars, `text` is 1..120000 chars.
+# Enforced here so a pathological Hermes payload is clamped rather than
+# rejected by the server after the plugin already dropped its local copy.
+MAX_HOME_DELIVERY_LABEL_CHARS = 200
+MAX_HOME_DELIVERY_TEXT_CHARS = 120_000
+
+# Ceiling on a single skill body crossing the wire. Skill markdown is
+# human-authored documentation, not data: 512 KiB is far past any real
+# SKILL.md while still bounding a pathological file from stalling the socket.
+MAX_SKILL_BODY_CHARS = 512_000
+
+# Raw-byte ceiling for a single `media.deliver` frame, and for each attachment
+# riding an inbound turn frame. Mirrors `HERMES_MEDIA_MAX_BYTES` in the T3
+# contract: 25MB of raw bytes is ~34MB of base64, and the schema bound there is
+# on the encoded string so an oversized frame fails at decode. Deliberately no
+# chunking — a file that does not fit does not send, with a clear error.
+MAX_MEDIA_BYTES = 25 * 1024 * 1024
+
+# Wire bounds from the T3 contract (`HermesGatewayMediaDeliver`): `name` is a
+# trimmed non-empty string of at most 255 chars, `mimeType` at most 100, and
+# `caption` at most 2000. Enforced here for the same reason as the
+# `home.deliver` bounds: a queued frame must already be wire-valid on disk.
+MAX_MEDIA_NAME_CHARS = 255
+MAX_MEDIA_MIME_CHARS = 100
+MAX_MEDIA_CAPTION_CHARS = 2_000
+
+
+def request_id() -> str:
+ return str(uuid.uuid4())
+
+
+def item_id() -> str:
+ return str(uuid.uuid4())
+
+
+def delivery_id() -> str:
+ """Mint the idempotency key for one home delivery.
+
+ Stable across retries by construction: the id is minted once, when the
+ delivery is created, and the queued copy carries it verbatim through every
+ flush. T3 dedupes on it, which is what makes double-flushing safe.
+ """
+ return str(uuid.uuid4())
+
+
+def iso_now() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def frame(frame_type: str, **payload: Any) -> dict[str, Any]:
+ return {
+ "type": frame_type,
+ "protocolVersion": PROTOCOL_VERSION,
+ **payload,
+ }
+
+
+def configured_model() -> str | None:
+ """Return Hermes' configured default model, or None when unavailable.
+
+ Reads `hermes_cli.config.load_config_readonly()`, the documented read-only
+ accessor. That function returns the *shared, process-wide cached* config
+ dict, so nothing here mutates it or hands a nested structure to a caller
+ that might: only a trimmed string copy of `model.default` leaves this
+ function.
+
+ Every failure mode — no Hermes on the path, an older Hermes without the
+ accessor, a config with no model section — degrades to None so the field is
+ omitted from the handshake rather than sent as null or empty.
+ """
+ try:
+ from hermes_cli.config import load_config_readonly
+
+ model = load_config_readonly().get("model", {}).get("default")
+ except Exception: # noqa: BLE001 - model reporting must never break the handshake
+ return None
+ if not isinstance(model, str):
+ return None
+ trimmed = model.strip()
+ return trimmed or None
+
+
+def configured_reasoning_effort() -> str | None:
+ """Return Hermes' configured reasoning effort, or None when unavailable.
+
+ Same discipline as `configured_model()`: `load_config_readonly()` hands
+ back the *shared, process-wide cached* config dict, so this reads
+ `agent.reasoning_effort` and copies out a trimmed string — nothing here
+ mutates the cache or lets a nested structure escape to a caller that
+ might.
+
+ Every failure mode — no Hermes on the path, an older Hermes without the
+ accessor, a config with no `agent` section, a non-string value — degrades
+ to None so the field is omitted from `describe.response` rather than sent
+ as null or empty.
+ """
+ try:
+ from hermes_cli.config import load_config_readonly
+
+ effort = load_config_readonly().get("agent", {}).get("reasoning_effort")
+ except Exception: # noqa: BLE001 - describe must never break the connection
+ return None
+ if not isinstance(effort, str):
+ return None
+ trimmed = effort.strip()
+ return trimmed or None
+
+
+def installed_skills() -> list[dict[str, Any]]:
+ """Return metadata for the skills Hermes currently exposes.
+
+ Reads the documented `tools.skills_tool.skills_list()` tool surface — the
+ same JSON the agent itself sees — rather than the private `_find_all_skills`
+ scanner behind it. `skills_list()` already applies Hermes' platform,
+ environment, and disabled-skill filters, so every entry it returns is a
+ skill this Hermes would actually load; `enabled` is therefore always True
+ here and disabled skills are simply absent (see COMPATIBILITY.md).
+
+ Only trimmed string copies of `name`, `description`, and `category` leave
+ this function: the payload is rebuilt entry by entry so nothing Hermes owns
+ — cached or otherwise — is handed to a caller that might mutate it.
+
+ Every failure mode — no Hermes on the path, an older Hermes without the
+ tool, a non-JSON or unsuccessful response, a malformed entry — degrades to
+ an empty list. Describing an agent must never break the connection.
+ """
+ try:
+ from tools.skills_tool import skills_list
+
+ payload = json.loads(skills_list())
+ except Exception: # noqa: BLE001 - describe must never break the connection
+ return []
+ if not isinstance(payload, dict) or not payload.get("success"):
+ return []
+ entries = payload.get("skills")
+ if not isinstance(entries, list):
+ return []
+ skills: list[dict[str, Any]] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ name = entry.get("name")
+ if not isinstance(name, str) or not name.strip():
+ continue
+ skill: dict[str, Any] = {"name": name.strip(), "enabled": True}
+ description = entry.get("description")
+ if isinstance(description, str) and description.strip():
+ skill["description"] = description.strip()
+ # Hermes' category is the closest thing it publishes to an install
+ # source. There is no on-disk path in this surface; see COMPATIBILITY.md.
+ source = entry.get("category")
+ if isinstance(source, str) and source.strip():
+ skill["source"] = source.strip()
+ skills.append(skill)
+ return skills
+
+
+def skill_body(name: str) -> str | None:
+ """Return a skill's SKILL.md markdown, or None when unavailable.
+
+ Reads the documented `tools.skills_tool.skill_view()` tool surface with
+ `preprocess=False`: T3 renders the skill for a human to read, so the
+ literal authored markdown is wanted, not Hermes' template/inline-shell
+ rendering of it.
+
+ Unlike the omit-on-failure optional fields, `markdown` is explicitly null
+ on failure: the request named a specific skill, so the caller needs to
+ distinguish "asked and there is nothing to show" from a dropped reply.
+ A missing skill, an unreadable file, an ambiguous name, an older Hermes,
+ or no Hermes at all all land on None.
+ """
+ if not isinstance(name, str) or not name.strip():
+ return None
+ try:
+ from tools.skills_tool import skill_view
+
+ payload = json.loads(skill_view(name.strip(), preprocess=False))
+ except Exception: # noqa: BLE001 - describe must never break the connection
+ return None
+ if not isinstance(payload, dict) or not payload.get("success"):
+ return None
+ content = payload.get("content")
+ if not isinstance(content, str) or not content.strip():
+ return None
+ return content[:MAX_SKILL_BODY_CHARS]
+
+
+def describe_response(
+ *,
+ request_id_value: str,
+ hermes_version: str,
+ model: str | None = None,
+ reasoning_effort: str | None = None,
+ skills: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ """Build the reply to a `describe.request`.
+
+ Mirrors `connection_hello`: the version/capability block is always present
+ because the plugin owns it outright, while every Hermes-sourced optional
+ field is *omitted* when it cannot be read rather than sent as null or
+ empty, so a server that has the field still falls back to its own generic
+ label. `skills` is always present — an empty list is the truthful answer
+ when Hermes reports none, and it keeps the client's rendering shape stable.
+ """
+ resolved_model = model if model is not None else configured_model()
+ resolved_effort = (
+ reasoning_effort
+ if reasoning_effort is not None
+ else configured_reasoning_effort()
+ )
+ resolved_skills = installed_skills() if skills is None else skills
+ payload: dict[str, Any] = {
+ "type": "describe.response",
+ "protocolVersion": PROTOCOL_VERSION,
+ "requestId": request_id_value,
+ "pluginVersion": PLUGIN_VERSION,
+ "hermesVersion": hermes_version,
+ "capabilities": dict(CAPABILITIES),
+ "skills": [dict(skill) for skill in resolved_skills],
+ "describedAt": iso_now(),
+ }
+ if resolved_model:
+ payload["model"] = resolved_model
+ if resolved_effort:
+ payload["reasoningEffort"] = resolved_effort
+ return payload
+
+
+def skill_body_response(
+ *,
+ request_id_value: str,
+ skill_name: str,
+ markdown: str | None,
+) -> dict[str, Any]:
+ """Build the reply to a `skill.body.request`.
+
+ `markdown` is explicitly nullable here — the request named a skill, so the
+ caller must be able to tell "Hermes has no body to show for this" apart
+ from a reply that never arrived. Empty strings normalize to null.
+ """
+ return {
+ "type": "skill.body.response",
+ "protocolVersion": PROTOCOL_VERSION,
+ "requestId": request_id_value,
+ "skillName": skill_name,
+ "markdown": markdown if markdown else None,
+ }
+
+
+def connection_hello(
+ *,
+ hermes_version: str,
+ authentication: dict[str, str],
+ hello_request_id: str | None = None,
+ model: str | None = None,
+ role: str = "gateway",
+) -> dict[str, Any]:
+ """Build the handshake frame.
+
+ `role` is sent explicitly even though the T3 contract decodes a missing
+ value as `"gateway"`: an out-of-process cron sender MUST announce
+ `"delivery"` or the broker registers it as the instance's primary
+ connection and generation-fences the live gateway socket off.
+ """
+ resolved_model = model if model is not None else configured_model()
+ normalized_role = str(role or "gateway").strip().lower()
+ if normalized_role not in CONNECTION_ROLES:
+ normalized_role = "gateway"
+ hello: dict[str, Any] = {
+ "type": "connection.hello",
+ "requestId": hello_request_id or request_id(),
+ "protocolVersion": PROTOCOL_VERSION,
+ "pluginVersion": PLUGIN_VERSION,
+ "hermesVersion": hermes_version,
+ "capabilities": dict(CAPABILITIES),
+ "authentication": authentication,
+ "role": normalized_role,
+ }
+ # Optional on the wire: omit entirely rather than send null/empty so a
+ # server that has the field still falls back to its generic label.
+ if resolved_model:
+ hello["model"] = resolved_model
+ return hello
+
+
+def _normalize_delivery_provenance(kind: str, label: str) -> tuple[str, str]:
+ normalized_kind = str(kind or "").strip().lower()
+ if normalized_kind not in HOME_DELIVERY_KINDS:
+ normalized_kind = "other"
+ normalized_label = str(label or "").strip()[:MAX_HOME_DELIVERY_LABEL_CHARS].strip()
+ return normalized_kind, normalized_label or "Hermes"
+
+
+def home_deliver(
+ *,
+ delivery_id_value: str,
+ thread_id: str,
+ kind: str,
+ label: str,
+ text: str,
+ created_at: str | None = None,
+) -> dict[str, Any]:
+ """Build a `home.deliver` frame with every wire bound already applied.
+
+ Like `protocol_error`, this wraps the generic `frame()` helper rather than
+ assembling the envelope itself — but unlike the plain outbound frames the
+ adapter builds inline, a delivery has server-side validation the plugin
+ must not trip: `label` is trimmed non-empty ≤200 chars and `text` is
+ 1..120000 chars in the T3 contract. Normalizing here rather than at the
+ call sites means a queued delivery is already wire-valid on disk, so a
+ flush after a plugin upgrade cannot resurrect a payload the server will
+ reject — the plugin would purge it only on an ack that never comes.
+
+ An unknown `kind` degrades to `"other"` and an empty label to `"Hermes"`:
+ a misclassified badge is the documented worst case, a dropped delivery is
+ not.
+ """
+ normalized_kind, normalized_label = _normalize_delivery_provenance(kind, label)
+ normalized_text = str(text or "")[:MAX_HOME_DELIVERY_TEXT_CHARS]
+ if not normalized_text:
+ normalized_text = " "
+ return frame(
+ "home.deliver",
+ deliveryId=delivery_id_value,
+ threadId=str(thread_id),
+ kind=normalized_kind,
+ label=normalized_label,
+ text=normalized_text,
+ createdAt=created_at or iso_now(),
+ )
+
+
+def media_deliver(
+ *,
+ delivery_id_value: str,
+ thread_id: str,
+ kind: str,
+ label: str,
+ name: str,
+ mime_type: str,
+ data: bytes,
+ turn_id: str | None = None,
+ caption: str | None = None,
+ created_at: str | None = None,
+) -> dict[str, Any]:
+ """Build a `media.deliver` frame with every wire bound already applied.
+
+ Mirrors `home_deliver`: normalizing here rather than at the call sites
+ means a queued delivery is already wire-valid on disk, so a flush after a
+ plugin upgrade cannot resurrect a payload the server will reject — the
+ plugin would purge it only on an ack that never comes.
+
+ The clamp-vs-reject split follows what each field can survive. Provenance
+ (`kind`, `label`) and presentation (`caption`) degrade exactly like
+ `home_deliver`'s — a wrong badge or a shortened caption is the documented
+ worst case. The payload itself cannot degrade: truncated bytes are a
+ corrupt file, so an empty payload, a payload over `MAX_MEDIA_BYTES`, or a
+ missing `deliveryId` raises `ValueError` instead — better a loud send-time
+ failure than a poisoned queue entry T3 rejects forever.
+
+ `data` is raw bytes; base64 encoding happens here so no call site can get
+ the wire encoding wrong, and `sizeBytes` is derived from the same bytes so
+ the two can never disagree.
+ """
+ if not str(delivery_id_value or "").strip():
+ raise ValueError("media.deliver requires a deliveryId")
+ if not isinstance(data, (bytes, bytearray)):
+ raise TypeError("media.deliver data must be bytes")
+ if len(data) == 0:
+ raise ValueError("media.deliver requires a non-empty payload")
+ if len(data) > MAX_MEDIA_BYTES:
+ raise ValueError(
+ f"media.deliver payload is {len(data)} bytes; "
+ f"the wire ceiling is {MAX_MEDIA_BYTES} bytes (25MB)"
+ )
+ normalized_kind, normalized_label = _normalize_delivery_provenance(kind, label)
+ normalized_name = str(name or "").strip()[:MAX_MEDIA_NAME_CHARS].strip()
+ if not normalized_name:
+ normalized_name = "attachment.bin"
+ normalized_mime = str(mime_type or "").strip()[:MAX_MEDIA_MIME_CHARS].strip()
+ if not normalized_mime:
+ normalized_mime = "application/octet-stream"
+ payload: dict[str, Any] = {
+ "deliveryId": delivery_id_value,
+ "threadId": str(thread_id),
+ "kind": normalized_kind,
+ "label": normalized_label,
+ "name": normalized_name,
+ "mimeType": normalized_mime,
+ "sizeBytes": len(data),
+ "data": base64.b64encode(bytes(data)).decode("ascii"),
+ "createdAt": created_at or iso_now(),
+ }
+ # Optional on the wire: omit rather than send null/empty, matching the
+ # T3 schema's `Schema.optional` fields.
+ if turn_id:
+ payload["turnId"] = str(turn_id)
+ normalized_caption = str(caption or "")[:MAX_MEDIA_CAPTION_CHARS]
+ if normalized_caption:
+ payload["caption"] = normalized_caption
+ return frame("media.deliver", **payload)
+
+
+def turn_attachments(message: dict[str, Any]) -> list[dict[str, Any]]:
+ """Decode the optional `attachments` on a `turn.start` / `turn.steer`.
+
+ Returns `[{"name", "mimeType", "data": bytes}, ...]` with the base64
+ already decoded and each payload bounded by `MAX_MEDIA_BYTES`. The wire
+ `sizeBytes` is advisory — the decoded length is the truth, so it is what
+ callers get.
+
+ A malformed entry raises `ValueError` rather than being skipped: T3
+ validates these frames against its own schema before sending, so a bad
+ entry here means version drift, and silently dropping a file the user
+ attached is worse than a correlated `protocol.error` they can see.
+ """
+ raw = message.get("attachments")
+ if raw is None:
+ return []
+ if not isinstance(raw, list):
+ raise ValueError("turn attachments must be a list")
+ attachments: list[dict[str, Any]] = []
+ for index, entry in enumerate(raw):
+ if not isinstance(entry, dict):
+ raise ValueError(f"turn attachment {index} must be an object")
+ name = str(entry.get("name") or "").strip()
+ if not name:
+ raise ValueError(f"turn attachment {index} is missing a name")
+ encoded = entry.get("data")
+ if not isinstance(encoded, str) or not encoded:
+ raise ValueError(f"turn attachment {name!r} carries no data")
+ try:
+ data = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise ValueError(
+ f"turn attachment {name!r} is not valid base64"
+ ) from exc
+ if len(data) == 0:
+ raise ValueError(f"turn attachment {name!r} decoded to zero bytes")
+ if len(data) > MAX_MEDIA_BYTES:
+ raise ValueError(
+ f"turn attachment {name!r} is {len(data)} bytes; "
+ f"the wire ceiling is {MAX_MEDIA_BYTES} bytes (25MB)"
+ )
+ mime = str(entry.get("mimeType") or "").strip()
+ attachments.append(
+ {
+ "name": name,
+ "mimeType": mime or "application/octet-stream",
+ "data": data,
+ }
+ )
+ return attachments
+
+
+def protocol_error(
+ code: str,
+ message: str,
+ *,
+ recoverable: bool,
+ related_request_id: str | None = None,
+) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "code": code,
+ "message": message,
+ "recoverable": recoverable,
+ }
+ if related_request_id:
+ payload["requestId"] = related_request_id
+ return frame("protocol.error", **payload)
+
+
+def validate_server_frame(message: Any) -> dict[str, Any]:
+ if not isinstance(message, dict):
+ raise TypeError("gateway frame must be a JSON object")
+ frame_type = message.get("type")
+ if frame_type not in SERVER_COMMANDS:
+ raise ValueError(f"unsupported T3 gateway frame: {frame_type!r}")
+ if message.get("protocolVersion") != PROTOCOL_VERSION:
+ raise ValueError(
+ f"unsupported protocol version: {message.get('protocolVersion')!r}"
+ )
+ return message
+
+
+def canonical_tool_item_type(tool_name: str) -> str:
+ normalized = (tool_name or "").strip().lower()
+ if normalized in {"terminal", "execute_code", "shell", "bash"}:
+ return "command_execution"
+ if normalized in {
+ "apply_patch",
+ "write_file",
+ "edit_file",
+ "delete_file",
+ "move_file",
+ }:
+ return "file_change"
+ if normalized.startswith(("mcp", "mcp__")):
+ return "mcp_tool_call"
+ if normalized in {"delegate_task", "spawn_agent", "send_message"}:
+ return "collab_agent_tool_call"
+ if "search" in normalized or normalized in {"web_fetch", "fetch_url"}:
+ return "web_search"
+ if normalized in {"view_image", "open_image"}:
+ return "image_view"
+ return "dynamic_tool_call"
+
+
+def canonical_tool_data(tool_name: str, args: Any) -> dict[str, Any] | None:
+ """Project known-safe, canonical fields; never forward arbitrary tool args."""
+ if not isinstance(args, dict):
+ return None
+ item_type = canonical_tool_item_type(tool_name)
+ if item_type == "command_execution":
+ command = args.get("command")
+ cwd = args.get("cwd") or args.get("workdir")
+ projected = {}
+ if isinstance(command, str) and command.strip():
+ projected["command"] = command[:4_000]
+ if isinstance(cwd, str) and cwd.strip():
+ projected["cwd"] = cwd[:1_000]
+ return projected or None
+ if item_type == "file_change":
+ path = args.get("path") or args.get("file_path") or args.get("filename")
+ return (
+ {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None
+ )
+ if item_type == "web_search":
+ query = args.get("query") or args.get("q") or args.get("url")
+ return (
+ {"query": query[:2_000]}
+ if isinstance(query, str) and query.strip()
+ else None
+ )
+ if item_type == "image_view":
+ path = args.get("path") or args.get("image_path")
+ return (
+ {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None
+ )
+ if item_type == "mcp_tool_call":
+ server = args.get("server")
+ operation = args.get("tool") or args.get("operation")
+ projected = {}
+ if isinstance(server, str) and server.strip():
+ projected["server"] = server[:200]
+ if isinstance(operation, str) and operation.strip():
+ projected["operation"] = operation[:200]
+ return projected or None
+ return None
diff --git a/integrations/hermes-t3-gateway/pyproject.toml b/integrations/hermes-t3-gateway/pyproject.toml
new file mode 100644
index 000000000000..c384077cb322
--- /dev/null
+++ b/integrations/hermes-t3-gateway/pyproject.toml
@@ -0,0 +1,15 @@
+# Tool configuration only — the plugin is installed by copying the directory
+# (see install.sh), not packaged, so there is deliberately no [project] table.
+
+[tool.ruff]
+# The plugin's floor: the code uses `X | None` unions and dict/list generics
+# that require Python 3.10+.
+target-version = "py310"
+
+[tool.ruff.lint]
+# F catches real bugs — F811 (redefinition) is the rule that would have
+# flagged the duplicated definitions this config was added alongside.
+select = ["E", "W", "F"]
+# The plugin favors long explanatory docstrings and comments; do not enforce a
+# line length rather than reflowing existing prose.
+ignore = ["E501"]
diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py
new file mode 100644
index 000000000000..3dddf2105d9d
--- /dev/null
+++ b/integrations/hermes-t3-gateway/tests/test_adapter.py
@@ -0,0 +1,2947 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import dataclasses
+import enum
+import importlib.util
+import pathlib
+import sys
+import tempfile
+import threading
+import types
+import unittest
+import unittest.mock
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PACKAGE = "hermes_t3_gateway_adapter_test"
+
+
+class Platform(str, enum.Enum):
+ T3 = "t3"
+
+ @classmethod
+ def _missing_(cls, value):
+ if value == "t3":
+ return cls.T3
+ return None
+
+
+@dataclasses.dataclass
+class PlatformConfig:
+ enabled: bool = True
+ extra: dict = dataclasses.field(default_factory=dict)
+
+
+class MessageType(enum.Enum):
+ TEXT = "text"
+ COMMAND = "command"
+
+
+@dataclasses.dataclass
+class MessageEvent:
+ text: str
+ message_type: MessageType
+ source: object
+ message_id: str
+ metadata: dict
+ # Media attachments, defaulted exactly like upstream
+ # (`gateway/platforms/base.py:1801`): local file paths plus aligned MIMEs.
+ media_urls: list = dataclasses.field(default_factory=list)
+ media_types: list = dataclasses.field(default_factory=list)
+
+
+@dataclasses.dataclass
+class SendResult:
+ success: bool
+ message_id: str | None = None
+ error: str | None = None
+
+
+@dataclasses.dataclass
+class Source:
+ platform: Platform
+ chat_id: str
+ message_id: str
+
+
+class BasePlatformAdapter:
+ def __init__(self, config, platform):
+ self.config = config
+ self.platform = platform
+ self._status_text = {}
+ self.messages = []
+ self._running = False
+ self._message_handler = None
+
+ def build_source(self, *, chat_id, message_id, **kwargs):
+ return Source(self.platform, str(chat_id), str(message_id))
+
+ async def handle_message(self, event):
+ self.messages.append(event)
+ if (
+ self._message_handler is not None
+ and event.message_type == MessageType.COMMAND
+ and event.text.startswith("/steer ")
+ ):
+ # Faithful model of Hermes BasePlatformAdapter's active-command
+ # bypass path (gateway/platforms/base.py ~4926 at upstream
+ # 62e07223): the gateway handler returns a control
+ # acknowledgement, then the base adapter sends it through the
+ # platform adapter with `reply_to=_reply_anchor_for_event(event)`
+ # — which, for a platform with no thread_id, is the dispatched
+ # event's own message_id — and notify=True metadata.
+ response = await self._message_handler(event)
+ if response:
+ await self.send(
+ event.source.chat_id,
+ response,
+ reply_to=event.message_id,
+ metadata={"notify": True},
+ )
+
+ async def interrupt_session_activity(self, session_key, chat_id):
+ self.interrupted = (session_key, chat_id)
+
+ def set_status_text(self, chat_id, text):
+ if text:
+ self._status_text[str(chat_id)] = text
+ else:
+ self._status_text.pop(str(chat_id), None)
+
+ def _mark_connected(self):
+ self._running = True
+
+ def _mark_disconnected(self):
+ self._running = False
+
+ def _set_fatal_error(self, *args, **kwargs):
+ self.fatal_error = (args, kwargs)
+
+
+def build_session_key(source):
+ return f"agent:main:t3:dm:{source.chat_id}"
+
+
+def install_fake_hermes_modules():
+ gateway = types.ModuleType("gateway")
+ config = types.ModuleType("gateway.config")
+ config.Platform = Platform
+ config.PlatformConfig = PlatformConfig
+ platforms = types.ModuleType("gateway.platforms")
+ base = types.ModuleType("gateway.platforms.base")
+ base.BasePlatformAdapter = BasePlatformAdapter
+ base.MessageEvent = MessageEvent
+ base.MessageType = MessageType
+ base.SendResult = SendResult
+ session = types.ModuleType("gateway.session")
+ session.build_session_key = build_session_key
+ sys.modules.update(
+ {
+ "gateway": gateway,
+ "gateway.config": config,
+ "gateway.platforms": platforms,
+ "gateway.platforms.base": base,
+ "gateway.session": session,
+ }
+ )
+
+
+def load_plugin_modules():
+ install_fake_hermes_modules()
+ package = types.ModuleType(PACKAGE)
+ package.__path__ = [str(ROOT)]
+ sys.modules[PACKAGE] = package
+ for name in ("protocol", "connection", "cli", "home", "adapter"):
+ spec = importlib.util.spec_from_file_location(
+ f"{PACKAGE}.{name}", ROOT / f"{name}.py"
+ )
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[f"{PACKAGE}.{name}"] = module
+ spec.loader.exec_module(module)
+ return sys.modules[f"{PACKAGE}.adapter"]
+
+
+adapter_module = load_plugin_modules()
+protocol_module = sys.modules[f"{PACKAGE}.protocol"]
+home_module = sys.modules[f"{PACKAGE}.home"]
+
+
+@contextlib.contextmanager
+def hermes_without_describe_surfaces():
+ """Model an older Hermes: the modules import, the accessors are absent."""
+ names = ("hermes_cli", "hermes_cli.config", "tools", "tools.skills_tool")
+ saved = {name: sys.modules.get(name) for name in names}
+ hermes_cli = types.ModuleType("hermes_cli")
+ hermes_cli.__path__ = []
+ config = types.ModuleType("hermes_cli.config")
+ tools = types.ModuleType("tools")
+ tools.__path__ = []
+ skills_tool = types.ModuleType("tools.skills_tool")
+ sys.modules.update(
+ {
+ "hermes_cli": hermes_cli,
+ "hermes_cli.config": config,
+ "tools": tools,
+ "tools.skills_tool": skills_tool,
+ }
+ )
+ try:
+ yield
+ finally:
+ for name, module in saved.items():
+ if module is None:
+ sys.modules.pop(name, None)
+ else:
+ sys.modules[name] = module
+
+
+class FakeConnection:
+ def __init__(self):
+ self.connected = True
+ self.messages = []
+
+ async def send(self, message):
+ self.messages.append(message)
+
+
+class AdapterTests(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ self.adapter = adapter_module.T3PlatformAdapter(
+ PlatformConfig(
+ extra={
+ "url": "wss://t3.example/api/hermes-gateway/ws",
+ "instance_id": "instance",
+ "credential": "credential",
+ }
+ )
+ )
+ self.connection = FakeConnection()
+ self.adapter._connection = self.connection
+ # Exercise retained callbacks independently of the production boundary.
+ self.adapter._gateway_interactive_turns_enabled = True
+
+ async def test_gateway_interactive_turns_are_disabled_by_default(self):
+ self.adapter._gateway_interactive_turns_enabled = False
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "interactive-disabled",
+ "threadId": "thread-1",
+ "sessionId": "session-1",
+ "turnId": "turn-1",
+ "text": "do not run",
+ }
+ )
+ error = self.connection.messages[-1]
+ self.assertEqual(error["type"], "protocol.error")
+ self.assertEqual(error["requestId"], "interactive-disabled")
+ self.assertIn("hermes-acp", error["message"])
+
+ async def _start_turn(self, thread_id: str, turn_id: str):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": f"ensure-{thread_id}",
+ "threadId": thread_id,
+ }
+ )
+ session_id = self.adapter._sessions[thread_id]
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": f"start-{thread_id}",
+ "threadId": thread_id,
+ "sessionId": session_id,
+ "turnId": turn_id,
+ "text": "Start",
+ }
+ )
+ return session_id
+
+ async def test_thread_ensure_start_stream_and_complete(self):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-1",
+ "threadId": "thread-1",
+ }
+ )
+ ready = self.connection.messages[-2]
+ self.assertEqual(ready["type"], "session.ready")
+ self.assertEqual(ready["sessionId"], "agent:main:t3:dm:thread-1")
+ self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1)
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-1",
+ "threadId": "thread-1",
+ "sessionId": ready["sessionId"],
+ "turnId": "turn-1",
+ "text": "Hello Hermes",
+ }
+ )
+ self.assertEqual(self.adapter.messages[-1].text, "Hello Hermes")
+ await self.adapter.send("thread-1", "Hello", metadata={"expect_edits": True})
+ # `finalize` must NOT complete the turn — the gateway's progress loop
+ # sets it on every progress edit. Only a `notify=True` send does.
+ await self.adapter.edit_message(
+ "thread-1", "message", "Hello world", finalize=True
+ )
+ self.assertNotIn(
+ "turn.completed", [m["type"] for m in self.connection.messages]
+ )
+ await self.adapter.send("thread-1", "Hello world", metadata={"notify": True})
+ types_seen = [message["type"] for message in self.connection.messages]
+ self.assertIn("content.delta", types_seen)
+ self.assertIn("turn.completed", types_seen)
+ deltas = [
+ message["delta"]
+ for message in self.connection.messages
+ if message["type"] == "content.delta"
+ ]
+ self.assertEqual(deltas, ["Hello", " world"])
+
+ async def test_tool_progress_bubble_edits_never_complete_the_turn(self):
+ """Regression: the gateway's progress loop must not end a T3 turn.
+
+ This is the defect this plugin shipped with. Declaring
+ ``REQUIRES_EDIT_FINALIZE = True`` makes the gateway's tool-progress
+ loop pass ``finalize=True`` on EVERY progress-bubble edit
+ (``gateway/run.py:20777-20780`` at upstream 62e07223) — it is a
+ presentation hint for rich-card surfaces, not a turn boundary.
+ Treating it as "turn finished" closed the T3 turn on the first tool
+ call; every later send then failed with "no active T3 turn" and the
+ real answer never reached the transcript. The turn ends on exactly one
+ signal: ``notify=True`` metadata on ``send``, which the gateway applies
+ via ``_mark_notify_metadata`` (``gateway/platforms/base.py:89``) only
+ for genuine user-visible replies.
+ """
+ # Declaring the flag is what arms the gateway's finalize-on-every-edit
+ # branch, so the declaration itself is part of the contract under test.
+ # It is NOT sufficient on its own: the stream consumer also passes
+ # finalize=True on every mid-turn segment break regardless of the flag
+ # (`gateway/stream_consumer.py:938-940`), which is why `edit_message`
+ # must ignore `finalize` outright — see the segment-break leg below.
+ self.assertFalse(self.adapter.REQUIRES_EDIT_FINALIZE)
+
+ await self._start_turn("thread-progress", "turn-progress")
+ turn = self.adapter._active_turns["thread-progress"]
+ progress_start = len(self.connection.messages)
+
+ # Progress metadata is thread/routing metadata only; the progress loop
+ # never marks it notify-worthy (verified: zero _mark_notify_metadata
+ # calls in gateway/run.py:20700-20960).
+ progress_metadata = {"thread_id": "thread-progress"}
+
+ async def edit_progress_message(message_id: str, content: str):
+ """Mirror of the gateway's `_edit_progress_message` closure."""
+ kwargs = {
+ "chat_id": "thread-progress",
+ "message_id": message_id,
+ "content": content,
+ }
+ if getattr(self.adapter, "REQUIRES_EDIT_FINALIZE", False):
+ kwargs["finalize"] = True
+ kwargs["metadata"] = progress_metadata
+ return await self.adapter.edit_message(**kwargs)
+
+ # First progress bubble is a plain send, never notify-marked.
+ first = await self.adapter.send(
+ "thread-progress",
+ "📚 Reading skill hermes-agent",
+ reply_to=None,
+ metadata=progress_metadata,
+ )
+ self.assertTrue(first.success)
+ self.assertIn("thread-progress", self.adapter._active_turns)
+
+ # Then the loop edits that one bubble once per tool event.
+ progress_lines = ["📚 Reading skill hermes-agent"]
+ for line in (
+ "🔍 Searching the web for hermes gateway",
+ "📖 Reading file gateway/run.py",
+ "🛠️ Running tests",
+ ):
+ progress_lines.append(line)
+ result = await edit_progress_message(
+ first.message_id, "\n".join(progress_lines)
+ )
+ self.assertTrue(result.success)
+ # Every single edit must leave the turn running.
+ self.assertIn("thread-progress", self.adapter._active_turns)
+ self.assertIs(self.adapter._active_turns["thread-progress"], turn)
+
+ self.assertNotIn(
+ "turn.completed",
+ [message["type"] for message in self.connection.messages],
+ )
+
+ # Second, flag-independent leg: the stream consumer finalizes the
+ # current content message at every tool/segment boundary
+ # (`gateway/stream_consumer.py:938-940` passes
+ # `finalize=(got_done or got_segment_break)`), and it does so whether
+ # or not the adapter declares REQUIRES_EDIT_FINALIZE. A mid-turn
+ # segment break is not a turn boundary either.
+ for partial in ("Let me check the docs.", "Let me check the docs. Found it."):
+ segment = await self.adapter.edit_message(
+ "thread-progress",
+ first.message_id,
+ partial,
+ finalize=True,
+ metadata=progress_metadata,
+ )
+ self.assertTrue(segment.success)
+ self.assertIn("thread-progress", self.adapter._active_turns)
+ self.assertNotIn(
+ "turn.completed",
+ [message["type"] for message in self.connection.messages],
+ )
+
+ # Now the real answer arrives as the gateway's notify-marked final
+ # send. That — and only that — closes the turn, exactly once.
+ answer = await self.adapter.send(
+ "thread-progress",
+ "\n".join(progress_lines) + "\nHere is the real answer.",
+ metadata={"notify": True},
+ )
+ self.assertTrue(answer.success)
+ self.assertNotIn("thread-progress", self.adapter._active_turns)
+ self.assertEqual(
+ [
+ message["type"]
+ for message in self.connection.messages[progress_start:]
+ if message["type"] == "turn.completed"
+ ],
+ ["turn.completed"],
+ )
+
+ # A late finalize edit after completion cannot resurrect or re-close
+ # the turn; it fails closed with the "no active turn" result.
+ late = await edit_progress_message(first.message_id, "late progress")
+ self.assertFalse(late.success)
+ self.assertEqual(late.error, "no active T3 turn")
+ self.assertEqual(
+ len(
+ [
+ message
+ for message in self.connection.messages
+ if message["type"] == "turn.completed"
+ ]
+ ),
+ 1,
+ )
+
+ async def test_cumulative_edits_emit_delta_snapshot_delta_then_finalize(self):
+ await self._start_turn("thread-snapshot", "turn-snapshot")
+ content_start = len(self.connection.messages)
+
+ await self.adapter.send("thread-snapshot", "Hello")
+ duplicate_start = len(self.connection.messages)
+ await self.adapter.edit_message("thread-snapshot", "message", "Hello")
+ self.assertEqual(len(self.connection.messages), duplicate_start)
+
+ await self.adapter.edit_message("thread-snapshot", "message", "Help")
+ snapshot_duplicate_start = len(self.connection.messages)
+ await self.adapter.edit_message("thread-snapshot", "message", "Help")
+ self.assertEqual(len(self.connection.messages), snapshot_duplicate_start)
+
+ await self.adapter.edit_message(
+ "thread-snapshot",
+ "message",
+ "Helpful",
+ finalize=True,
+ )
+ # `finalize` is inert; the notify send is what closes the turn.
+ await self.adapter.send("thread-snapshot", "Helpful", metadata={"notify": True})
+
+ content_frames = self.connection.messages[content_start:]
+ self.assertEqual(
+ [message["type"] for message in content_frames],
+ [
+ "item.started",
+ "content.delta",
+ "content.snapshot",
+ "content.delta",
+ "item.completed",
+ "turn.completed",
+ "connection.status",
+ ],
+ )
+ self.assertEqual(content_frames[1]["delta"], "Hello")
+ self.assertEqual(content_frames[2]["text"], "Help")
+ self.assertEqual(content_frames[3]["delta"], "ful")
+
+ async def test_empty_and_duplicate_cumulative_edits_are_reconciled(self):
+ await self._start_turn("thread-empty", "turn-empty")
+ content_start = len(self.connection.messages)
+
+ await self.adapter.send("thread-empty", "")
+ duplicate_start = len(self.connection.messages)
+ await self.adapter.edit_message("thread-empty", "message", "")
+ self.assertEqual(len(self.connection.messages), duplicate_start)
+
+ await self.adapter.edit_message("thread-empty", "message", "Visible")
+ await self.adapter.edit_message("thread-empty", "message", "")
+ empty_snapshot_end = len(self.connection.messages)
+ await self.adapter.edit_message("thread-empty", "message", "")
+ self.assertEqual(len(self.connection.messages), empty_snapshot_end)
+ await self.adapter.edit_message(
+ "thread-empty",
+ "message",
+ "",
+ finalize=True,
+ )
+ await self.adapter.send("thread-empty", "", metadata={"notify": True})
+
+ content_frames = self.connection.messages[content_start:]
+ self.assertEqual(
+ [message["type"] for message in content_frames],
+ [
+ "item.started",
+ "content.delta",
+ "content.snapshot",
+ "item.completed",
+ "turn.completed",
+ "connection.status",
+ ],
+ )
+ self.assertEqual(content_frames[1]["delta"], "Visible")
+ self.assertEqual(content_frames[2]["text"], "")
+
+ async def test_failed_content_sends_do_not_advance_visible_text(self):
+ await self._start_turn("thread-retry", "turn-retry")
+ await self.adapter.send("thread-retry", "Hello")
+ original_send = self.connection.send
+
+ async def fail_content(message):
+ if message["type"] in {"content.delta", "content.snapshot"}:
+ raise ConnectionError("send failed")
+ await original_send(message)
+
+ self.connection.send = fail_content
+ failed = await self.adapter.edit_message(
+ "thread-retry",
+ "message",
+ "Hello world",
+ )
+ self.assertFalse(failed.success)
+ self.assertEqual(
+ self.adapter._active_turns["thread-retry"].visible_text,
+ "Hello",
+ )
+
+ self.connection.send = original_send
+ retried = await self.adapter.edit_message(
+ "thread-retry",
+ "message",
+ "Hello world",
+ )
+ self.assertTrue(retried.success)
+ self.assertEqual(self.connection.messages[-1]["delta"], " world")
+
+ self.connection.send = fail_content
+ failed_snapshot = await self.adapter.edit_message(
+ "thread-retry",
+ "message",
+ "Hi",
+ )
+ self.assertFalse(failed_snapshot.success)
+ self.assertEqual(
+ self.adapter._active_turns["thread-retry"].visible_text,
+ "Hello world",
+ )
+
+ self.connection.send = original_send
+ retried_snapshot = await self.adapter.edit_message(
+ "thread-retry",
+ "message",
+ "Hi",
+ )
+ self.assertTrue(retried_snapshot.success)
+ self.assertEqual(self.connection.messages[-1]["type"], "content.snapshot")
+ self.assertEqual(self.connection.messages[-1]["text"], "Hi")
+
+ async def test_failed_generic_activity_start_retries_the_full_lifecycle(self):
+ await self._start_turn("thread-activity-retry", "turn-activity-retry")
+ turn = self.adapter._active_turns["thread-activity-retry"]
+ original_send = self.connection.send
+
+ async def fail_activity_start(message):
+ if message["type"] == "item.started":
+ raise ConnectionError("send failed")
+ await original_send(message)
+
+ self.connection.send = fail_activity_start
+ with self.assertRaisesRegex(ConnectionError, "send failed"):
+ await self.adapter._emit_generic_activity(turn, "Reading repository")
+
+ self.assertIsNone(turn.generic_activity_id)
+ self.assertIsNone(turn.generic_activity_detail)
+
+ self.connection.send = original_send
+ await self.adapter._emit_generic_activity(turn, "Reading repository")
+ started = self.connection.messages[-1]
+ self.assertEqual(started["type"], "item.started")
+ self.assertEqual(started["detail"], "Reading repository")
+ self.assertEqual(turn.generic_activity_id, started["itemId"])
+ self.assertEqual(turn.generic_activity_detail, "Reading repository")
+
+ await self.adapter._emit_generic_activity(turn, "Running tests")
+ updated = self.connection.messages[-1]
+ self.assertEqual(updated["type"], "item.updated")
+ self.assertEqual(updated["itemId"], started["itemId"])
+ self.assertEqual(updated["detail"], "Running tests")
+
+ async def test_live_status_uses_status_text_not_the_unknown_sentinel(self):
+ await self._start_turn("thread-status-type", "turn-status-type")
+ turn = self.adapter._active_turns["thread-status-type"]
+
+ await self.adapter._emit_generic_activity(turn, "Reading repository")
+ await self.adapter._emit_generic_activity(turn, "Running tests")
+ await self.adapter._complete_turn(turn)
+
+ status_frames = [
+ message
+ for message in self.connection.messages
+ if message.get("itemId") == turn.generic_activity_id
+ ]
+ self.assertEqual(
+ [message["type"] for message in status_frames],
+ ["item.started", "item.updated", "item.completed"],
+ )
+ # `unknown` is the canonical "could not classify" sentinel other
+ # adapters rely on being inert; status lines get their own type.
+ self.assertEqual(
+ {message["itemType"] for message in status_frames},
+ {"status_text"},
+ )
+ # T3 renders these rows preferring `detail`, so the real status string
+ # must ride there rather than only in `title`.
+ self.assertEqual(status_frames[0]["detail"], "Reading repository")
+ self.assertEqual(status_frames[1]["detail"], "Running tests")
+ self.assertEqual(status_frames[2]["detail"], "Running tests")
+
+ async def test_concurrent_generic_activity_updates_share_one_lifecycle(self):
+ await self._start_turn("thread-activity-concurrent", "turn-activity-concurrent")
+ turn = self.adapter._active_turns["thread-activity-concurrent"]
+ original_send = self.connection.send
+ first_send_started = asyncio.Event()
+ release_first_send = asyncio.Event()
+
+ async def block_first_activity_send(message):
+ if message["type"] == "item.started" and not first_send_started.is_set():
+ first_send_started.set()
+ await release_first_send.wait()
+ await original_send(message)
+
+ self.connection.send = block_first_activity_send
+ first_update = asyncio.create_task(
+ self.adapter._emit_generic_activity(turn, "Reading repository")
+ )
+ await first_send_started.wait()
+ second_update = asyncio.create_task(
+ self.adapter._emit_generic_activity(turn, "Running tests")
+ )
+ await asyncio.sleep(0)
+ release_first_send.set()
+ await asyncio.gather(first_update, second_update)
+
+ activity_frames = [
+ message
+ for message in self.connection.messages
+ if message["type"] in {"item.started", "item.updated"}
+ ]
+ self.assertEqual(
+ [message["type"] for message in activity_frames],
+ ["item.started", "item.updated"],
+ )
+ self.assertEqual(activity_frames[0]["itemId"], activity_frames[1]["itemId"])
+ self.assertEqual(turn.generic_activity_id, activity_frames[0]["itemId"])
+ self.assertEqual(turn.generic_activity_detail, "Running tests")
+
+ async def test_turn_completion_waits_for_in_flight_generic_activity_update(self):
+ await self._start_turn("thread-activity-complete", "turn-activity-complete")
+ turn = self.adapter._active_turns["thread-activity-complete"]
+ await self.adapter._emit_generic_activity(turn, "Reading repository")
+ activity_id = turn.generic_activity_id
+ original_send = self.connection.send
+ update_send_started = asyncio.Event()
+ release_update_send = asyncio.Event()
+
+ async def block_activity_update(message):
+ if message["type"] == "item.updated":
+ update_send_started.set()
+ await release_update_send.wait()
+ await original_send(message)
+
+ self.connection.send = block_activity_update
+ in_flight_update = asyncio.create_task(
+ self.adapter._emit_generic_activity(turn, "Running tests")
+ )
+ await update_send_started.wait()
+ completion = asyncio.create_task(self.adapter._complete_turn(turn))
+ await asyncio.sleep(0)
+ self.assertFalse(completion.done())
+
+ release_update_send.set()
+ await asyncio.gather(in_flight_update, completion)
+
+ lifecycle_frames = [
+ message
+ for message in self.connection.messages
+ if message["type"]
+ in {"item.started", "item.updated", "item.completed", "turn.completed"}
+ ]
+ self.assertEqual(
+ [message["type"] for message in lifecycle_frames],
+ ["item.started", "item.updated", "item.completed", "turn.completed"],
+ )
+ self.assertTrue(
+ all(
+ message["itemId"] == activity_id
+ for message in lifecycle_frames
+ if message["type"].startswith("item.")
+ )
+ )
+ self.assertNotIn("thread-activity-complete", self.adapter._active_turns)
+
+ def test_home_channel_notice_literal_matches_hermes_construction(self):
+ # Hermes builds this notice inline from an f-string rather than
+ # exporting a constant (gateway/run.py:13780 at upstream 62e07223), and
+ # the adapter suppresses it by exact string equality. Reconstruct it the
+ # same way so upstream wording drift fails here loudly instead of
+ # leaking the notice into a T3 transcript.
+ platform_name = "t3" # Platform("t3").value
+ sethome_cmd = "/sethome" # non-Slack branch
+ expected = (
+ f"📬 No home channel is set for {platform_name.title()}. "
+ f"A home channel is where Hermes delivers cron job results "
+ f"and cross-platform messages.\n\n"
+ f"Type {sethome_cmd} to make this chat your home channel, "
+ f"or ignore to skip."
+ )
+ self.assertEqual(adapter_module._T3_HOME_CHANNEL_NOTICE, expected)
+
+ async def test_exact_t3_home_channel_notice_is_suppressed(self):
+ await self._start_turn("thread-notice", "turn-notice")
+ content_start = len(self.connection.messages)
+ notice = (
+ "📬 No home channel is set for T3. "
+ "A home channel is where Hermes delivers cron job results "
+ "and cross-platform messages.\n\n"
+ "Type /sethome to make this chat your home channel, or ignore to skip."
+ )
+
+ suppressed = await self.adapter.send("thread-notice", notice)
+ self.assertTrue(suppressed.success)
+ self.assertEqual(len(self.connection.messages), content_start)
+ self.assertFalse(
+ self.adapter._active_turns["thread-notice"].assistant_started
+ )
+
+ await self.adapter.edit_message(
+ "thread-notice",
+ "message",
+ "The actual Hermes response",
+ finalize=True,
+ )
+ await self.adapter.send(
+ "thread-notice",
+ "The actual Hermes response",
+ metadata={"notify": True},
+ )
+ content_frames = self.connection.messages[content_start:]
+ self.assertEqual(
+ [message["type"] for message in content_frames],
+ [
+ "item.started",
+ "content.delta",
+ "item.completed",
+ "turn.completed",
+ "connection.status",
+ ],
+ )
+ self.assertEqual(content_frames[1]["delta"], "The actual Hermes response")
+
+ async def test_terminal_send_suppresses_exact_home_notice_and_completes_turn(self):
+ await self._start_turn("thread-terminal-notice-send", "turn-terminal-notice-send")
+ content_start = len(self.connection.messages)
+ notice = (
+ "📬 No home channel is set for T3. "
+ "A home channel is where Hermes delivers cron job results "
+ "and cross-platform messages.\n\n"
+ "Type /sethome to make this chat your home channel, or ignore to skip."
+ )
+
+ suppressed = await self.adapter.send(
+ "thread-terminal-notice-send",
+ notice,
+ metadata={"notify": True},
+ )
+
+ self.assertTrue(suppressed.success)
+ self.assertNotIn("thread-terminal-notice-send", self.adapter._active_turns)
+ self.assertEqual(
+ [
+ message["type"]
+ for message in self.connection.messages[content_start:]
+ ],
+ ["turn.completed", "connection.status"],
+ )
+
+ async def test_edit_of_exact_home_notice_is_suppressed_without_completing(self):
+ await self._start_turn("thread-terminal-notice-edit", "turn-terminal-notice-edit")
+ content_start = len(self.connection.messages)
+ notice = (
+ "📬 No home channel is set for T3. "
+ "A home channel is where Hermes delivers cron job results "
+ "and cross-platform messages.\n\n"
+ "Type /sethome to make this chat your home channel, or ignore to skip."
+ )
+
+ suppressed = await self.adapter.edit_message(
+ "thread-terminal-notice-edit",
+ "message",
+ notice,
+ finalize=True,
+ )
+
+ self.assertTrue(suppressed.success)
+ # The notice is still swallowed, but an edit — even a `finalize` one —
+ # no longer ends the turn: the progress loop sets `finalize` on every
+ # progress bubble, so acting on it truncated real turns.
+ self.assertIn("thread-terminal-notice-edit", self.adapter._active_turns)
+ self.assertEqual(self.connection.messages[content_start:], [])
+
+ async def test_near_match_home_channel_text_is_not_suppressed(self):
+ await self._start_turn("thread-notice-near-match", "turn-notice-near-match")
+ content_start = len(self.connection.messages)
+ await self.adapter.edit_message(
+ "thread-notice-near-match",
+ "message",
+ (
+ "📬 No home channel is set for T3. "
+ "A home channel is where Hermes delivers cron job results "
+ "and cross-platform messages.\n\n"
+ "Type /sethome to make this chat your home channel, "
+ "or ignore to skip. "
+ ),
+ finalize=True,
+ )
+ await self.adapter.send(
+ "thread-notice-near-match", "done", metadata={"notify": True}
+ )
+ self.assertEqual(
+ [
+ message["type"]
+ for message in self.connection.messages[content_start:]
+ ],
+ [
+ "item.started",
+ "content.delta",
+ "content.snapshot",
+ "item.completed",
+ "turn.completed",
+ "connection.status",
+ ],
+ )
+
+ async def test_session_ready_reports_an_active_turn_on_reconnect(self):
+ session_id = await self._start_turn("thread-reconnect", "turn-reconnect")
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-reconnect",
+ "threadId": "thread-reconnect",
+ "resumeSessionId": session_id,
+ }
+ )
+
+ ready = self.connection.messages[-2]
+ self.assertEqual(ready["type"], "session.ready")
+ self.assertTrue(ready["resumed"])
+ self.assertEqual(ready["activeTurnId"], "turn-reconnect")
+
+ async def test_steer_uses_official_hermes_command(self):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-2",
+ "threadId": "thread-2",
+ }
+ )
+ session_id = self.adapter._sessions["thread-2"]
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-2",
+ "threadId": "thread-2",
+ "sessionId": session_id,
+ "turnId": "turn-2",
+ "text": "Start",
+ }
+ )
+ messages_before_steer = len(self.connection.messages)
+
+ async def accept_steer(_event):
+ return (
+ "⏩ Steer queued — arrives after the next tool call: 'Focus on tests'"
+ )
+
+ self.adapter._message_handler = accept_steer
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.steer",
+ "protocolVersion": 4,
+ "requestId": "steer-2",
+ "threadId": "thread-2",
+ "sessionId": session_id,
+ "turnId": "turn-2",
+ "text": "Focus on tests",
+ }
+ )
+ self.assertEqual(self.adapter.messages[-1].text, "/steer Focus on tests")
+ self.assertEqual(self.adapter.messages[-1].message_type, MessageType.COMMAND)
+ steer_messages = self.connection.messages[messages_before_steer:]
+ self.assertEqual(
+ [message["type"] for message in steer_messages], ["turn.started"]
+ )
+ self.assertEqual(steer_messages[0]["requestId"], "steer-2")
+ self.assertIn("thread-2", self.adapter._active_turns)
+
+ # A post-steer edit streams the real answer. `finalize` is inert — the
+ # gateway sets it on every tool-progress edit — so the turn must stay
+ # open until the notify-marked final send arrives.
+ await self.adapter.edit_message(
+ "thread-2",
+ "message",
+ "Actual response after steering",
+ finalize=True,
+ )
+ deltas = [
+ message["delta"]
+ for message in self.connection.messages
+ if message["type"] == "content.delta"
+ ]
+ self.assertEqual(deltas, ["Actual response after steering"])
+ self.assertIn("thread-2", self.adapter._active_turns)
+ self.assertNotIn(
+ "turn.completed", [m["type"] for m in self.connection.messages]
+ )
+
+ await self.adapter.send(
+ "thread-2",
+ "Actual response after steering",
+ metadata={"notify": True},
+ )
+ self.assertNotIn("thread-2", self.adapter._active_turns)
+ self.assertEqual(
+ [
+ message["type"]
+ for message in self.connection.messages
+ if message["type"] == "turn.completed"
+ ],
+ ["turn.completed"],
+ )
+
+ async def test_assistant_output_during_a_steer_is_not_captured_as_control(self):
+ session_id = await self._start_turn("thread-steer-race", "turn-steer-race")
+ messages_before_steer = len(self.connection.messages)
+
+ async def stream_while_steering(event):
+ # A steer targets a RUNNING turn, so Hermes can emit genuine
+ # assistant output on this same thread while the steering command
+ # is still being awaited. That output must reach the transcript.
+ await self.adapter.edit_message(
+ "thread-steer-race",
+ "hermes-stream-message",
+ "Mid-steer assistant output",
+ )
+ del event
+ return "⏩ Steer queued — arrives after the next tool call: 'Focus'"
+
+ self.adapter._message_handler = stream_while_steering
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.steer",
+ "protocolVersion": 4,
+ "requestId": "steer-race",
+ "threadId": "thread-steer-race",
+ "sessionId": session_id,
+ "turnId": "turn-steer-race",
+ "text": "Focus",
+ }
+ )
+
+ steer_messages = self.connection.messages[messages_before_steer:]
+ self.assertEqual(
+ [message["type"] for message in steer_messages],
+ ["item.started", "content.delta", "turn.started"],
+ )
+ self.assertEqual(steer_messages[1]["delta"], "Mid-steer assistant output")
+ # The acknowledgement itself is still captured and suppressed, so the
+ # steer is acknowledged rather than failing closed on the prefix check.
+ self.assertEqual(steer_messages[2]["requestId"], "steer-race")
+ self.assertIn("thread-steer-race", self.adapter._active_turns)
+ self.assertEqual(
+ self.adapter._active_turns["thread-steer-race"].visible_text,
+ "Mid-steer assistant output",
+ )
+
+ async def test_steer_control_acknowledgement_edits_stay_suppressed(self):
+ session_id = await self._start_turn("thread-steer-edit", "turn-steer-edit")
+ messages_before_steer = len(self.connection.messages)
+ acknowledgement = "⏩ Steer queued — arrives after the next tool call: 'Focus'"
+
+ async def edit_own_acknowledgement(event):
+ sent = await self.adapter.send(
+ "thread-steer-edit",
+ acknowledgement,
+ reply_to=event.message_id,
+ metadata={"notify": True},
+ )
+ # A retry/finalize edit of the control message correlates by the
+ # synthetic control message id, so it stays out of the transcript.
+ await self.adapter.edit_message(
+ "thread-steer-edit",
+ sent.message_id,
+ acknowledgement,
+ finalize=True,
+ )
+
+ self.adapter._message_handler = edit_own_acknowledgement
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.steer",
+ "protocolVersion": 4,
+ "requestId": "steer-edit",
+ "threadId": "thread-steer-edit",
+ "sessionId": session_id,
+ "turnId": "turn-steer-edit",
+ "text": "Focus",
+ }
+ )
+
+ steer_messages = self.connection.messages[messages_before_steer:]
+ self.assertEqual(
+ [message["type"] for message in steer_messages], ["turn.started"]
+ )
+ self.assertIn("thread-steer-edit", self.adapter._active_turns)
+
+ async def test_rejected_steer_emits_error_without_completing_active_turn(self):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-rejected-steer",
+ "threadId": "thread-rejected-steer",
+ }
+ )
+ session_id = self.adapter._sessions["thread-rejected-steer"]
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-rejected-steer",
+ "threadId": "thread-rejected-steer",
+ "sessionId": session_id,
+ "turnId": "turn-rejected-steer",
+ "text": "Start",
+ }
+ )
+ messages_before_steer = len(self.connection.messages)
+
+ async def reject_steer(_event):
+ return "Steer rejected (empty payload)."
+
+ self.adapter._message_handler = reject_steer
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.steer",
+ "protocolVersion": 4,
+ "requestId": "steer-rejected",
+ "threadId": "thread-rejected-steer",
+ "sessionId": session_id,
+ "turnId": "turn-rejected-steer",
+ "text": "Focus on tests",
+ }
+ )
+
+ # The core invariant: rejecting a steer reports an error and leaves the
+ # running turn untouched. The rejection must emit exactly the error —
+ # no turn lifecycle frame of any kind.
+ steer_messages = self.connection.messages[messages_before_steer:]
+ self.assertEqual(
+ [message["type"] for message in steer_messages], ["protocol.error"]
+ )
+ self.assertEqual(steer_messages[0]["requestId"], "steer-rejected")
+ self.assertEqual(steer_messages[0]["code"], "invalid-message")
+ self.assertIn("thread-rejected-steer", self.adapter._active_turns)
+
+ # The still-active turn keeps streaming. `finalize` on an edit is inert
+ # (the gateway sets it on every progress bubble), so the turn survives.
+ await self.adapter.edit_message(
+ "thread-rejected-steer",
+ "message",
+ "Actual response after rejected steering",
+ finalize=True,
+ )
+ self.assertEqual(self.connection.messages[-1]["type"], "content.delta")
+ self.assertIn("thread-rejected-steer", self.adapter._active_turns)
+ self.assertNotIn(
+ "turn.completed", [m["type"] for m in self.connection.messages]
+ )
+
+ # Only the notify-marked final send ends it.
+ await self.adapter.send(
+ "thread-rejected-steer",
+ "Actual response after rejected steering",
+ metadata={"notify": True},
+ )
+ self.assertEqual(self.connection.messages[-1]["type"], "connection.status")
+ self.assertNotIn("thread-rejected-steer", self.adapter._active_turns)
+
+ async def test_failed_steer_emits_correlated_internal_error(self):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-failed-steer",
+ "threadId": "thread-failed-steer",
+ }
+ )
+ session_id = self.adapter._sessions["thread-failed-steer"]
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-failed-steer",
+ "threadId": "thread-failed-steer",
+ "sessionId": session_id,
+ "turnId": "turn-failed-steer",
+ "text": "Start",
+ }
+ )
+ messages_before_steer = len(self.connection.messages)
+
+ async def fail_steer(_event):
+ raise RuntimeError("running agent rejected steering")
+
+ self.adapter._message_handler = fail_steer
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.steer",
+ "protocolVersion": 4,
+ "requestId": "steer-failed",
+ "threadId": "thread-failed-steer",
+ "sessionId": session_id,
+ "turnId": "turn-failed-steer",
+ "text": "Focus on tests",
+ }
+ )
+
+ steer_messages = self.connection.messages[messages_before_steer:]
+ self.assertEqual(
+ [message["type"] for message in steer_messages], ["protocol.error"]
+ )
+ self.assertEqual(steer_messages[0]["requestId"], "steer-failed")
+ self.assertEqual(steer_messages[0]["code"], "internal-error")
+ self.assertIn("thread-failed-steer", self.adapter._active_turns)
+
+ async def test_schedule_keeps_a_strong_reference_until_the_task_finishes(self):
+ self.adapter._event_loop = asyncio.get_running_loop()
+ released = asyncio.Event()
+
+ async def work():
+ await asyncio.sleep(0)
+ released.set()
+
+ self.adapter._schedule(work())
+ self.assertEqual(len(self.adapter._scheduled_tasks), 1)
+ await released.wait()
+ await asyncio.sleep(0)
+ self.assertEqual(self.adapter._scheduled_tasks, set())
+
+ async def test_schedule_logs_background_task_failures(self):
+ self.adapter._event_loop = asyncio.get_running_loop()
+
+ async def boom():
+ raise RuntimeError("background frame failed")
+
+ with self.assertLogs(adapter_module.logger, level="ERROR") as captured:
+ self.adapter._schedule(boom())
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ self.assertTrue(
+ any("background frame failed" in line for line in captured.output)
+ )
+ self.assertEqual(self.adapter._scheduled_tasks, set())
+
+ async def test_schedule_does_not_create_tasks_on_a_foreign_loop(self):
+ other_loop = asyncio.new_event_loop()
+ self.adapter._event_loop = other_loop
+
+ async def work():
+ return None
+
+ coroutine = work()
+ try:
+ with (
+ unittest.mock.patch.object(other_loop, "create_task") as create_task,
+ unittest.mock.patch.object(
+ adapter_module.asyncio, "run_coroutine_threadsafe"
+ ) as threadsafe,
+ ):
+ # The running loop is this test's loop, not the adapter's, so
+ # create_task would schedule onto the wrong loop entirely.
+ self.adapter._schedule(coroutine)
+ create_task.assert_not_called()
+ threadsafe.assert_called_once_with(coroutine, other_loop)
+ self.assertEqual(self.adapter._scheduled_tasks, set())
+ finally:
+ coroutine.close()
+ other_loop.close()
+
+ async def test_schedule_closes_the_coroutine_when_the_loop_is_gone(self):
+ self.adapter._event_loop = None
+ started = False
+
+ async def work():
+ nonlocal started
+ started = True
+
+ coroutine = work()
+ self.adapter._schedule(coroutine)
+ self.assertFalse(started)
+ self.assertEqual(self.adapter._scheduled_tasks, set())
+
+ async def test_session_status_counts_ready_sessions_and_stop_decrements(self):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-3",
+ "threadId": "thread-3",
+ }
+ )
+ session_id = self.adapter._sessions["thread-3"]
+ self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1)
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.stop",
+ "protocolVersion": 4,
+ "requestId": "stop-3",
+ "threadId": "thread-3",
+ "sessionId": session_id,
+ }
+ )
+ self.assertEqual(self.connection.messages[-1]["type"], "connection.status")
+ self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 0)
+ self.assertEqual(self.adapter._sessions["thread-3"], session_id)
+
+ async def test_describe_request_replies_with_the_requests_own_id(self):
+ with unittest.mock.patch.object(
+ adapter_module, "_hermes_version", return_value="0.19.0"
+ ), unittest.mock.patch.object(
+ adapter_module,
+ "describe_response",
+ wraps=adapter_module.describe_response,
+ ) as describe:
+ await self.adapter._handle_server_frame(
+ {
+ "type": "describe.request",
+ "protocolVersion": 4,
+ "requestId": "describe-1",
+ }
+ )
+
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "describe.response")
+ # Correlation, exactly like ping -> pong.
+ self.assertEqual(reply["requestId"], "describe-1")
+ self.assertEqual(reply["protocolVersion"], 4)
+ self.assertEqual(reply["hermesVersion"], "0.19.0")
+ self.assertIsInstance(reply["skills"], list)
+ self.assertIn("capabilities", reply)
+ self.assertEqual(describe.call_count, 1)
+
+ async def test_describe_request_survives_hermes_being_unreadable(self):
+ # An older Hermes whose modules exist but export none of the accessors
+ # the plugin reads. The reply gets thinner; it never becomes an error
+ # and never breaks the connection.
+ with hermes_without_describe_surfaces():
+ await self.adapter._handle_server_frame(
+ {
+ "type": "describe.request",
+ "protocolVersion": 4,
+ "requestId": "describe-degraded",
+ }
+ )
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "describe.response")
+ self.assertEqual(reply["requestId"], "describe-degraded")
+ self.assertNotIn("reasoningEffort", reply)
+ self.assertNotIn("model", reply)
+ self.assertEqual(reply["skills"], [])
+ self.assertEqual(reply["pluginVersion"], protocol_module.PLUGIN_VERSION)
+
+ async def test_skill_body_request_survives_hermes_being_unreadable(self):
+ with hermes_without_describe_surfaces():
+ await self.adapter._handle_server_frame(
+ {
+ "type": "skill.body.request",
+ "protocolVersion": 4,
+ "requestId": "body-degraded",
+ "skillName": "codex",
+ }
+ )
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "skill.body.response")
+ self.assertEqual(reply["requestId"], "body-degraded")
+ self.assertEqual(reply["skillName"], "codex")
+ self.assertIsNone(reply["markdown"])
+
+ async def test_skill_body_request_replies_with_correlated_markdown(self):
+ with unittest.mock.patch.object(
+ adapter_module, "skill_body", return_value="# Codex\n"
+ ) as read_body:
+ await self.adapter._handle_server_frame(
+ {
+ "type": "skill.body.request",
+ "protocolVersion": 4,
+ "requestId": "body-1",
+ "skillName": "codex",
+ }
+ )
+
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "skill.body.response")
+ self.assertEqual(reply["requestId"], "body-1")
+ self.assertEqual(reply["skillName"], "codex")
+ self.assertEqual(reply["markdown"], "# Codex\n")
+ read_body.assert_called_once_with("codex")
+
+ async def test_skill_body_request_replies_null_for_an_unknown_skill(self):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "skill.body.request",
+ "protocolVersion": 4,
+ "requestId": "body-2",
+ "skillName": "does-not-exist",
+ }
+ )
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "skill.body.response")
+ self.assertEqual(reply["requestId"], "body-2")
+ self.assertEqual(reply["skillName"], "does-not-exist")
+ # Present but null, not an error the UI would have to render.
+ self.assertIn("markdown", reply)
+ self.assertIsNone(reply["markdown"])
+
+ async def test_skill_body_request_without_a_name_is_a_correlated_error(self):
+ # `skillName` is echoed back for the client to key on and is non-empty
+ # on the wire, so a nameless request cannot be answered with a
+ # response frame — it takes the ordinary protocol.error path.
+ await self.adapter._handle_server_frame(
+ {
+ "type": "skill.body.request",
+ "protocolVersion": 4,
+ "requestId": "body-3",
+ }
+ )
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "protocol.error")
+ self.assertEqual(reply["requestId"], "body-3")
+ self.assertEqual(reply["code"], "unsupported-message")
+ self.assertTrue(reply["recoverable"])
+
+ async def test_describe_frames_never_emit_a_protocol_error(self):
+ for message in (
+ {
+ "type": "describe.request",
+ "protocolVersion": 4,
+ "requestId": "describe-no-error",
+ },
+ {
+ "type": "skill.body.request",
+ "protocolVersion": 4,
+ "requestId": "body-no-error",
+ "skillName": "codex",
+ },
+ ):
+ with self.subTest(frame_type=message["type"]):
+ await self.adapter._handle_server_frame(message)
+ self.assertNotIn(
+ "protocol.error",
+ [message["type"] for message in self.connection.messages],
+ )
+
+ def test_tool_progress_chrome_is_dropped(self):
+ """Tool chrome is redundant with T3's typed activity items.
+
+ T3 already renders tool calls as typed `item.started` /
+ `item.completed` activity from the `pre_tool_call` / `post_tool_call`
+ hooks, so a text line duplicating them is strictly worse.
+
+ NOTE: this override is NOT what protects the turn. At Hermes 62e07223
+ it is not even on the live path — its only caller,
+ `GatewayEventDispatcher` (`gateway/stream_dispatch.py:108`), is
+ referenced solely by upstream tests. The turn is protected by ignoring
+ `finalize` in `edit_message`; see
+ `test_tool_progress_bubble_edits_never_complete_the_turn`.
+ """
+
+ class _ToolCallChunk:
+ tool_name = "skill_view"
+ preview = "hermes-agent"
+ args = {"name": "hermes-agent"}
+
+ for mode in ("all", "new", "verbose"):
+ with self.subTest(mode=mode):
+ self.assertIsNone(
+ self.adapter.format_tool_event(_ToolCallChunk(), mode=mode)
+ )
+
+ async def test_tool_hooks_resolve_the_turn_from_the_gateway_session_key(self):
+ """Tool hooks carry Hermes' run id, not this plugin's session id.
+
+ `agent.session_id` (`agent/tool_executor.py:188`) is a timestamped run
+ id like `20260725_143012_ab12cd34` (`gateway/session.py:2388`), while
+ this plugin's session ids come from `build_session_key`
+ (`agent:main:t3:dm:`). Keying `_thread_by_session` on the hook's
+ value alone therefore never matches and silently drops every tool
+ activity item. The gateway's stable routing key is available from
+ `HERMES_SESSION_KEY` (`gateway/run.py:17367`), which IS the
+ build_session_key value.
+ """
+ self.adapter._event_loop = asyncio.get_running_loop()
+ session_id = await self._start_turn("thread-tools", "turn-tools")
+ frames_before = len(self.connection.messages)
+
+ # What Hermes actually passes: an unrelated run id.
+ hermes_run_id = "20260725_143012_ab12cd34"
+ self.assertNotIn(hermes_run_id, self.adapter._thread_by_session)
+
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: session_id),
+ ):
+ self.adapter.emit_tool_started(
+ hermes_run_id, "web_search", {"query": "hermes"}, "call-1"
+ )
+ self.adapter.emit_tool_completed(
+ hermes_run_id, "web_search", "result", 42, "call-1"
+ )
+ await asyncio.sleep(0)
+
+ tool_frames = self.connection.messages[frames_before:]
+ self.assertEqual(
+ [message["type"] for message in tool_frames],
+ ["item.started", "item.completed"],
+ )
+ # Both halves must correlate onto ONE activity item, or T3 renders a
+ # started row that never resolves plus an orphan completion.
+ self.assertEqual(tool_frames[0]["itemId"], tool_frames[1]["itemId"])
+ self.assertEqual(tool_frames[0]["title"], "web_search")
+ self.assertEqual(tool_frames[1]["status"], "completed")
+ self.assertEqual(tool_frames[0]["threadId"], "thread-tools")
+ self.assertEqual(tool_frames[0]["sessionId"], session_id)
+
+ async def test_tool_hooks_fall_back_to_the_sole_active_turn(self):
+ """With exactly one active turn there is no ambiguity to resolve."""
+ self.adapter._event_loop = asyncio.get_running_loop()
+ await self._start_turn("thread-only", "turn-only")
+ frames_before = len(self.connection.messages)
+
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: ""),
+ ):
+ self.adapter.emit_tool_started(
+ "20260725_143012_ab12cd34", "read_file", {"path": "a.py"}, "call-2"
+ )
+ await asyncio.sleep(0)
+
+ tool_frames = self.connection.messages[frames_before:]
+ self.assertEqual([m["type"] for m in tool_frames], ["item.started"])
+ self.assertEqual(tool_frames[0]["threadId"], "thread-only")
+
+ async def test_tool_hooks_drop_when_the_turn_is_ambiguous(self):
+ """Two concurrent turns and no routing key: emit nothing.
+
+ Guessing would attach one thread's tool activity to another's
+ transcript. Tool activity is decorative, so dropping is correct.
+ """
+ self.adapter._event_loop = asyncio.get_running_loop()
+ await self._start_turn("thread-a", "turn-a")
+ await self._start_turn("thread-b", "turn-b")
+ frames_before = len(self.connection.messages)
+
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: ""),
+ ):
+ self.adapter.emit_tool_started(
+ "20260725_143012_ab12cd34", "read_file", {"path": "a.py"}, "call-3"
+ )
+ self.adapter.emit_tool_completed(
+ "20260725_143012_ab12cd34", "read_file", "ok", 5, "call-3"
+ )
+ await asyncio.sleep(0)
+
+ self.assertEqual(self.connection.messages[frames_before:], [])
+
+ async def test_tool_hook_session_key_lookup_never_raises(self):
+ """An unavailable Hermes session context must degrade, not raise."""
+ self.adapter._event_loop = asyncio.get_running_loop()
+ await self._start_turn("thread-ctx-a", "turn-ctx-a")
+ await self._start_turn("thread-ctx-b", "turn-ctx-b")
+ frames_before = len(self.connection.messages)
+
+ def _boom():
+ raise RuntimeError("no session context bound")
+
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(_boom),
+ ):
+ with self.assertRaises(RuntimeError):
+ adapter_module.T3PlatformAdapter._gateway_session_key()
+
+ # The real accessor swallows its own failures rather than propagating.
+ with unittest.mock.patch.dict(sys.modules, {"gateway.session_context": None}):
+ self.assertEqual(self.adapter._gateway_session_key(), "")
+ self.adapter.emit_tool_started(
+ "20260725_143012_ab12cd34", "read_file", {"path": "a.py"}, "call-4"
+ )
+ await asyncio.sleep(0)
+ self.assertEqual(self.connection.messages[frames_before:], [])
+
+ async def test_status_line_closes_before_the_assistant_message(self):
+ """The status item must complete BEFORE the terminal assistant message.
+
+ T3 folds a settled turn's activity behind the "Worked for …" row, but
+ only entries that precede the turn's terminal assistant message.
+ Completing the status item afterwards stamped it milliseconds later, so
+ it sorted below the answer, escaped the fold, and rendered as a stray
+ "Work Log" section under the reply.
+ """
+ await self._start_turn("thread-order", "turn-order")
+ turn = self.adapter._active_turns["thread-order"]
+ await self.adapter._emit_generic_activity(turn, "Reading repository")
+ await self.adapter.send("thread-order", "The answer")
+ order_start = len(self.connection.messages)
+
+ await self.adapter.send("thread-order", "The answer", metadata={"notify": True})
+
+ completions = [
+ message
+ for message in self.connection.messages[order_start:]
+ if message["type"] == "item.completed"
+ ]
+ self.assertEqual(
+ [message["itemType"] for message in completions],
+ ["status_text", "assistant_message"],
+ )
+ types_after = [m["type"] for m in self.connection.messages[order_start:]]
+ self.assertEqual(types_after[-2:], ["turn.completed", "connection.status"])
+
+ async def test_cron_tool_hooks_are_excluded_from_the_sole_turn_fallback(self):
+ """A cron job's tool calls must never land in an unrelated live turn.
+
+ The hooks are process-global, so a cron job running tools while exactly
+ one T3 turn happens to be active would otherwise resolve through the
+ sole-active-turn fallback and paint its tool rows into a conversation
+ it has nothing to do with. Cron runs are identifiable by the
+ `cron__` session id the scheduler mints
+ (`cron/scheduler.py:3017`); their activity belongs to the eventual
+ `home.deliver`, not to any turn.
+ """
+ self.adapter._event_loop = asyncio.get_running_loop()
+ await self._start_turn("thread-live", "turn-live")
+ frames_before = len(self.connection.messages)
+ cron_session = "cron_daily-digest_20260726_090000"
+
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: ""),
+ ):
+ self.adapter.emit_tool_started(
+ cron_session, "web_search", {"query": "weather"}, "cron-call-1"
+ )
+ self.adapter.emit_tool_completed(
+ cron_session, "web_search", "sunny", 12, "cron-call-1"
+ )
+ await asyncio.sleep(0)
+
+ self.assertEqual(self.connection.messages[frames_before:], [])
+ # The unrelated turn is untouched and still streaming.
+ self.assertIn("thread-live", self.adapter._active_turns)
+
+ # A genuine gateway run id still takes the fallback — the exclusion is
+ # scoped to cron, not a blanket removal of the fallback.
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: ""),
+ ):
+ self.adapter.emit_tool_started(
+ "20260726_090000_ab12cd34", "read_file", {"path": "a.py"}, "call-9"
+ )
+ await asyncio.sleep(0)
+ fallback_frames = self.connection.messages[frames_before:]
+ self.assertEqual([m["type"] for m in fallback_frames], ["item.started"])
+ self.assertEqual(fallback_frames[0]["threadId"], "thread-live")
+
+ async def test_a_failed_turn_start_leaves_no_phantom_turn_behind(self):
+ """A turn that never started must not wedge its thread forever.
+
+ `_active_turns[thread_id]` is registered before `turn.started` goes out,
+ so a socket that drops in that window used to leave an entry no
+ completion path could ever reach — and the duplicate-turn guard then
+ rejected every future `turn.start` on that thread for the life of the
+ process. One dropped frame permanently silenced the thread.
+ """
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": "ensure-wedged",
+ "threadId": "thread-wedged",
+ }
+ )
+ session_id = self.adapter._sessions["thread-wedged"]
+
+ original_send = self.connection.send
+
+ async def drop_the_turn_started(message):
+ if message.get("type") == "turn.started":
+ raise ConnectionError("socket dropped mid-handshake")
+ await original_send(message)
+
+ with unittest.mock.patch.object(
+ self.connection, "send", drop_the_turn_started
+ ):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-wedged",
+ "threadId": "thread-wedged",
+ "sessionId": session_id,
+ "turnId": "turn-wedged",
+ "text": "Start",
+ }
+ )
+
+ # Rolled back, and the failure was reported against its own request.
+ self.assertEqual(self.adapter._active_turns, {})
+ self.assertEqual(self.connection.messages[-1]["type"], "protocol.error")
+ self.assertEqual(self.connection.messages[-1]["requestId"], "start-wedged")
+
+ # The thread is usable again on the very next attempt.
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-recovered",
+ "threadId": "thread-wedged",
+ "sessionId": session_id,
+ "turnId": "turn-recovered",
+ "text": "Try again",
+ }
+ )
+ self.assertEqual(
+ self.adapter._active_turns["thread-wedged"].turn_id, "turn-recovered"
+ )
+ self.assertEqual(self.adapter.messages[-1].text, "Try again")
+
+
+class HomeDeliveryTests(unittest.IsolatedAsyncioTestCase):
+ """The proactive `home.deliver` branch and its durable queue."""
+
+ HOME = "home-thread"
+
+ async def asyncSetUp(self):
+ self.adapter = adapter_module.T3PlatformAdapter(
+ PlatformConfig(
+ extra={
+ "url": "wss://t3.example/api/hermes-gateway/ws",
+ "instance_id": "instance",
+ "credential": "credential",
+ }
+ )
+ )
+ self.connection = FakeConnection()
+ self.adapter._connection = self.connection
+ self.adapter._gateway_interactive_turns_enabled = True
+
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
+ self.queue = home_module.HomeDeliveryQueue(path=queue_file)
+ self.adapter._home_queue = self.queue
+
+ environment = unittest.mock.patch.dict(
+ adapter_module.os.environ,
+ {home_module.HOME_CHANNEL_ENV: self.HOME},
+ )
+ environment.start()
+ self.addCleanup(environment.stop)
+
+ # No Hermes session context is bound in tests, so the real accessors
+ # would fall through to os.environ. Pin them to "no session" — the
+ # state a cron run or a lifecycle broadcast is actually in.
+ for name in ("_gateway_session_key", "_session_user_id"):
+ patch = unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter, name, staticmethod(lambda: "")
+ )
+ patch.start()
+ self.addCleanup(patch.stop)
+
+ async def _start_turn(self, thread_id: str, turn_id: str) -> str:
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": f"ensure-{thread_id}",
+ "threadId": thread_id,
+ }
+ )
+ session_id = self.adapter._sessions[thread_id]
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": f"start-{thread_id}",
+ "threadId": thread_id,
+ "sessionId": session_id,
+ "turnId": turn_id,
+ "text": "Start",
+ }
+ )
+ return session_id
+
+ async def test_a_proactive_send_to_home_emits_home_deliver(self):
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send(
+ self.HOME,
+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
+ metadata={"notify": True, "job_id": "nightly"},
+ )
+
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["home.deliver"])
+ delivery = frames[0]
+ self.assertEqual(delivery["protocolVersion"], 4)
+ self.assertEqual(delivery["threadId"], self.HOME)
+ self.assertEqual(delivery["kind"], "cron")
+ self.assertEqual(delivery["label"], "Cron: nightly")
+ self.assertTrue(delivery["createdAt"].endswith("Z"))
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, delivery["deliveryId"])
+
+ # No turn was invented, and none was completed.
+ self.assertEqual(self.adapter._active_turns, {})
+
+ async def test_a_delivery_never_emits_turn_or_item_frames(self):
+ frames_before = len(self.connection.messages)
+ await self.adapter.send(self.HOME, "♻️ Gateway online — Hermes is back and ready.")
+ emitted = {frame["type"] for frame in self.connection.messages[frames_before:]}
+ self.assertEqual(emitted, {"home.deliver"})
+ self.assertEqual(self.adapter._active_turns, {})
+
+ async def test_public_handoff_callback_creates_and_routes_to_a_t3_thread(self):
+ create = asyncio.create_task(
+ self.adapter.create_handoff_thread(self.HOME, "Hermes — shipping")
+ )
+ await asyncio.sleep(0)
+ request = self.connection.messages[-1]
+ self.assertEqual(request["type"], "handoff.create")
+ self.assertEqual(request["parentThreadId"], self.HOME)
+ self.assertEqual(request["name"], "Hermes — shipping")
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "handoff.created",
+ "protocolVersion": 4,
+ "requestId": request["requestId"],
+ "threadId": "handoff-thread",
+ }
+ )
+ self.assertEqual(await create, "handoff-thread")
+ self.assertEqual(self.adapter._pending_handoff_creates, {})
+
+ frames_before = len(self.connection.messages)
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_session_user_id",
+ staticmethod(lambda: "system:handoff"),
+ ):
+ result = await self.adapter.send(
+ self.HOME,
+ "The CLI session is ready here.",
+ metadata={"thread_id": "handoff-thread", "notify": True},
+ )
+ self.assertTrue(result.success)
+ delivery = self.connection.messages[frames_before]
+ self.assertEqual(delivery["type"], "home.deliver")
+ self.assertEqual(delivery["kind"], "handoff")
+ self.assertEqual(delivery["threadId"], "handoff-thread")
+
+ async def test_handoff_protocol_error_returns_official_home_fallback(self):
+ create = asyncio.create_task(
+ self.adapter.create_handoff_thread(self.HOME, "Unavailable")
+ )
+ await asyncio.sleep(0)
+ request = self.connection.messages[-1]
+ with self.assertLogs(adapter_module.logger, level="WARNING"):
+ await self.adapter._handle_server_frame(
+ {
+ "type": "protocol.error",
+ "protocolVersion": 4,
+ "requestId": request["requestId"],
+ "code": "unsupported-message",
+ "message": "Upgrade T3",
+ "recoverable": True,
+ }
+ )
+ self.assertIsNone(await create)
+ self.assertEqual(self.adapter._pending_handoff_creates, {})
+
+ async def test_reconnect_releases_pending_handoff_without_a_deadlock(self):
+ create = asyncio.create_task(
+ self.adapter.create_handoff_thread(self.HOME, "Reconnect")
+ )
+ await asyncio.sleep(0)
+ request = self.connection.messages[-1]
+
+ await self.adapter._handle_connection_state(False, "socket replaced")
+ self.assertIsNone(await asyncio.wait_for(create, timeout=0.1))
+ self.assertEqual(self.adapter._pending_handoff_creates, {})
+
+ # A response from the fenced socket is late and inert; it cannot
+ # recreate a pending entry or resolve a newer request accidentally.
+ self.adapter._resolve_handoff_create(
+ {
+ "requestId": request["requestId"],
+ "threadId": "late-thread",
+ }
+ )
+ self.assertEqual(self.adapter._pending_handoff_creates, {})
+
+ async def test_handoff_timeout_cleans_up_the_pending_request(self):
+ with unittest.mock.patch.object(
+ adapter_module,
+ "_HANDOFF_CREATE_TIMEOUT_SECONDS",
+ 0.001,
+ ):
+ with self.assertLogs(adapter_module.logger, level="WARNING"):
+ result = await self.adapter.create_handoff_thread(self.HOME, "Timeout")
+ self.assertIsNone(result)
+ self.assertEqual(self.adapter._pending_handoff_creates, {})
+
+ async def test_a_notify_stamped_delivery_does_not_complete_the_live_turn(self):
+ """THE deadlock regression.
+
+ A cron delivery landing in Home while the user has a live turn there
+ arrives notify-stamped (`_mark_notify_metadata`,
+ `gateway/platforms/base.py:89`). Under a naive "no active turn →
+ deliver" gate it would fall into the active-turn path, stream as that
+ turn's assistant content, and its notify stamp would COMPLETE the
+ user's turn with the cron output as the answer. The gate is provenance,
+ not turn absence: the cron send does not carry the turn's session key,
+ so it becomes a `home.deliver` and the turn keeps running.
+ """
+ session_id = await self._start_turn(self.HOME, "turn-user")
+ # The user's turn has already streamed some of its real answer.
+ await self.adapter.send(self.HOME, "Working on it")
+ frames_before = len(self.connection.messages)
+
+ # A cron delivery fires mid-turn, notify-stamped as every final cron
+ # delivery is.
+ result = await self.adapter.send(
+ self.HOME,
+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
+ metadata={"notify": True, "job_id": "nightly"},
+ )
+
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["home.deliver"])
+ self.assertTrue(result.success)
+
+ # The user's turn is untouched: still active, still owning its stream.
+ self.assertIn(self.HOME, self.adapter._active_turns)
+ turn = self.adapter._active_turns[self.HOME]
+ self.assertEqual(turn.turn_id, "turn-user")
+ self.assertEqual(turn.visible_text, "Working on it")
+ self.assertNotIn(
+ "turn.completed", [frame["type"] for frame in self.connection.messages]
+ )
+
+ # …and it still completes normally on its own notify, inside its own
+ # session context.
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: session_id),
+ ):
+ await self.adapter.send(
+ self.HOME, "Here is the answer", metadata={"notify": True}
+ )
+ self.assertNotIn(self.HOME, self.adapter._active_turns)
+ self.assertIn(
+ "turn.completed", [frame["type"] for frame in self.connection.messages]
+ )
+
+ async def test_a_turn_reply_in_home_is_never_rerouted_to_a_delivery(self):
+ """Output produced inside the turn's session context is turn content."""
+ session_id = await self._start_turn(self.HOME, "turn-user")
+ frames_before = len(self.connection.messages)
+
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: session_id),
+ ):
+ await self.adapter.send(self.HOME, "Streaming answer")
+
+ types = [frame["type"] for frame in self.connection.messages[frames_before:]]
+ self.assertEqual(types, ["item.started", "content.delta"])
+ self.assertNotIn("home.deliver", types)
+
+ async def test_an_unclassifiable_send_during_a_live_home_turn_stays_with_it(self):
+ """The conservative half of the gate.
+
+ With a live turn in Home and no positive provenance, the send may well
+ be that turn's own output arriving from a context where the session key
+ did not resolve. Routing it to `home.deliver` would tear a real answer
+ out of the turn; leaving it with the turn is at worst a misplacement
+ inside the same thread.
+ """
+ await self._start_turn(self.HOME, "turn-user")
+ frames_before = len(self.connection.messages)
+
+ await self.adapter.send(self.HOME, "Something unclassifiable")
+
+ types = [frame["type"] for frame in self.connection.messages[frames_before:]]
+ self.assertEqual(types, ["item.started", "content.delta"])
+ self.assertIn(self.HOME, self.adapter._active_turns)
+
+ async def test_a_non_home_thread_without_a_turn_still_errors(self):
+ """"Message any thread unprompted" stays out of scope."""
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send(
+ "some-other-thread",
+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
+ metadata={"notify": True, "job_id": "nightly"},
+ )
+
+ self.assertFalse(result.success)
+ self.assertEqual(result.error, "no active T3 turn")
+ self.assertEqual(self.connection.messages[frames_before:], [])
+
+ async def test_no_designated_home_means_no_proactive_delivery(self):
+ """Before the first `connection.accepted` there is nowhere to deliver."""
+ with unittest.mock.patch.dict(
+ adapter_module.os.environ, {home_module.HOME_CHANNEL_ENV: ""}
+ ):
+ result = await self.adapter.send(self.HOME, "Nowhere to go")
+ self.assertFalse(result.success)
+ self.assertEqual(result.error, "no active T3 turn")
+
+ async def test_edit_message_has_no_proactive_branch(self):
+ """A delivery is an atomic document, not a streaming surface."""
+ result = await self.adapter.edit_message(
+ self.HOME, "some-message", "Revised delivery", finalize=True
+ )
+ self.assertFalse(result.success)
+ self.assertEqual(result.error, "no active T3 turn")
+ self.assertEqual(
+ [frame["type"] for frame in self.connection.messages], []
+ )
+
+ async def test_a_delivery_is_queued_before_it_is_sent_and_purged_on_ack(self):
+ result = await self.adapter.send(self.HOME, "Queued then acked")
+ delivery_id = result.message_id
+ self.assertEqual(
+ [entry["deliveryId"] for entry in self.queue.entries()], [delivery_id]
+ )
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "home.deliver.ack",
+ "protocolVersion": 4,
+ "deliveryId": delivery_id,
+ }
+ )
+ self.assertEqual(self.queue.entries(), [])
+
+ async def test_delivery_queue_io_runs_off_the_gateway_event_loop(self):
+ event_loop_thread = threading.get_ident()
+
+ def append_in_worker(frame):
+ self.assertNotEqual(threading.get_ident(), event_loop_thread)
+ return True
+
+ with unittest.mock.patch.object(
+ self.queue, "append", side_effect=append_in_worker
+ ):
+ result = await self.adapter.send(self.HOME, "Non-blocking queue write")
+
+ self.assertTrue(result.success)
+ self.assertEqual(self.connection.messages[0]["type"], "home.deliver")
+
+ async def test_a_delivery_survives_a_dead_socket_and_flushes_on_reconnect(self):
+ """Offline delivery: nothing is lost across either side restarting."""
+
+ class DeadConnection:
+ connected = False
+
+ async def send(self, message):
+ raise ConnectionError("T3 Code gateway is offline")
+
+ self.adapter._connection = DeadConnection()
+ with self.assertLogs(adapter_module.logger, level="WARNING"):
+ offline = await self.adapter.send(self.HOME, "Sent while offline")
+ # Reported successful: it is durably queued and WILL arrive, so a cron
+ # job must not log a failure for it.
+ self.assertTrue(offline.success)
+ self.assertEqual(
+ [entry["text"] for entry in self.queue.entries()], ["Sent while offline"]
+ )
+
+ # Reconnect: the accepted frame reconciles the designation and flushes.
+ self.adapter._connection = self.connection
+ await self.adapter._handle_connection_accepted(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": "hello-1",
+ "instanceId": "instance",
+ "nickname": "Hermes",
+ "homeThreadId": self.HOME,
+ }
+ )
+
+ flushed = self.connection.messages
+ self.assertEqual([frame["type"] for frame in flushed], ["home.deliver"])
+ self.assertEqual(flushed[0]["text"], "Sent while offline")
+ self.assertEqual(flushed[0]["deliveryId"], offline.message_id)
+ # Still queued — only the ack purges it.
+ self.assertEqual(len(self.queue.entries()), 1)
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "home.deliver.ack",
+ "protocolVersion": 4,
+ "deliveryId": offline.message_id,
+ }
+ )
+ self.assertEqual(self.queue.entries(), [])
+
+ async def test_a_delivery_that_is_neither_queued_nor_sent_reports_failure(self):
+ """Success is a claim about durability, so it needs one leg to hold.
+
+ A queue write that failed used to be ignored: an offline socket then
+ produced "delivered" for content held nowhere, and the cron job that
+ wrote it logged a success for output that will never appear.
+ """
+
+ class DeadConnection:
+ connected = False
+
+ async def send(self, message):
+ raise ConnectionError("T3 Code gateway is offline")
+
+ self.adapter._connection = DeadConnection()
+ with unittest.mock.patch.object(
+ self.queue, "append", return_value=False
+ ), self.assertLogs(adapter_module.logger, level="WARNING"):
+ result = await self.adapter.send(self.HOME, "Held nowhere at all")
+
+ self.assertFalse(result.success)
+ self.assertIn("queued", result.error)
+
+ async def test_a_delivery_that_reached_t3_is_honest_success_unqueued(self):
+ """T3 has it; the ack will simply find nothing to purge."""
+ with unittest.mock.patch.object(self.queue, "append", return_value=False):
+ result = await self.adapter.send(self.HOME, "Sent but not queued")
+
+ self.assertTrue(result.success)
+ self.assertEqual(
+ [frame["type"] for frame in self.connection.messages], ["home.deliver"]
+ )
+ self.assertEqual(self.queue.entries(), [])
+
+ async def test_flush_restamps_stale_protocol_versions(self):
+ """A frame queued under an older plugin must not wedge the reconnect.
+
+ T3's strict-lockstep decoder closes the socket on any frame carrying a
+ different protocolVersion, so a v3-era queued delivery would otherwise
+ turn one stale outbox entry into a reconnect loop that outlives the
+ upgrade. The flush restamps to the current version; the delivery
+ fields themselves are version-stable.
+ """
+ stale = protocol_module.home_deliver(
+ thread_id=self.HOME,
+ text="Queued before the upgrade",
+ kind="cron",
+ label="Cron: nightly",
+ delivery_id_value="stale-v3-delivery",
+ )
+ stale["protocolVersion"] = 3
+ self.assertTrue(self.queue.append(stale))
+
+ await self.adapter._flush_home_queue()
+
+ flushed = self.connection.messages
+ self.assertEqual(len(flushed), 1)
+ self.assertEqual(flushed[0]["protocolVersion"], protocol_module.PROTOCOL_VERSION)
+ self.assertEqual(flushed[0]["text"], "Queued before the upgrade")
+ # The queued copy is untouched — restamping happens on the wire only,
+ # and the entry still purges by deliveryId on ack.
+ self.assertEqual(self.queue.entries()[0]["protocolVersion"], 3)
+
+ async def test_the_queue_flushes_in_fifo_order(self):
+ class DeadConnection:
+ connected = False
+
+ async def send(self, message):
+ raise ConnectionError("T3 Code gateway is offline")
+
+ self.adapter._connection = DeadConnection()
+ with self.assertLogs(adapter_module.logger, level="WARNING"):
+ for text in ("first", "second", "third"):
+ await self.adapter.send(self.HOME, text)
+
+ self.adapter._connection = self.connection
+ await self.adapter._flush_home_queue()
+
+ self.assertEqual(
+ [frame["text"] for frame in self.connection.messages],
+ ["first", "second", "third"],
+ )
+
+ async def test_connection_accepted_reconciles_the_home_designation(self):
+ """T3 owns the designation; a differing local value is overwritten."""
+ with unittest.mock.patch.dict(
+ adapter_module.os.environ,
+ {home_module.HOME_CHANNEL_ENV: "stale-hand-edited-thread"},
+ ), unittest.mock.patch.object(
+ adapter_module, "save_home_thread_id"
+ ) as save:
+ await self.adapter._handle_connection_accepted(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": "hello-1",
+ "instanceId": "instance",
+ "nickname": "Hermes",
+ "homeThreadId": "authoritative-thread",
+ }
+ )
+ save.assert_called_once_with("authoritative-thread")
+
+ async def test_an_accepted_frame_without_a_home_thread_changes_nothing(self):
+ """Resolving the home thread must never fail a handshake."""
+ with unittest.mock.patch.object(
+ adapter_module, "save_home_thread_id"
+ ) as save:
+ await self.adapter._handle_connection_accepted(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": "hello-1",
+ "instanceId": "instance",
+ "nickname": "Hermes",
+ }
+ )
+ save.assert_not_called()
+ self.assertEqual(adapter_module.home_thread_id(), self.HOME)
+
+ async def test_a_nameless_ack_is_a_correlated_protocol_error(self):
+ await self.adapter._handle_server_frame(
+ {"type": "home.deliver.ack", "protocolVersion": 4, "requestId": "ack-1"}
+ )
+ reply = self.connection.messages[-1]
+ self.assertEqual(reply["type"], "protocol.error")
+ self.assertEqual(reply["code"], "unsupported-message")
+
+
+class InboundAttachmentTests(unittest.IsolatedAsyncioTestCase):
+ """v4 turn attachments: base64 on the frame → temp files → media_urls."""
+
+ async def asyncSetUp(self):
+ self.adapter = adapter_module.T3PlatformAdapter(
+ PlatformConfig(
+ extra={
+ "url": "wss://t3.example/api/hermes-gateway/ws",
+ "instance_id": "instance",
+ "credential": "credential",
+ }
+ )
+ )
+ self.connection = FakeConnection()
+ self.adapter._connection = self.connection
+ self.adapter._gateway_interactive_turns_enabled = True
+
+ async def _ensure(self, thread_id: str) -> str:
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": f"ensure-{thread_id}",
+ "threadId": thread_id,
+ }
+ )
+ return self.adapter._sessions[thread_id]
+
+ async def test_turn_attachments_land_as_local_files_on_the_message_event(self):
+ import base64
+
+ session_id = await self._ensure("thread-attach")
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-attach",
+ "threadId": "thread-attach",
+ "sessionId": session_id,
+ "turnId": "turn-attach",
+ "text": "Describe this image",
+ "attachments": [
+ {
+ "name": "photo.png",
+ "mimeType": "image/png",
+ "sizeBytes": 9,
+ "data": base64.b64encode(b"PNG bytes").decode("ascii"),
+ },
+ {
+ "name": "notes.txt",
+ "mimeType": "text/plain",
+ "sizeBytes": 5,
+ "data": base64.b64encode(b"hello").decode("ascii"),
+ },
+ ],
+ }
+ )
+
+ event = self.adapter.messages[-1]
+ self.assertEqual(event.text, "Describe this image")
+ # Aligned pairs, exactly the shape Hermes' enrichment pipeline reads.
+ self.assertEqual(event.media_types, ["image/png", "text/plain"])
+ self.assertEqual(len(event.media_urls), 2)
+ for path, payload in zip(event.media_urls, [b"PNG bytes", b"hello"]):
+ self.addCleanup(
+ lambda p=path: pathlib.Path(p).unlink(missing_ok=True)
+ )
+ self.assertEqual(pathlib.Path(path).read_bytes(), payload)
+ # Secure perms: owner-only file in an owner-only directory.
+ self.assertEqual(pathlib.Path(path).stat().st_mode & 0o777, 0o600)
+ self.assertEqual(
+ pathlib.Path(path).parent.stat().st_mode & 0o777, 0o700
+ )
+ # The extension survives — Hermes routes files by suffix in several
+ # places (audio-vs-document, the text-document allowlist).
+ self.assertTrue(event.media_urls[0].endswith(".png"))
+ self.assertTrue(event.media_urls[1].endswith(".txt"))
+
+ async def test_a_hostile_attachment_name_cannot_escape_the_temp_directory(self):
+ import base64
+
+ session_id = await self._ensure("thread-hostile")
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-hostile",
+ "threadId": "thread-hostile",
+ "sessionId": session_id,
+ "turnId": "turn-hostile",
+ "text": "Look at this",
+ "attachments": [
+ {
+ "name": "../../etc/passwd",
+ "mimeType": "text/plain",
+ "sizeBytes": 4,
+ "data": base64.b64encode(b"evil").decode("ascii"),
+ }
+ ],
+ }
+ )
+ event = self.adapter.messages[-1]
+ path = pathlib.Path(event.media_urls[0])
+ self.addCleanup(lambda: path.unlink(missing_ok=True))
+ self.assertTrue(
+ path.parent.name.startswith("hermes-t3-attachments-"),
+ path,
+ )
+ self.assertNotIn("..", path.name)
+
+ async def test_a_turn_without_attachments_carries_no_media(self):
+ session_id = await self._ensure("thread-plain")
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-plain",
+ "threadId": "thread-plain",
+ "sessionId": session_id,
+ "turnId": "turn-plain",
+ "text": "Just text",
+ }
+ )
+ event = self.adapter.messages[-1]
+ self.assertEqual(event.media_urls, [])
+ self.assertEqual(event.media_types, [])
+
+ async def test_a_malformed_attachment_errors_before_any_turn_starts(self):
+ session_id = await self._ensure("thread-bad-attach")
+ frames_before = len(self.connection.messages)
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-bad",
+ "threadId": "thread-bad-attach",
+ "sessionId": session_id,
+ "turnId": "turn-bad",
+ "text": "With a broken file",
+ "attachments": [{"name": "x.bin", "data": "!!! not base64 !!!"}],
+ }
+ )
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["protocol.error"])
+ self.assertEqual(frames[0]["requestId"], "start-bad")
+ # No half-started turn to clean up, and nothing reached Hermes.
+ self.assertNotIn("thread-bad-attach", self.adapter._active_turns)
+ self.assertEqual(
+ [event.text for event in self.adapter.messages
+ if getattr(event, "message_id", "") == "start-bad"],
+ [],
+ )
+
+ async def test_steer_attachments_ride_the_injected_text_as_path_notes(self):
+ import base64
+
+ session_id = await self._ensure("thread-steer-attach")
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": "start-steer-attach",
+ "threadId": "thread-steer-attach",
+ "sessionId": session_id,
+ "turnId": "turn-steer-attach",
+ "text": "Start",
+ }
+ )
+
+ async def accept_steer(_event):
+ return "⏩ Steer queued — arrives after the next tool call"
+
+ self.adapter._message_handler = accept_steer
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.steer",
+ "protocolVersion": 4,
+ "requestId": "steer-attach",
+ "threadId": "thread-steer-attach",
+ "sessionId": session_id,
+ "turnId": "turn-steer-attach",
+ "text": "Use this file",
+ "attachments": [
+ {
+ "name": "data.csv",
+ "mimeType": "text/csv",
+ "sizeBytes": 3,
+ "data": base64.b64encode(b"a,b").decode("ascii"),
+ }
+ ],
+ }
+ )
+ steer_event = self.adapter.messages[-1]
+ # Hermes' /steer handler injects only text between tool iterations
+ # (`gateway/run.py:11254`), so the file rides the command as a path
+ # note the mid-turn agent can open with its tools.
+ self.assertTrue(steer_event.text.startswith("/steer Use this file\n"))
+ self.assertIn("[The user attached a file (text/csv): ", steer_event.text)
+ path = steer_event.text.rsplit(": ", 1)[1].rstrip("]")
+ self.addCleanup(lambda: pathlib.Path(path).unlink(missing_ok=True))
+ self.assertEqual(pathlib.Path(path).read_bytes(), b"a,b")
+
+
+class MediaDeliveryTests(unittest.IsolatedAsyncioTestCase):
+ """Outbound `media.deliver`: turn scoping plus the durable ack lifecycle."""
+
+ HOME = "home-thread"
+
+ async def asyncSetUp(self):
+ self.adapter = adapter_module.T3PlatformAdapter(
+ PlatformConfig(
+ extra={
+ "url": "wss://t3.example/api/hermes-gateway/ws",
+ "instance_id": "instance",
+ "credential": "credential",
+ }
+ )
+ )
+ self.connection = FakeConnection()
+ self.adapter._connection = self.connection
+ self.adapter._gateway_interactive_turns_enabled = True
+
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
+ self.queue = home_module.HomeDeliveryQueue(path=queue_file)
+ self.adapter._home_queue = self.queue
+
+ self.chart = pathlib.Path(self._tmp.name) / "chart.png"
+ self.chart.write_bytes(b"\x89PNG fake bytes")
+
+ environment = unittest.mock.patch.dict(
+ adapter_module.os.environ,
+ {home_module.HOME_CHANNEL_ENV: self.HOME},
+ )
+ environment.start()
+ self.addCleanup(environment.stop)
+
+ for name in ("_gateway_session_key", "_session_user_id"):
+ patch = unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter, name, staticmethod(lambda: "")
+ )
+ patch.start()
+ self.addCleanup(patch.stop)
+
+ async def _start_turn(self, thread_id: str, turn_id: str) -> str:
+ await self.adapter._handle_server_frame(
+ {
+ "type": "session.ensure",
+ "protocolVersion": 4,
+ "requestId": f"ensure-{thread_id}",
+ "threadId": thread_id,
+ }
+ )
+ session_id = self.adapter._sessions[thread_id]
+ await self.adapter._handle_server_frame(
+ {
+ "type": "turn.start",
+ "protocolVersion": 4,
+ "requestId": f"start-{thread_id}",
+ "threadId": thread_id,
+ "sessionId": session_id,
+ "turnId": turn_id,
+ "text": "Start",
+ }
+ )
+ return session_id
+
+ async def test_turn_media_is_delivered_turn_scoped(self):
+ await self._start_turn("thread-media", "turn-media")
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_image_file(
+ "thread-media", str(self.chart), caption="A chart"
+ )
+
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["media.deliver"])
+ delivery = frames[0]
+ self.assertEqual(delivery["protocolVersion"], 4)
+ self.assertEqual(delivery["threadId"], "thread-media")
+ self.assertEqual(delivery["turnId"], "turn-media")
+ self.assertEqual(delivery["name"], "chart.png")
+ self.assertEqual(delivery["mimeType"], "image/png")
+ self.assertEqual(delivery["caption"], "A chart")
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, delivery["deliveryId"])
+ # Media never touches the turn machinery: the turn is still live and
+ # no turn/item frame was emitted for the file.
+ self.assertIn("thread-media", self.adapter._active_turns)
+
+ async def test_reply_media_arriving_just_after_completion_keeps_its_turn(self):
+ """The base adapter sends a reply's text BEFORE its media files
+ (`gateway/platforms/base.py:5326` then `:5383+`), and the notify-marked
+ text completes the T3 turn — so a reply's chart routinely arrives
+ moments after its turn closed and must still land turn-scoped."""
+ session_id = await self._start_turn("thread-late-media", "turn-late")
+ with unittest.mock.patch.object(
+ adapter_module.T3PlatformAdapter,
+ "_gateway_session_key",
+ staticmethod(lambda: session_id),
+ ):
+ await self.adapter.send(
+ "thread-late-media", "Here is the chart", metadata={"notify": True}
+ )
+ self.assertNotIn("thread-late-media", self.adapter._active_turns)
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_image_file(
+ "thread-late-media", str(self.chart)
+ )
+
+ self.assertTrue(result.success)
+ delivery = self.connection.messages[frames_before:][0]
+ self.assertEqual(delivery["type"], "media.deliver")
+ self.assertEqual(delivery["turnId"], "turn-late")
+
+ async def test_live_repro_reply_media_lands_with_no_session_key_bound(self):
+ """The 2026-07-27 18:47:06 gateway.log repro, end to end.
+
+ An ordinary (non-home) thread asks for an image. Upstream's delivery
+ pipeline sends the reply's notify-marked TEXT — completing the T3 turn
+ through the real completion path — and 36ms later dispatches the file.
+
+ The session context is modelled as it ACTUALLY is at that moment:
+ UNAVAILABLE. `HERMES_SESSION_KEY` is bound inside
+ `_handle_message_with_agent` and cleared in its own `finally`
+ (`gateway/run.py:12972` → `:14626`), while this whole delivery block
+ runs one frame further out in
+ `BasePlatformAdapter._process_message_background`, after the handler
+ returned — and `clear_session_vars` sets `""` rather than resetting, so
+ the `os.environ` fallback is suppressed too. Every send here reads `""`.
+
+ That is why the file was dropped with "no active T3 turn": the text
+ path never consults the key when a live turn exists, but the media path
+ required it to match. The class default `_gateway_session_key` stub
+ (`lambda: ""`) is exactly this state — no per-test patch.
+ """
+ thread = "3667b0a1-c1db-4216-8e72-2f62a3ff87e2"
+ await self._start_turn(thread, "turn-live-repro")
+
+ # The reply's final text. notify=True is what upstream stamps via
+ # `_mark_notify_metadata`, and it completes the turn for real.
+ text_result = await self.adapter.send(
+ thread,
+ "Here's the image you asked for.",
+ metadata={"thread_id": thread, "notify": True},
+ )
+ self.assertTrue(text_result.success)
+ self.assertNotIn(thread, self.adapter._active_turns)
+ completed = [
+ frame
+ for frame in self.connection.messages
+ if frame["type"] == "turn.completed"
+ ]
+ self.assertEqual([frame["turnId"] for frame in completed], ["turn-live-repro"])
+ frames_before = len(self.connection.messages)
+
+ # ~36ms later: the same reply's image, same metadata dict.
+ result = await self.adapter.send_image_file(
+ thread,
+ str(self.chart),
+ caption=None,
+ metadata={"thread_id": thread, "notify": True},
+ )
+
+ self.assertTrue(result.success)
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["media.deliver"])
+ delivery = frames[0]
+ # Scoped to its own turn, in its own thread — not exiled to Home.
+ self.assertEqual(delivery["threadId"], thread)
+ self.assertEqual(delivery["turnId"], "turn-live-repro")
+ self.assertEqual(delivery["name"], "chart.png")
+ # The completed turn is not resurrected by claiming its media.
+ self.assertNotIn(thread, self.adapter._active_turns)
+
+ async def test_an_image_only_reply_completes_its_turn(self):
+ """Live repro 2026-07-27 21:26: "send it one more time" → image, no text.
+
+ Upstream notify-marks every send of a reply's final delivery batch —
+ text AND media (`_mark_notify_metadata`, base.py:5220) — but a reply
+ that is only an image produces no text send, so the media send is the
+ only carrier of the completion signal. Without honoring it, the turn
+ sat "Working" until the two-minute liveness timeout.
+ """
+ thread = "thread-image-only-reply"
+ await self._start_turn(thread, "turn-image-only")
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_image_file(
+ thread,
+ str(self.chart),
+ caption=None,
+ metadata={"thread_id": thread, "notify": True},
+ )
+
+ self.assertTrue(result.success)
+ frames = self.connection.messages[frames_before:]
+ # `_complete_turn` also republishes connection.status; the contract
+ # here is the ORDER media -> completed, not the exact frame set.
+ types = [frame["type"] for frame in frames]
+ self.assertEqual(types[:2], ["media.deliver", "turn.completed"])
+ self.assertEqual(frames[0]["turnId"], "turn-image-only")
+ self.assertEqual(frames[1]["turnId"], "turn-image-only")
+ self.assertNotIn(thread, self.adapter._active_turns)
+
+ async def test_trailing_media_does_not_recomplete_a_closed_turn(self):
+ """The text-then-media ordering must emit exactly one turn.completed.
+
+ The text completes the turn; the file's own notify mark must not
+ re-complete the `_recent_turns` entry it scopes to — T3 already
+ folded the turn, and a second terminal frame names a turn its
+ conflict gate would reject.
+ """
+ thread = "thread-text-then-media"
+ await self._start_turn(thread, "turn-text-media")
+ await self.adapter.send(
+ thread, "Here it is.", metadata={"thread_id": thread, "notify": True}
+ )
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_image_file(
+ thread,
+ str(self.chart),
+ caption=None,
+ metadata={"thread_id": thread, "notify": True},
+ )
+
+ self.assertTrue(result.success)
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["media.deliver"])
+
+ async def test_media_long_after_a_turn_closed_does_not_claim_it(self):
+ """Recency is what bounds the reach-back, so an old turn must not claim.
+
+ Without the window, `_recent_turns` would keep a thread's last turn
+ claimable forever and an unrelated later delivery would be sequenced
+ into an answer the user finished reading long ago.
+ """
+ thread = "thread-stale-reachback"
+ await self._start_turn(thread, "turn-stale")
+ await self.adapter.send(thread, "Done.", metadata={"notify": True})
+ self.assertNotIn(thread, self.adapter._active_turns)
+
+ stale = self.adapter._recent_turns[thread]
+ stale.completed_at -= adapter_module._RECENT_TURN_MEDIA_WINDOW_SECONDS + 1
+ frames_before = len(self.connection.messages)
+
+ with self.assertLogs(adapter_module.logger, level="INFO"):
+ result = await self.adapter.send_document(thread, str(self.chart))
+
+ self.assertTrue(result.success)
+ delivery = self.connection.messages[frames_before:][0]
+ self.assertNotIn("turnId", delivery)
+ self.assertEqual(delivery["threadId"], self.HOME)
+
+ async def test_a_cron_delivery_never_claims_a_just_closed_turn(self):
+ """Provenance still overrides recency inside the window.
+
+ A positively-classified proactive send — cron here — is refused the
+ completed turn even microseconds after it closed, and takes the
+ turnless home route with its badge intact. This is the guard that
+ keeps the recency window from re-opening the defect class the
+ session-key gate was built for.
+ """
+ thread = "thread-cron-collision"
+ await self._start_turn(thread, "turn-cron-collision")
+ await self.adapter.send(thread, "All set.", metadata={"notify": True})
+ self.assertIsNotNone(self.adapter._recent_turns[thread].completed_at)
+ frames_before = len(self.connection.messages)
+
+ with self.assertLogs(adapter_module.logger, level="INFO"):
+ result = await self.adapter.send_document(
+ thread,
+ str(self.chart),
+ caption="Cronjob Response: nightly\n-------------\n\nChart attached.",
+ )
+
+ self.assertTrue(result.success)
+ delivery = self.connection.messages[frames_before:][0]
+ self.assertNotIn("turnId", delivery)
+ self.assertEqual(delivery["threadId"], self.HOME)
+ self.assertEqual(delivery["kind"], "cron")
+ self.assertEqual(delivery["label"], "Cron: nightly")
+
+ async def test_a_live_turn_still_outranks_a_completed_one(self):
+ """The user asked again; the new turn owns the thread, not the old one."""
+ thread = "thread-relay"
+ await self._start_turn(thread, "turn-first")
+ await self.adapter.send(thread, "First answer.", metadata={"notify": True})
+ await self._start_turn(thread, "turn-second")
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_image_file(thread, str(self.chart))
+
+ self.assertTrue(result.success)
+ delivery = self.connection.messages[frames_before:][0]
+ self.assertEqual(delivery["turnId"], "turn-second")
+
+ async def test_proactive_media_to_home_is_turnless_with_provenance(self):
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_document(
+ self.HOME,
+ str(self.chart),
+ caption=(
+ "Cronjob Response: nightly\n(job_id: nightly)\n"
+ "-------------\n\nDone."
+ ),
+ )
+
+ frames = self.connection.messages[frames_before:]
+ self.assertEqual([frame["type"] for frame in frames], ["media.deliver"])
+ delivery = frames[0]
+ self.assertNotIn("turnId", delivery)
+ self.assertEqual(delivery["kind"], "cron")
+ self.assertEqual(delivery["label"], "Cron: nightly")
+ self.assertTrue(result.success)
+ self.assertEqual(self.adapter._active_turns, {})
+
+ async def test_unscopeable_media_falls_back_to_home_instead_of_dropping(self):
+ """"Send media to any thread unprompted" still lands — in Home.
+
+ The thread route stays out of scope: the frame goes out turnless, so
+ T3 re-resolves the home thread server-side and can write nowhere else.
+ But the file is NOT dropped. Upstream's only response to a failed
+ media send is a log line, so returning an error silently loses an
+ artifact Hermes already spent a generation call producing.
+ """
+ with self.assertLogs(adapter_module.logger, level="INFO"):
+ result = await self.adapter.send_document(
+ "some-other-thread", str(self.chart)
+ )
+
+ self.assertTrue(result.success)
+ frames = self.connection.messages
+ self.assertEqual([frame["type"] for frame in frames], ["media.deliver"])
+ delivery = frames[0]
+ # Home-addressed and turnless: it renders as a badged notification,
+ # never as a reply inside the thread that could not take it.
+ self.assertEqual(delivery["threadId"], self.HOME)
+ self.assertNotIn("turnId", delivery)
+ self.assertEqual(delivery["label"], "Hermes")
+ self.assertEqual(
+ [entry["deliveryId"] for entry in self.queue.entries()],
+ [result.message_id],
+ )
+
+ async def test_media_with_no_home_designated_still_errors(self):
+ """With nowhere to fall back to, the original error stands."""
+ with unittest.mock.patch.dict(
+ adapter_module.os.environ, {home_module.HOME_CHANNEL_ENV: ""}
+ ):
+ result = await self.adapter.send_document(
+ "some-other-thread", str(self.chart)
+ )
+ self.assertFalse(result.success)
+ self.assertEqual(result.error, "no active T3 turn")
+ self.assertEqual(self.connection.messages, [])
+ self.assertEqual(self.queue.entries(), [])
+
+ async def test_media_is_queued_before_it_is_sent_and_purged_only_on_ack(self):
+ result = await self.adapter.send_video(self.HOME, str(self.chart))
+ delivery_id = result.message_id
+ self.assertEqual(
+ [entry["deliveryId"] for entry in self.queue.entries()], [delivery_id]
+ )
+
+ # A home.deliver.ack for some OTHER delivery purges nothing.
+ await self.adapter._handle_server_frame(
+ {
+ "type": "media.deliver.ack",
+ "protocolVersion": 4,
+ "deliveryId": "unrelated",
+ }
+ )
+ self.assertEqual(len(self.queue.entries()), 1)
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "media.deliver.ack",
+ "protocolVersion": 4,
+ "deliveryId": delivery_id,
+ }
+ )
+ self.assertEqual(self.queue.entries(), [])
+
+ async def test_queued_media_survives_a_dead_socket_and_flushes_on_reconnect(self):
+ class DeadConnection:
+ connected = False
+
+ async def send(self, message):
+ raise ConnectionError("T3 Code gateway is offline")
+
+ self.adapter._connection = DeadConnection()
+ with self.assertLogs(adapter_module.logger, level="WARNING"):
+ offline = await self.adapter.send_document(self.HOME, str(self.chart))
+ # Reported successful: durably queued, WILL arrive.
+ self.assertTrue(offline.success)
+ self.assertEqual(len(self.queue.entries()), 1)
+
+ self.adapter._connection = self.connection
+ await self.adapter._handle_connection_accepted(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": "hello-1",
+ "instanceId": "instance",
+ "nickname": "Hermes",
+ "homeThreadId": self.HOME,
+ }
+ )
+ flushed = self.connection.messages
+ self.assertEqual([frame["type"] for frame in flushed], ["media.deliver"])
+ self.assertEqual(flushed[0]["deliveryId"], offline.message_id)
+ self.assertEqual(flushed[0]["name"], "chart.png")
+ # Still queued — only the ack purges it.
+ self.assertEqual(len(self.queue.entries()), 1)
+
+ await self.adapter._handle_server_frame(
+ {
+ "type": "media.deliver.ack",
+ "protocolVersion": 4,
+ "deliveryId": offline.message_id,
+ }
+ )
+ self.assertEqual(self.queue.entries(), [])
+
+ async def test_an_unreadable_file_fails_the_send_and_queues_nothing(self):
+ """A frame T3 would reject forever must never enter the outbox."""
+ await self._start_turn("thread-bad-file", "turn-bad-file")
+ with self.assertLogs(adapter_module.logger, level="WARNING"):
+ result = await self.adapter.send_image_file(
+ "thread-bad-file", str(pathlib.Path(self._tmp.name) / "gone.png")
+ )
+ self.assertFalse(result.success)
+ self.assertEqual(self.queue.entries(), [])
+ self.assertNotIn(
+ "media.deliver",
+ [frame["type"] for frame in self.connection.messages],
+ )
+
+ async def test_media_that_is_neither_queued_nor_sent_reports_failure(self):
+ """An unpersisted delivery must not be reported as durable.
+
+ Success here used to be unconditional on the queue write, so a full
+ disk plus a dead socket produced "delivered" for a file that exists
+ nowhere — and, on a notify-marked send, completed the turn on it. The
+ bytes are the only copy: Hermes' temp file is reaped and nothing can
+ replay a frame that was never written.
+ """
+
+ class DeadConnection:
+ connected = False
+
+ async def send(self, message):
+ raise ConnectionError("T3 Code gateway is offline")
+
+ thread = "thread-nowhere-to-go"
+ await self._start_turn(thread, "turn-nowhere")
+ self.adapter._connection = DeadConnection()
+
+ with unittest.mock.patch.object(
+ self.queue, "append", return_value=False
+ ), self.assertLogs(adapter_module.logger, level="WARNING"):
+ result = await self.adapter.send_image_file(
+ thread,
+ str(self.chart),
+ metadata={"thread_id": thread, "notify": True},
+ )
+
+ self.assertFalse(result.success)
+ self.assertIn("queued", result.error)
+ # The turn is NOT completed on media that went nowhere.
+ self.assertIn(thread, self.adapter._active_turns)
+
+ async def test_media_that_reached_t3_is_honest_success_without_the_queue(self):
+ """The other branch: the live send held, so T3 has the file.
+
+ The queue's only remaining job would be a replay T3 does not need, and
+ the ack simply finds nothing to purge.
+ """
+ thread = "thread-sent-not-queued"
+ await self._start_turn(thread, "turn-sent-not-queued")
+ frames_before = len(self.connection.messages)
+
+ with unittest.mock.patch.object(self.queue, "append", return_value=False):
+ result = await self.adapter.send_image_file(thread, str(self.chart))
+
+ self.assertTrue(result.success)
+ self.assertEqual(
+ [frame["type"] for frame in self.connection.messages[frames_before:]],
+ ["media.deliver"],
+ )
+
+ async def test_audio_rides_the_same_media_frame_instead_of_the_fallback(self):
+ """T3 renders audio as a download card — still strictly better than
+ the base class's "couldn't deliver the audio attachment" notice."""
+ audio = pathlib.Path(self._tmp.name) / "reply.mp3"
+ audio.write_bytes(b"ID3 fake audio")
+ await self._start_turn("thread-audio", "turn-audio")
+ frames_before = len(self.connection.messages)
+
+ result = await self.adapter.send_voice("thread-audio", str(audio))
+
+ self.assertTrue(result.success)
+ delivery = self.connection.messages[frames_before:][0]
+ self.assertEqual(delivery["type"], "media.deliver")
+ self.assertEqual(delivery["mimeType"], "audio/mpeg")
+
+
+class EnvEnablementTests(unittest.TestCase):
+ """`home_channel` is the magic key that makes `get_home_channel` resolve."""
+
+ ENROLLED = {
+ "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws",
+ "HERMES_T3_GATEWAY_INSTANCE_ID": "instance",
+ "HERMES_T3_GATEWAY_CREDENTIAL": "credential",
+ }
+
+ def test_a_designated_home_seeds_the_magic_home_channel_key(self):
+ with unittest.mock.patch.dict(
+ adapter_module.os.environ,
+ {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: "home-thread"},
+ ):
+ seed = adapter_module.env_enablement()
+ # Core pops this key and promotes it to a real HomeChannel dataclass
+ # (gateway/config.py:2648-2660), reading only chat_id/name/thread_id.
+ # T3 threads are the addressing unit, so chat_id IS the thread id and
+ # thread_id stays unset.
+ self.assertEqual(
+ seed["home_channel"], {"chat_id": "home-thread", "name": "Home"}
+ )
+
+ def test_no_designation_yet_seeds_no_home_channel(self):
+ with unittest.mock.patch.dict(
+ adapter_module.os.environ,
+ {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: ""},
+ ):
+ seed = adapter_module.env_enablement()
+ # The pre-designation window — first connect, before any
+ # `connection.accepted`. This is exactly why the `/sethome` nudge
+ # suppression is still needed.
+ self.assertNotIn("home_channel", seed)
+ self.assertEqual(seed["instance_id"], "instance")
+
+ def test_an_unenrolled_hermes_seeds_nothing_at_all(self):
+ with unittest.mock.patch.dict(
+ adapter_module.os.environ,
+ {**self.ENROLLED, "HERMES_T3_GATEWAY_CREDENTIAL": ""},
+ ):
+ self.assertIsNone(adapter_module.env_enablement())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/integrations/hermes-t3-gateway/tests/test_connection.py b/integrations/hermes-t3-gateway/tests/test_connection.py
new file mode 100644
index 000000000000..0a74818b362c
--- /dev/null
+++ b/integrations/hermes-t3-gateway/tests/test_connection.py
@@ -0,0 +1,400 @@
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+import json
+import pathlib
+import sys
+import types
+import unittest
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PACKAGE = "hermes_t3_gateway_test"
+
+package = types.ModuleType(PACKAGE)
+package.__path__ = [str(ROOT)]
+sys.modules.setdefault(PACKAGE, package)
+
+for name in ("protocol", "connection"):
+ spec = importlib.util.spec_from_file_location(
+ f"{PACKAGE}.{name}", ROOT / f"{name}.py"
+ )
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[f"{PACKAGE}.{name}"] = module
+ spec.loader.exec_module(module)
+
+connection = sys.modules[f"{PACKAGE}.connection"]
+
+
+async def _immediate(value):
+ return value
+
+
+class FakeSocket:
+ def __init__(self, response):
+ self.response = response
+ self.sent = []
+ self.closed = False
+
+ async def send(self, value):
+ self.sent.append(json.loads(value))
+
+ async def recv(self):
+ request_id = self.sent[0]["requestId"]
+ return json.dumps({**self.response, "requestId": request_id})
+
+ async def close(self):
+ self.closed = True
+
+
+class ConnectionTests(unittest.IsolatedAsyncioTestCase):
+ def test_url_normalization(self):
+ self.assertEqual(
+ connection.websocket_url("https://t3.example"),
+ "wss://t3.example/api/hermes-gateway/ws",
+ )
+ self.assertEqual(
+ connection.websocket_url("http://localhost:8484/"),
+ "ws://localhost:8484/api/hermes-gateway/ws",
+ )
+ with self.assertRaises(ValueError):
+ connection.websocket_url("ftp://invalid.example")
+
+ async def test_disconnect_cancels_and_drains_in_flight_handlers(self):
+ started = asyncio.Event()
+ cancelled = asyncio.Event()
+ never_finishes = asyncio.Event()
+
+ async def on_message(_message):
+ started.set()
+ try:
+ await never_finishes.wait()
+ finally:
+ cancelled.set()
+
+ conn = connection.T3GatewayConnection(
+ url="ws://t3.example/api/hermes-gateway/ws",
+ instance_id="provider-instance",
+ credential="secret",
+ hermes_version="0.19.0",
+ on_message=on_message,
+ )
+ conn._spawn_handler({"type": "turn.start", "requestId": "turn-1"})
+ await asyncio.wait_for(started.wait(), timeout=1)
+
+ await conn.disconnect()
+
+ self.assertTrue(cancelled.is_set())
+ self.assertEqual(conn._handlers, set())
+
+ async def test_enrollment_handshake_returns_credential(self):
+ socket = FakeSocket(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "instanceId": "provider-instance",
+ "nickname": "Research",
+ "credential": "persistent-secret",
+ }
+ )
+ accepted = await connection.authenticate_socket(
+ socket,
+ authentication={"type": "enrollment-token", "token": "once"},
+ hermes_version="0.19.0",
+ )
+ self.assertEqual(accepted["credential"], "persistent-secret")
+ self.assertEqual(
+ socket.sent[0]["authentication"],
+ {"type": "enrollment-token", "token": "once"},
+ )
+
+ async def test_accepted_handshake_rejects_an_incompatible_protocol(self):
+ socket = FakeSocket(
+ {
+ "type": "connection.accepted",
+ # A v3 server: the version policy stays fail-closed across the
+ # v4 bump, so this must not be silently accepted.
+ "protocolVersion": 3,
+ "instanceId": "provider-instance",
+ "nickname": "Research",
+ }
+ )
+ with self.assertRaisesRegex(RuntimeError, "incompatible version"):
+ await connection.authenticate_socket(
+ socket,
+ authentication={
+ "type": "instance-credential",
+ "instanceId": "provider-instance",
+ "credential": "secret",
+ },
+ hermes_version="0.19.0",
+ )
+
+ async def test_rejected_handshake_fails_closed(self):
+ socket = FakeSocket(
+ {
+ "type": "connection.rejected",
+ "code": "version-incompatible",
+ "message": "upgrade required",
+ "expectedProtocolVersion": 4,
+ }
+ )
+ with self.assertRaises(connection.ConnectionRejected) as raised:
+ await connection.authenticate_socket(
+ socket,
+ authentication={
+ "type": "instance-credential",
+ "instanceId": "provider-instance",
+ "credential": "secret",
+ },
+ hermes_version="0.19.0",
+ )
+ self.assertEqual(raised.exception.code, "version-incompatible")
+
+ async def test_handshake_survives_a_ping_racing_the_reply(self):
+ """A ping may arrive before `connection.accepted`.
+
+ The server starts probing liveness on its own schedule, so the first
+ frame after hello is not guaranteed to be the handshake reply.
+ Treating it as one tore down the freshly established connection and
+ reconnected in a loop — the plugin logged "unexpected requestId" while
+ the server logged missed pongs.
+ """
+ class RacingSocket:
+ def __init__(self):
+ self.sent = []
+ self._frames = None
+
+ async def send(self, value):
+ self.sent.append(json.loads(value))
+
+ async def recv(self):
+ if self._frames is None:
+ hello_id = self.sent[0]["requestId"]
+ self._frames = iter(
+ [
+ json.dumps(
+ {
+ "type": "ping",
+ "protocolVersion": 4,
+ "requestId": "server-ping-1",
+ }
+ ),
+ json.dumps(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": hello_id,
+ "instanceId": "provider-instance",
+ "nickname": "Hermes",
+ }
+ ),
+ ]
+ )
+ return next(self._frames)
+
+ socket = RacingSocket()
+ accepted = await connection.authenticate_socket(
+ socket,
+ authentication={
+ "type": "instance-credential",
+ "instanceId": "provider-instance",
+ "credential": "secret",
+ },
+ hermes_version="0.19.0",
+ )
+ self.assertEqual(accepted["type"], "connection.accepted")
+ pongs = [f for f in socket.sent if f.get("type") == "pong"]
+ self.assertEqual(len(pongs), 1, "the racing ping must still be answered")
+ self.assertEqual(pongs[0]["requestId"], "server-ping-1")
+
+ async def test_ping_is_answered_while_a_command_handler_is_blocked(self):
+ """A ping must not queue behind command dispatch.
+
+ `_on_message` awaits Hermes: `turn.start` blocks for the whole agent
+ turn. If the real read loop awaited that before reading the next
+ frame, a ping arriving mid-turn would go unanswered for minutes and
+ T3 would close a healthy socket as half-open — which is what happened
+ in practice. This drives `_supervise` itself so the loop under test is
+ the one that ships.
+ """
+ import asyncio
+
+ blocked = asyncio.Event()
+ released = asyncio.Event()
+
+ class BlockingSocket:
+ def __init__(self):
+ self.sent = []
+
+ async def send(self, value):
+ self.sent.append(json.loads(value))
+
+ async def recv(self):
+ return json.dumps(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": self.sent[0]["requestId"],
+ "instanceId": "provider-instance",
+ "nickname": "Hermes",
+ }
+ )
+
+ async def close(self):
+ return None
+
+ def __aiter__(self):
+ async def frames():
+ yield json.dumps({"type": "turn.start", "requestId": "turn-1"})
+ yield json.dumps(
+ {"type": "ping", "protocolVersion": 4, "requestId": "ping-1"}
+ )
+ await released.wait()
+
+ return frames()
+
+ socket = BlockingSocket()
+
+ async def on_message(message):
+ # Stands in for Hermes running a turn: does not return while the
+ # test checks whether the pong went out regardless.
+ blocked.set()
+ await released.wait()
+
+ conn = connection.T3GatewayConnection(
+ url="ws://t3.example/api/hermes-gateway/ws",
+ instance_id="provider-instance",
+ credential="secret",
+ hermes_version="0.19.0",
+ on_message=on_message,
+ )
+
+ original_open = connection._open_socket
+ connection._open_socket = lambda url: _immediate(socket)
+ try:
+ self.assertTrue(await conn.connect(timeout=2))
+ await asyncio.wait_for(blocked.wait(), timeout=2)
+ # Let the read loop reach the queued ping while on_message is stuck.
+ for _ in range(10):
+ await asyncio.sleep(0)
+ pongs = [f for f in socket.sent if f.get("type") == "pong"]
+ self.assertEqual(
+ len(pongs), 1, "the ping must be answered while a command is blocked"
+ )
+ self.assertEqual(pongs[0]["requestId"], "ping-1")
+ finally:
+ connection._open_socket = original_open
+ released.set()
+ await conn.disconnect()
+
+ async def test_ping_is_answered_while_the_accepted_callback_flushes(self):
+ """Reconnect queue replay must not run ahead of the socket read loop."""
+ import asyncio
+
+ callback_started = asyncio.Event()
+ release_callback = asyncio.Event()
+
+ class BlockingAcceptedSocket:
+ def __init__(self):
+ self.sent = []
+
+ async def send(self, value):
+ self.sent.append(json.loads(value))
+
+ async def recv(self):
+ return json.dumps(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": 4,
+ "requestId": self.sent[0]["requestId"],
+ "instanceId": "provider-instance",
+ "nickname": "Hermes",
+ }
+ )
+
+ async def close(self):
+ return None
+
+ def __aiter__(self):
+ async def frames():
+ await callback_started.wait()
+ yield json.dumps(
+ {
+ "type": "ping",
+ "protocolVersion": 4,
+ "requestId": "ping-during-flush",
+ }
+ )
+ await release_callback.wait()
+
+ return frames()
+
+ socket = BlockingAcceptedSocket()
+
+ async def on_accepted(_message):
+ callback_started.set()
+ await release_callback.wait()
+
+ conn = connection.T3GatewayConnection(
+ url="ws://t3.example/api/hermes-gateway/ws",
+ instance_id="provider-instance",
+ credential="secret",
+ hermes_version="0.19.0",
+ on_message=lambda _message: _immediate(None),
+ on_accepted=on_accepted,
+ )
+
+ original_open = connection._open_socket
+ connection._open_socket = lambda url: _immediate(socket)
+ try:
+ self.assertTrue(await conn.connect(timeout=2))
+ await asyncio.wait_for(callback_started.wait(), timeout=2)
+ for _ in range(10):
+ await asyncio.sleep(0)
+ pongs = [frame for frame in socket.sent if frame.get("type") == "pong"]
+ self.assertEqual(len(pongs), 1)
+ self.assertEqual(pongs[0]["requestId"], "ping-during-flush")
+ finally:
+ connection._open_socket = original_open
+ release_callback.set()
+ await conn.disconnect()
+
+ async def test_disconnect_cancels_handlers_before_notifying_disconnected(self):
+ handler_started = asyncio.Event()
+ handler_drained = asyncio.Event()
+ notifications = []
+
+ async def handler(_message):
+ handler_started.set()
+ try:
+ await asyncio.Future()
+ finally:
+ await asyncio.sleep(0)
+ handler_drained.set()
+
+ async def on_state(connected, _reason):
+ if not connected:
+ notifications.append(handler_drained.is_set())
+
+ conn = connection.T3GatewayConnection(
+ url="ws://unused",
+ instance_id="provider-instance",
+ credential="secret",
+ hermes_version="0.19.0",
+ on_message=handler,
+ on_state=on_state,
+ )
+ conn._spawn_handler({"type": "turn.start"})
+ await handler_started.wait()
+
+ await conn.disconnect()
+
+ self.assertTrue(handler_drained.is_set())
+ self.assertEqual(notifications, [True])
+ self.assertEqual(conn._handlers, set())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/integrations/hermes-t3-gateway/tests/test_coreshim.py b/integrations/hermes-t3-gateway/tests/test_coreshim.py
new file mode 100644
index 000000000000..149e4c6b7e0c
--- /dev/null
+++ b/integrations/hermes-t3-gateway/tests/test_coreshim.py
@@ -0,0 +1,443 @@
+"""Tests for the in-process compensation of two upstream `send_message` bugs.
+
+The fake `tools.send_message_tool` below models the shape `coreshim` actually
+depends on at Hermes v0.19.0 — the two coroutine signatures, the live-adapter
+shortcut that drops `media_files` (Bug B, upstream line 711), the unconditional
+omission warning (Bug A, upstream line 1108), and the media-only hard error
+(upstream line 1101). It is deliberately a stand-in and not an import of the
+real module: these tests must run with no Hermes installed.
+"""
+
+from __future__ import annotations
+
+import enum
+import asyncio
+import importlib.util
+import pathlib
+import sys
+import types
+import unittest
+import unittest.mock
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PACKAGE = "hermes_t3_gateway_coreshim_test"
+
+package = types.ModuleType(PACKAGE)
+package.__path__ = [str(ROOT)]
+sys.modules.setdefault(PACKAGE, package)
+
+for _name in ("protocol", "connection", "home", "coreshim"):
+ _spec = importlib.util.spec_from_file_location(
+ f"{PACKAGE}.{_name}", ROOT / f"{_name}.py"
+ )
+ assert _spec and _spec.loader
+ _module = importlib.util.module_from_spec(_spec)
+ sys.modules[f"{PACKAGE}.{_name}"] = _module
+ _spec.loader.exec_module(_module)
+
+coreshim = sys.modules[f"{PACKAGE}.coreshim"]
+home = sys.modules[f"{PACKAGE}.home"]
+
+
+class Platform(str, enum.Enum):
+ """Core passes an enum whose `.value` is the platform name."""
+
+ T3 = "t3"
+ TELEGRAM = "telegram"
+
+
+# The nine platforms core hard-codes into the warning at upstream line 1112.
+# The exact list is version-dependent, which is precisely why the shim matches
+# on prefix — this fake spells it out so a prefix regression would be caught.
+_SUPPORTED = (
+ "telegram, discord, matrix, weixin, signal, yuanbao, feishu, whatsapp and slack"
+)
+
+
+def _fake_core() -> types.ModuleType:
+ """Build a module that reproduces the two defects faithfully."""
+ module = types.ModuleType("tools.send_message_tool")
+ module.calls = []
+ # Set by the test to simulate a co-resident gateway (Bug B's precondition).
+ module.live_adapter = None
+ # Warnings from unrelated causes, which the shim must never touch.
+ module.extra_warnings = []
+
+ async def _send_via_adapter(
+ platform,
+ pconfig,
+ chat_id,
+ chunk,
+ *,
+ thread_id=None,
+ media_files=None,
+ force_document=False,
+ ):
+ module.calls.append(
+ {
+ "fn": "_send_via_adapter",
+ "platform": platform,
+ "chat_id": chat_id,
+ "chunk": chunk,
+ "media_files": media_files,
+ "thread_id": thread_id,
+ }
+ )
+ if module.live_adapter is not None:
+ # Bug B, upstream `tools/send_message_tool.py:711-732`: the live
+ # adapter is handed content and metadata only. `media_files` is
+ # dropped here, and the text was already stripped of its `MEDIA:`
+ # directives upstream at line 442, so nothing downstream can
+ # recover the attachments.
+ return await module.live_adapter(chat_id=chat_id, content=chunk)
+ return {"success": True, "message_id": "standalone-fallback"}
+
+ async def _send_to_platform(
+ platform,
+ pconfig,
+ chat_id,
+ message,
+ thread_id=None,
+ media_files=None,
+ force_document=False,
+ ):
+ module.calls.append(
+ {
+ "fn": "_send_to_platform",
+ "platform": platform,
+ "message": message,
+ "media_files": media_files,
+ }
+ )
+ name = platform.value if hasattr(platform, "value") else str(platform)
+ # Upstream line 1101-1107.
+ if media_files and not message.strip():
+ return {
+ "error": (
+ "send_message MEDIA delivery is currently only supported for "
+ f"{_SUPPORTED}; target {name} had only media attachments"
+ )
+ }
+ # Upstream line 1108-1113: computed with no reference to what the
+ # sender below actually does.
+ warning = None
+ if media_files:
+ warning = (
+ f"MEDIA attachments were omitted for {name}; native send_message "
+ f"media delivery is currently only supported for {_SUPPORTED}"
+ )
+ # Resolved off the module, exactly as upstream's module-level call does
+ # — so a patched `_send_via_adapter` is genuinely reached from here,
+ # which is what makes the co-resident interception testable.
+ result = await module._send_via_adapter(
+ platform,
+ pconfig,
+ chat_id,
+ message,
+ thread_id=thread_id,
+ media_files=media_files,
+ force_document=force_document,
+ )
+ # Upstream line 1154-1157.
+ if warning and isinstance(result, dict) and result.get("success"):
+ warnings = list(result.get("warnings", []))
+ warnings.extend(module.extra_warnings)
+ warnings.append(warning)
+ result["warnings"] = warnings
+ return result
+
+ module._send_via_adapter = _send_via_adapter
+ module._send_to_platform = _send_to_platform
+ return module
+
+
+class ShimApplicationTests(unittest.TestCase):
+ def test_both_wrappers_attach_to_a_faithful_module(self):
+ module = _fake_core()
+ self.assertEqual(
+ coreshim.apply(module),
+ {"_send_via_adapter": True, "_send_to_platform": True},
+ )
+
+ def test_applying_twice_does_not_stack_wrappers(self):
+ """`register()` can run more than once (e.g. discover_plugins(force=True))."""
+ module = _fake_core()
+ coreshim.apply(module)
+ first = module._send_via_adapter
+ second_pass = coreshim.apply(module)
+
+ self.assertEqual(
+ second_pass, {"_send_via_adapter": False, "_send_to_platform": False}
+ )
+ self.assertIs(module._send_via_adapter, first)
+ # One layer deep, still the genuine original.
+ self.assertFalse(
+ hasattr(module._send_via_adapter.__wrapped__, "__wrapped__")
+ )
+
+ def test_wrapper_preserves_positional_only_and_keyword_only_kinds(self):
+ module = _fake_core()
+ calls = []
+
+ async def _send_via_adapter(
+ platform, pconfig, chat_id, chunk, /, *, thread_id=None,
+ media_files=None, force_document=False
+ ):
+ calls.append((platform, chat_id, chunk, thread_id))
+ return {"success": True}
+
+ module._send_via_adapter = _send_via_adapter
+ self.assertTrue(coreshim.apply(module)["_send_via_adapter"])
+
+ async def exercise():
+ return await module._send_via_adapter(
+ Platform.T3, None, "home", "text", thread_id="thread"
+ )
+
+ self.assertEqual(asyncio.run(exercise()), {"success": True})
+ self.assertEqual(calls, [(Platform.T3, "home", "text", "thread")])
+
+
+class FailOpenTests(unittest.TestCase):
+ """A shape mismatch must leave core exactly as it was, never raise."""
+
+ def _assert_untouched(self, module, applied, attribute):
+ self.assertFalse(applied[attribute])
+ self.assertFalse(getattr(getattr(module, attribute, None), "_t3_gateway_shim", False))
+
+ def test_a_renamed_parameter_blocks_the_patch(self):
+ module = _fake_core()
+
+ async def _renamed(platform, pconfig, chat_id, chunk, *, thread_id=None, attachments=None):
+ return {"success": True}
+
+ module._send_via_adapter = _renamed
+ with self.assertLogs(coreshim.logger, level="WARNING") as logs:
+ applied = coreshim.apply(module)
+
+ self._assert_untouched(module, applied, "_send_via_adapter")
+ self.assertIs(module._send_via_adapter, _renamed)
+ self.assertTrue(any("media_files" in line for line in logs.output))
+ # The unrelated patch still lands — one mismatch does not disarm both.
+ self.assertTrue(applied["_send_to_platform"])
+
+ def test_a_missing_function_blocks_the_patch(self):
+ module = _fake_core()
+ del module._send_to_platform
+ with self.assertLogs(coreshim.logger, level="WARNING") as logs:
+ applied = coreshim.apply(module)
+
+ self.assertFalse(applied["_send_to_platform"])
+ self.assertFalse(hasattr(module, "_send_to_platform"))
+ self.assertTrue(any("is missing" in line for line in logs.output))
+
+ def test_a_sync_rewrite_blocks_the_patch(self):
+ """If upstream ever makes these sync, an async wrapper would break callers."""
+ module = _fake_core()
+
+ def _sync(platform, pconfig, chat_id, chunk, *, thread_id=None, media_files=None):
+ return {"success": True}
+
+ module._send_via_adapter = _sync
+ with self.assertLogs(coreshim.logger, level="WARNING") as logs:
+ applied = coreshim.apply(module)
+
+ self._assert_untouched(module, applied, "_send_via_adapter")
+ self.assertTrue(any("coroutine" in line for line in logs.output))
+
+ def test_an_unimportable_core_is_survivable(self):
+ """The real `apply()` with no Hermes on the path must not raise."""
+ with unittest.mock.patch.dict(sys.modules, {}, clear=False):
+ sys.modules.pop("tools.send_message_tool", None)
+ with self.assertLogs(coreshim.logger, level="WARNING"):
+ applied = coreshim.apply()
+ self.assertEqual(
+ applied, {"_send_via_adapter": False, "_send_to_platform": False}
+ )
+
+
+class RoutingTests(unittest.IsolatedAsyncioTestCase):
+ """Behaviour of the patched functions, with `standalone_send` stubbed."""
+
+ def setUp(self):
+ self.module = _fake_core()
+ self.sends = []
+
+ async def _standalone_send(
+ pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False
+ ):
+ self.sends.append(
+ {
+ "chat_id": chat_id,
+ "message": message,
+ "media_files": media_files,
+ "thread_id": thread_id,
+ "force_document": force_document,
+ }
+ )
+ return {
+ "success": True,
+ "message_id": "t3-delivery",
+ "media_count": len(media_files or []),
+ "acked_count": 1 + len(media_files or []),
+ "note": f"{len(media_files or [])} media file(s) delivered and acknowledged",
+ }
+
+ patch = unittest.mock.patch.object(home, "standalone_send", _standalone_send)
+ patch.start()
+ self.addCleanup(patch.stop)
+ coreshim.apply(self.module)
+
+ async def _live_adapter(*, chat_id, content):
+ # Whatever core hands the live adapter is all it ever sees.
+ return {"success": True, "message_id": "live-adapter"}
+
+ self.module.live_adapter = _live_adapter
+
+ async def test_co_resident_t3_media_bypasses_the_live_adapter(self):
+ """Bug B: the files must reach our sender, not the media-blind adapter."""
+ result = await self.module._send_via_adapter(
+ Platform.T3,
+ None,
+ "home-thread",
+ "Here is the chart",
+ thread_id="thread-1",
+ media_files=[("/tmp/chart.png", False)],
+ )
+
+ self.assertEqual(result["message_id"], "t3-delivery")
+ self.assertEqual(len(self.sends), 1)
+ self.assertEqual(self.sends[0]["media_files"], [("/tmp/chart.png", False)])
+ self.assertEqual(self.sends[0]["message"], "Here is the chart")
+ self.assertEqual(self.sends[0]["thread_id"], "thread-1")
+ # The original never ran, so the adapter never got a chance to drop it.
+ self.assertEqual(self.module.calls, [])
+
+ async def test_a_text_only_t3_send_keeps_the_original_path(self):
+ result = await self.module._send_via_adapter(
+ Platform.T3, None, "home-thread", "No attachments here"
+ )
+
+ self.assertEqual(result["message_id"], "live-adapter")
+ self.assertEqual(self.sends, [])
+ self.assertEqual(len(self.module.calls), 1)
+
+ async def test_another_platform_with_media_is_untouched(self):
+ result = await self.module._send_via_adapter(
+ Platform.TELEGRAM,
+ None,
+ "chat-1",
+ "Telegram body",
+ media_files=[("/tmp/chart.png", False)],
+ )
+
+ self.assertEqual(result["message_id"], "live-adapter")
+ self.assertEqual(self.sends, [])
+ self.assertEqual(len(self.module.calls), 1)
+ self.assertEqual(
+ self.module.calls[0]["media_files"], [("/tmp/chart.png", False)]
+ )
+
+ async def test_the_false_omission_warning_is_stripped_for_t3(self):
+ """Bug A: the warning is removed, and the rest of the result survives."""
+ result = await self.module._send_to_platform(
+ Platform.T3,
+ None,
+ "home-thread",
+ "Here is the chart",
+ media_files=[("/tmp/chart.png", False)],
+ )
+
+ self.assertTrue(result["success"])
+ self.assertNotIn("warnings", result)
+ self.assertEqual(result["media_count"], 1)
+
+ async def test_an_unrelated_warning_is_preserved(self):
+ """Only the one known-false warning is removed, not the whole key."""
+ self.module.extra_warnings = ["Something else happened"]
+ result = await self.module._send_to_platform(
+ Platform.T3,
+ None,
+ "home-thread",
+ "Here is the chart",
+ media_files=[("/tmp/chart.png", False)],
+ )
+
+ self.assertEqual(result["warnings"], ["Something else happened"])
+
+ async def test_the_warning_survives_for_a_platform_that_really_omits(self):
+ """Only `t3` is compensated; another platform's warning is truthful."""
+ self.module.live_adapter = None
+ result = await self.module._send_to_platform(
+ Platform.TELEGRAM,
+ None,
+ "chat-1",
+ "Telegram body",
+ media_files=[("/tmp/chart.png", False)],
+ )
+
+ self.assertEqual(len(result["warnings"]), 1)
+ self.assertTrue(
+ result["warnings"][0].startswith("MEDIA attachments were omitted for telegram")
+ )
+
+ async def test_a_media_only_t3_send_is_rescued_from_the_hard_error(self):
+ """Upstream returns an error before routing; the shim sends instead."""
+ result = await self.module._send_to_platform(
+ Platform.T3, None, "home-thread", "", media_files=[("/tmp/chart.png", False)]
+ )
+
+ self.assertTrue(result["success"])
+ self.assertEqual(self.sends[0]["media_files"], [("/tmp/chart.png", False)])
+ # The original router never ran, so its hard error never happened.
+ self.assertEqual(self.module.calls, [])
+
+ async def test_a_media_only_send_on_another_platform_still_errors(self):
+ result = await self.module._send_to_platform(
+ Platform.TELEGRAM, None, "chat-1", "", media_files=[("/tmp/chart.png", False)]
+ )
+
+ self.assertIn("had only media attachments", result["error"])
+ self.assertEqual(self.sends, [])
+
+ async def test_a_text_only_send_is_byte_identical_through_the_wrapper(self):
+ """No media means the wrapper is a pure pass-through, both platforms."""
+ for platform in (Platform.T3, Platform.TELEGRAM):
+ with self.subTest(platform=platform):
+ result = await self.module._send_to_platform(
+ platform, None, "chat-1", "Plain text"
+ )
+ self.assertEqual(
+ result, {"success": True, "message_id": "live-adapter"}
+ )
+ self.assertEqual(self.sends, [])
+
+
+class WarningMatchTests(unittest.TestCase):
+ """The prefix match must be robust to upstream's changing platform list."""
+
+ def test_a_future_platform_list_still_matches(self):
+ result = {
+ "success": True,
+ "warnings": [
+ "MEDIA attachments were omitted for t3; native send_message media "
+ "delivery is currently only supported for telegram, discord and "
+ "seventeen other platforms nobody has written yet"
+ ],
+ }
+ self.assertNotIn("warnings", coreshim._strip_false_warning(result))
+
+ def test_a_similar_warning_for_another_platform_is_not_matched(self):
+ warning = "MEDIA attachments were omitted for t3000; ..."
+ result = {"success": True, "warnings": [warning]}
+ self.assertEqual(
+ coreshim._strip_false_warning(result)["warnings"], [warning]
+ )
+
+ def test_a_non_dict_result_passes_through(self):
+ self.assertIsNone(coreshim._strip_false_warning(None))
+ self.assertEqual(coreshim._strip_false_warning("nope"), "nope")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/integrations/hermes-t3-gateway/tests/test_home.py b/integrations/hermes-t3-gateway/tests/test_home.py
new file mode 100644
index 000000000000..87c15f6cdc0f
--- /dev/null
+++ b/integrations/hermes-t3-gateway/tests/test_home.py
@@ -0,0 +1,842 @@
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+import json
+import os
+import pathlib
+import sys
+import tempfile
+import types
+import unittest
+import unittest.mock
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PACKAGE = "hermes_t3_gateway_home_test"
+
+# `home.py` depends only on `protocol.py` at import time (`connection.py` is
+# imported lazily inside the standalone sender), so this loader needs none of
+# the fake `gateway.*` modules the adapter tests install.
+package = types.ModuleType(PACKAGE)
+package.__path__ = [str(ROOT)]
+sys.modules.setdefault(PACKAGE, package)
+
+for _name in ("protocol", "connection", "home"):
+ _spec = importlib.util.spec_from_file_location(
+ f"{PACKAGE}.{_name}", ROOT / f"{_name}.py"
+ )
+ assert _spec and _spec.loader
+ _module = importlib.util.module_from_spec(_spec)
+ sys.modules[f"{PACKAGE}.{_name}"] = _module
+ _spec.loader.exec_module(_module)
+
+home = sys.modules[f"{PACKAGE}.home"]
+protocol = sys.modules[f"{PACKAGE}.protocol"]
+connection = sys.modules[f"{PACKAGE}.connection"]
+
+
+def make_delivery(text: str, thread_id: str = "home-thread", **kwargs):
+ return home.build_delivery(thread_id=thread_id, text=text, **kwargs)
+
+
+class QueueTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ self.path = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
+ self.queue = home.HomeDeliveryQueue(path=self.path)
+
+ def test_queue_replays_in_fifo_order(self):
+ """Deliveries flush oldest-first, so a Home transcript reads in order."""
+ first = make_delivery("first")
+ second = make_delivery("second")
+ third = make_delivery("third")
+ for entry in (first, second, third):
+ self.assertTrue(self.queue.append(entry))
+
+ self.assertEqual(
+ [entry["text"] for entry in self.queue.entries()],
+ ["first", "second", "third"],
+ )
+
+ def test_an_entry_is_purged_only_by_its_own_ack(self):
+ """The durability guarantee: nothing leaves the queue without an ack.
+
+ T3 acks only after a durable write, so purging on anything else — a
+ successful `send()`, a reconnect, a flush — would drop deliveries the
+ server never actually stored.
+ """
+ first = make_delivery("first")
+ second = make_delivery("second")
+ self.queue.append(first)
+ self.queue.append(second)
+
+ # Sending, flushing, or re-reading changes nothing.
+ self.assertEqual(len(self.queue.entries()), 2)
+ self.assertEqual(len(self.queue.entries()), 2)
+
+ # An unknown id purges nothing at all.
+ self.assertFalse(self.queue.purge("never-sent"))
+ self.assertEqual(len(self.queue.entries()), 2)
+
+ self.assertTrue(self.queue.purge(first["deliveryId"]))
+ self.assertEqual(
+ [entry["deliveryId"] for entry in self.queue.entries()],
+ [second["deliveryId"]],
+ )
+
+ # A duplicate ack (a replayed flush T3 deduped) is inert.
+ self.assertFalse(self.queue.purge(first["deliveryId"]))
+ self.assertEqual(len(self.queue.entries()), 1)
+
+ def test_the_queue_is_capped_and_drops_the_oldest(self):
+ """A wedged queue must not grow without bound — and must not go stale.
+
+ Dropping newest would make an over-full queue permanently swallow
+ current output; dropping oldest loses the least valuable entries and
+ keeps today's cron brief.
+ """
+ queue = home.HomeDeliveryQueue(path=self.path, max_entries=3)
+ deliveries = [make_delivery(f"delivery-{index}") for index in range(5)]
+ with self.assertLogs(home.logger, level="WARNING") as captured:
+ for entry in deliveries:
+ queue.append(entry)
+
+ self.assertTrue(
+ any("queue is full" in line for line in captured.output),
+ captured.output,
+ )
+ self.assertEqual(
+ [entry["text"] for entry in queue.entries()],
+ ["delivery-2", "delivery-3", "delivery-4"],
+ )
+
+ def test_the_queue_is_bounded_by_encoded_bytes_as_well_as_entry_count(self):
+ first = make_delivery("first")
+ second = make_delivery("second")
+ third = make_delivery("third")
+ two_entry_bytes = len(self.queue._encode(first).encode()) + len(
+ self.queue._encode(second).encode()
+ )
+ queue = home.HomeDeliveryQueue(
+ path=self.path, max_entries=10, max_bytes=two_entry_bytes + 4
+ )
+
+ with self.assertLogs(home.logger, level="WARNING"):
+ for entry in (first, second, third):
+ self.assertTrue(queue.append(entry))
+
+ self.assertEqual(
+ [entry["text"] for entry in queue.entries()], ["second", "third"]
+ )
+
+ def test_appending_the_same_delivery_twice_is_idempotent(self):
+ """A retried standalone send must not double-queue its own payload."""
+ entry = make_delivery("once")
+ self.assertTrue(self.queue.append(entry))
+ self.assertTrue(self.queue.append(dict(entry)))
+ self.assertEqual(len(self.queue.entries()), 1)
+
+ def test_a_torn_line_does_not_discard_the_whole_queue(self):
+ """A process killed mid-write must cost one entry, not the outbox."""
+ good = make_delivery("survivor")
+ self.queue.append(good)
+ with self.path.open("a", encoding="utf-8") as handle:
+ handle.write('{"deliveryId": "truncated"')
+
+ with self.assertLogs(home.logger, level="WARNING"):
+ self.assertEqual(
+ [entry["deliveryId"] for entry in self.queue.entries()],
+ [good["deliveryId"]],
+ )
+
+ def test_complete_interior_corruption_is_never_rewritten_from_a_partial_read(self):
+ first = make_delivery("before-corruption")
+ arriving = make_delivery("after-corruption")
+ self.queue.append(first)
+ with self.path.open("a", encoding="utf-8") as handle:
+ handle.write("not-json\n")
+
+ with self.assertLogs(home.logger, level="WARNING"):
+ self.assertTrue(self.queue.append(arriving))
+
+ raw = self.path.read_text(encoding="utf-8")
+ self.assertIn("before-corruption", raw)
+ self.assertIn("not-json", raw)
+ self.assertIn("after-corruption", raw)
+
+ def test_the_queue_survives_a_process_restart(self):
+ """Durability across a plugin restart is the entire point of the file."""
+ entry = make_delivery("across-restart")
+ self.queue.append(entry)
+
+ reopened = home.HomeDeliveryQueue(path=self.path)
+ self.assertEqual(
+ [item["deliveryId"] for item in reopened.entries()],
+ [entry["deliveryId"]],
+ )
+
+ def test_a_read_error_never_costs_the_entries_already_on_disk(self):
+ """The queue's worst failure mode, guarded.
+
+ `append` normally rewrites the whole file to enforce the entry cap. A
+ read that failed used to report an empty queue, so that rewrite
+ replaced every unacked delivery on disk with the one being appended.
+ A transient EIO destroyed the outbox. The rewrite is skipped entirely
+ when the read fails: the cap goes briefly unenforced (recoverable on
+ the next successful read), the entries do not (not recoverable at all).
+ """
+ first = make_delivery("already-queued")
+ second = make_delivery("also-queued")
+ self.queue.append(first)
+ self.queue.append(second)
+ arriving = make_delivery("arrives-during-the-outage")
+
+ real_read_text = pathlib.Path.read_text
+
+ def fail_reading_the_queue(path_self, *args, **kwargs):
+ if path_self == self.path:
+ raise OSError("simulated I/O error")
+ return real_read_text(path_self, *args, **kwargs)
+
+ with unittest.mock.patch.object(
+ pathlib.Path, "read_text", fail_reading_the_queue
+ ), self.assertLogs(home.logger, level="WARNING"):
+ self.assertTrue(self.queue.append(arriving))
+
+ # Every pre-existing delivery survived, and the new one joined them.
+ self.assertEqual(
+ [entry["text"] for entry in self.queue.entries()],
+ ["already-queued", "also-queued", "arrives-during-the-outage"],
+ )
+
+ def test_a_read_error_leaves_an_acked_entry_for_a_deduped_replay(self):
+ """Purge only rewrites, so an unreadable queue means doing nothing."""
+ entry = make_delivery("acked")
+ self.queue.append(entry)
+
+ real_read_text = pathlib.Path.read_text
+
+ def fail_reading_the_queue(path_self, *args, **kwargs):
+ if path_self == self.path:
+ raise OSError("simulated I/O error")
+ return real_read_text(path_self, *args, **kwargs)
+
+ with unittest.mock.patch.object(
+ pathlib.Path, "read_text", fail_reading_the_queue
+ ), self.assertLogs(home.logger, level="WARNING"):
+ self.assertFalse(self.queue.purge(entry["deliveryId"]))
+
+ self.assertEqual(
+ [item["deliveryId"] for item in self.queue.entries()],
+ [entry["deliveryId"]],
+ )
+
+ @unittest.skipIf(os.name != "posix", "POSIX file modes only")
+ def test_the_queue_is_readable_only_by_its_owner(self):
+ """Entries carry message text and base64 media — not other users' business."""
+ self.queue.append(make_delivery("private"))
+ self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
+ self.assertEqual(self.path.parent.stat().st_mode & 0o777, 0o700)
+
+ # And it stays private across the rewrite paths, not just on creation.
+ self.queue.append(make_delivery("still private"))
+ self.queue.purge(self.queue.entries()[0]["deliveryId"])
+ self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
+
+ @unittest.skipIf(os.name != "posix", "POSIX file modes only")
+ def test_a_preexisting_permissive_queue_is_repaired_on_rewrite(self):
+ self.queue.append(make_delivery("first"))
+ self.path.chmod(0o666)
+ self.queue.append(make_delivery("second"))
+ self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
+
+ @unittest.skipIf(os.name != "posix", "POSIX symlinks only")
+ def test_a_queue_symlink_is_replaced_without_touching_its_target(self):
+ target = pathlib.Path(self._tmp.name) / "do-not-touch"
+ target.write_text("private target", encoding="utf-8")
+ self.path.parent.mkdir(parents=True)
+ self.path.symlink_to(target)
+
+ self.assertTrue(self.queue.append(make_delivery("safe")))
+
+ self.assertFalse(self.path.is_symlink())
+ self.assertEqual(target.read_text(encoding="utf-8"), "private target")
+ self.assertEqual([entry["text"] for entry in self.queue.entries()], ["safe"])
+
+ def test_the_queue_lives_under_the_active_hermes_home(self):
+ """Profile-scoped: a second profile must not replay the first's output."""
+ with unittest.mock.patch.object(
+ home, "hermes_home", return_value=pathlib.Path("/tmp/profile-b")
+ ):
+ self.assertEqual(
+ home.queue_path(),
+ pathlib.Path("/tmp/profile-b/gateway/t3_home_delivery_queue.jsonl"),
+ )
+
+
+class ClassificationTests(unittest.TestCase):
+ def test_the_cron_job_id_metadata_is_the_strongest_signal(self):
+ kind, label, certain = home.classify_delivery(
+ "Anything at all", {"job_id": "daily-digest", "notify": True}
+ )
+ self.assertEqual(kind, "cron")
+ self.assertEqual(label, "Cron: daily-digest")
+ self.assertTrue(certain)
+
+ def test_the_cron_wrap_header_supplies_a_human_job_name(self):
+ content = (
+ "Cronjob Response: Morning digest\n"
+ "(job_id: abc123)\n"
+ "-------------\n\n"
+ "Three things happened.\n"
+ )
+ kind, label, certain = home.classify_delivery(content, None)
+ self.assertEqual(kind, "cron")
+ self.assertEqual(label, "Cron: Morning digest")
+ self.assertTrue(certain)
+
+ def test_gateway_lifecycle_notices_land_quietly(self):
+ for content in (
+ "♻️ Gateway online — Hermes is back and ready.",
+ "♻ Gateway restarted successfully. Your session continues.",
+ "⚠️ Gateway shutting down — Your current task will be interrupted.",
+ "⚠️ Gateway restarting — Your current task will be interrupted. "
+ "Send any message after restart and I'll try to resume where you "
+ "left off.",
+ ):
+ with self.subTest(content=content[:32]):
+ kind, label, certain = home.classify_delivery(content, None)
+ self.assertEqual(kind, "lifecycle")
+ self.assertEqual(label, "Gateway")
+ self.assertTrue(certain)
+
+ def test_a_handoff_is_recognised_from_its_synthetic_session_identity(self):
+ kind, label, certain = home.classify_delivery(
+ "Picking up where the CLI left off.",
+ None,
+ session_user_id="system:handoff",
+ )
+ self.assertEqual(kind, "handoff")
+ self.assertEqual(label, "Handoff")
+ self.assertTrue(certain)
+
+ def test_an_unrecognised_send_defaults_to_an_uncertain_message(self):
+ """Worst case is a wrong badge — and, in the live-turn window, a send
+ that stays with the turn rather than being torn out of it."""
+ kind, label, certain = home.classify_delivery("Just checking in.", None)
+ self.assertEqual(kind, "message")
+ self.assertEqual(label, "Hermes")
+ self.assertFalse(certain)
+
+
+class FrameTests(unittest.TestCase):
+ def test_home_deliver_applies_every_wire_bound(self):
+ frame = protocol.home_deliver(
+ delivery_id_value="delivery-1",
+ thread_id="home-thread",
+ kind="cron",
+ label=" " + "L" * 400 + " ",
+ text="x" * (protocol.MAX_HOME_DELIVERY_TEXT_CHARS + 500),
+ created_at="2026-07-26T00:00:00Z",
+ )
+ self.assertEqual(frame["type"], "home.deliver")
+ self.assertEqual(frame["protocolVersion"], 4)
+ self.assertEqual(frame["deliveryId"], "delivery-1")
+ self.assertEqual(frame["threadId"], "home-thread")
+ self.assertEqual(frame["kind"], "cron")
+ self.assertEqual(len(frame["label"]), protocol.MAX_HOME_DELIVERY_LABEL_CHARS)
+ self.assertEqual(
+ len(frame["text"]), protocol.MAX_HOME_DELIVERY_TEXT_CHARS
+ )
+ self.assertEqual(frame["createdAt"], "2026-07-26T00:00:00Z")
+
+ def test_home_deliver_never_emits_an_invalid_kind_or_empty_label(self):
+ frame = protocol.home_deliver(
+ delivery_id_value="delivery-2",
+ thread_id="home-thread",
+ kind="not-a-kind",
+ label=" ",
+ text="",
+ )
+ # A misclassification must cost a badge, never a server rejection of a
+ # delivery the plugin has already queued.
+ self.assertEqual(frame["kind"], "other")
+ self.assertEqual(frame["label"], "Hermes")
+ self.assertTrue(len(frame["text"]) >= 1)
+
+ def test_home_deliver_ack_is_an_accepted_server_command(self):
+ message = {"type": "home.deliver.ack", "protocolVersion": 4}
+ self.assertEqual(protocol.validate_server_frame(message), message)
+
+ def test_build_media_delivery_reads_the_file_and_guesses_the_mime(self):
+ import base64
+
+ with tempfile.TemporaryDirectory() as tmp:
+ chart = pathlib.Path(tmp) / "chart.png"
+ chart.write_bytes(b"\x89PNG fake bytes")
+ frame = home.build_media_delivery(
+ thread_id="home-thread",
+ path=str(chart),
+ kind="cron",
+ label="Cron: nightly",
+ caption="Nightly chart",
+ )
+ self.assertEqual(frame["type"], "media.deliver")
+ self.assertEqual(frame["kind"], "cron")
+ self.assertEqual(frame["name"], "chart.png")
+ self.assertEqual(frame["mimeType"], "image/png")
+ self.assertEqual(frame["sizeBytes"], len(b"\x89PNG fake bytes"))
+ self.assertEqual(base64.b64decode(frame["data"]), b"\x89PNG fake bytes")
+ self.assertEqual(frame["caption"], "Nightly chart")
+ self.assertTrue(frame["deliveryId"])
+ # Self-contained on disk: the frame carries the bytes, not the path,
+ # so a queued copy survives the source temp file being reaped.
+ self.assertNotIn("path", frame)
+
+ def test_build_media_delivery_fails_loudly_on_unreadable_or_oversized_files(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaises(OSError):
+ home.build_media_delivery(
+ thread_id="home-thread",
+ path=str(pathlib.Path(tmp) / "missing.png"),
+ )
+ empty = pathlib.Path(tmp) / "empty.bin"
+ empty.write_bytes(b"")
+ # An empty or oversized payload must never reach the durable
+ # queue: T3 would reject it on every flush, forever.
+ with self.assertRaises(ValueError):
+ home.build_media_delivery(
+ thread_id="home-thread", path=str(empty)
+ )
+
+ def test_hello_declares_its_connection_role(self):
+ gateway = protocol.connection_hello(
+ hermes_version="0.19.0",
+ authentication={"type": "instance-credential"},
+ )
+ self.assertEqual(gateway["role"], "gateway")
+ delivery = protocol.connection_hello(
+ hermes_version="0.19.0",
+ authentication={"type": "instance-credential"},
+ role="delivery",
+ )
+ self.assertEqual(delivery["role"], "delivery")
+ # An unknown role must never silently become "delivery" — the safe
+ # default is the ordinary connection.
+ unknown = protocol.connection_hello(
+ hermes_version="0.19.0",
+ authentication={"type": "instance-credential"},
+ role="nonsense",
+ )
+ self.assertEqual(unknown["role"], "gateway")
+
+
+class MockDeliveryServer:
+ """A T3 stand-in for the standalone sender's short-lived socket."""
+
+ def __init__(self, *, ack: bool = True):
+ self.ack = ack
+ self.sent: list[dict] = []
+ self.closed = False
+ self._outbox: list[str] = []
+
+ async def send(self, raw):
+ message = json.loads(raw)
+ self.sent.append(message)
+ if message.get("type") == "connection.hello":
+ self._outbox.append(
+ json.dumps(
+ {
+ "type": "connection.accepted",
+ "protocolVersion": protocol.PROTOCOL_VERSION,
+ "requestId": message["requestId"],
+ "instanceId": "provider-instance",
+ "nickname": "Hermes",
+ "homeThreadId": "home-thread",
+ }
+ )
+ )
+ elif message.get("type") in {"home.deliver", "media.deliver"} and self.ack:
+ self._outbox.append(
+ json.dumps(
+ {
+ "type": (
+ "media.deliver.ack"
+ if message["type"] == "media.deliver"
+ else "home.deliver.ack"
+ ),
+ "protocolVersion": protocol.PROTOCOL_VERSION,
+ "deliveryId": message["deliveryId"],
+ }
+ )
+ )
+
+ async def recv(self):
+ if not self._outbox:
+ raise AssertionError("the mock server was polled with nothing to send")
+ return self._outbox.pop(0)
+
+ async def close(self):
+ self.closed = True
+
+
+class StandaloneSenderTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ self.queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
+ patch = unittest.mock.patch.object(
+ home, "hermes_home", return_value=pathlib.Path(self._tmp.name)
+ )
+ patch.start()
+ self.addCleanup(patch.stop)
+ self.environment = unittest.mock.patch.dict(
+ home.os.environ,
+ {
+ "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws",
+ "HERMES_T3_GATEWAY_INSTANCE_ID": "provider-instance",
+ "HERMES_T3_GATEWAY_CREDENTIAL": "secret",
+ home.HOME_CHANNEL_ENV: "home-thread",
+ },
+ )
+ self.environment.start()
+ self.addCleanup(self.environment.stop)
+
+ def _serve(self, server):
+ async def _open(_url):
+ return server
+
+ return unittest.mock.patch.object(connection, "_open_socket", _open)
+
+ async def test_standalone_send_hellos_as_delivery_then_acks_and_closes(self):
+ """The full out-of-process cron path.
+
+ `role: "delivery"` is load-bearing: T3's broker registers a `gateway`
+ connection under generation fencing and displaces its predecessor, so a
+ cron dial-in announcing the default role would kick the live gateway
+ socket off its own instance mid-turn.
+ """
+ server = MockDeliveryServer()
+ with self._serve(server):
+ result = await home.standalone_send(
+ None,
+ "home-thread",
+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
+ )
+
+ self.assertTrue(result["success"])
+ self.assertEqual(
+ [message["type"] for message in server.sent],
+ ["connection.hello", "home.deliver"],
+ )
+ hello, delivery = server.sent
+ self.assertEqual(hello["role"], "delivery")
+ self.assertEqual(hello["protocolVersion"], 4)
+ self.assertEqual(
+ hello["authentication"],
+ {
+ "type": "instance-credential",
+ "instanceId": "provider-instance",
+ "credential": "secret",
+ },
+ )
+ self.assertEqual(delivery["threadId"], "home-thread")
+ self.assertEqual(delivery["kind"], "cron")
+ self.assertEqual(delivery["label"], "Cron: nightly")
+ self.assertEqual(result["message_id"], delivery["deliveryId"])
+ self.assertTrue(server.closed, "the delivery socket must not linger")
+
+ # Acked, so nothing is left queued for the live gateway to replay.
+ self.assertEqual(home.HomeDeliveryQueue().entries(), [])
+
+ async def test_delivery_ack_timeout_returns_ids_received_so_far(self):
+ class PartialAckServer(MockDeliveryServer):
+ async def recv(self):
+ if self._outbox:
+ return self._outbox.pop(0)
+ await asyncio.Future()
+
+ frames = [
+ home.build_delivery(thread_id="home-thread", text=text)
+ for text in ("first", "second")
+ ]
+ server = PartialAckServer()
+ # Suppress the second ack so recv blocks after the first one.
+ original_send = server.send
+
+ async def send(value):
+ await original_send(value)
+ if len([m for m in server.sent if m.get("type") == "home.deliver"]) == 2:
+ server._outbox.pop()
+
+ server.send = send
+ with self._serve(server):
+ acked = await home._deliver_over_short_lived_socket(
+ url="wss://t3.example/api/hermes-gateway/ws",
+ instance_id="provider-instance",
+ credential="secret",
+ frames=frames,
+ timeout=0.01,
+ )
+
+ self.assertEqual(acked, {frames[0]["deliveryId"]})
+ self.assertTrue(server.closed)
+
+ async def test_an_unreachable_t3_queues_rather_than_failing_the_cron_job(self):
+ """A cron job must not report failure for output that will arrive."""
+
+ async def _refuse(_url):
+ raise ConnectionRefusedError("T3 is down")
+
+ with unittest.mock.patch.object(connection, "_open_socket", _refuse):
+ result = await home.standalone_send(None, "home-thread", "Nightly brief")
+
+ self.assertTrue(result["success"])
+ self.assertTrue(result["queued"])
+ queued = home.HomeDeliveryQueue().entries()
+ self.assertEqual([entry["text"] for entry in queued], ["Nightly brief"])
+ self.assertEqual(queued[0]["deliveryId"], result["message_id"])
+
+ async def test_an_unacknowledged_delivery_stays_queued(self):
+ """No ack, no purge — the live gateway retries it on the next connect."""
+ server = MockDeliveryServer(ack=False)
+ with self._serve(server), unittest.mock.patch.object(
+ home, "_deliver_over_short_lived_socket", return_value=set()
+ ):
+ result = await home.standalone_send(None, "home-thread", "Unacked brief")
+
+ self.assertTrue(result["success"])
+ self.assertTrue(result["queued"])
+ self.assertEqual(
+ [entry["text"] for entry in home.HomeDeliveryQueue().entries()],
+ ["Unacked brief"],
+ )
+
+ async def test_standalone_send_refuses_when_hermes_is_not_enrolled(self):
+ with unittest.mock.patch.dict(
+ home.os.environ, {"HERMES_T3_GATEWAY_CREDENTIAL": ""}
+ ):
+ result = await home.standalone_send(None, "home-thread", "Brief")
+ self.assertIn("not enrolled", result["error"])
+
+ async def test_standalone_send_falls_back_to_the_designated_home_thread(self):
+ server = MockDeliveryServer()
+ with self._serve(server):
+ result = await home.standalone_send(None, "", "Brief with no chat id")
+ self.assertTrue(result["success"])
+ self.assertEqual(server.sent[1]["threadId"], "home-thread")
+
+ async def test_standalone_send_delivers_media_files_as_media_frames(self):
+ """`deliver=t3` cron output with files rides the v4 media framing."""
+ chart = pathlib.Path(self._tmp.name) / "chart.png"
+ chart.write_bytes(b"\x89PNG fake bytes")
+ server = MockDeliveryServer()
+ with self._serve(server):
+ result = await home.standalone_send(
+ None,
+ "home-thread",
+ "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
+ media_files=[(str(chart), False)],
+ )
+
+ self.assertTrue(result["success"])
+ self.assertEqual(
+ [message["type"] for message in server.sent],
+ ["connection.hello", "home.deliver", "media.deliver"],
+ )
+ media = server.sent[2]
+ # Media inherits the text's provenance so the chart gets the same
+ # badge as the brief it accompanies.
+ self.assertEqual(media["kind"], "cron")
+ self.assertEqual(media["label"], "Cron: nightly")
+ self.assertEqual(media["name"], "chart.png")
+ self.assertEqual(media["mimeType"], "image/png")
+ # Both frames were acked, so nothing stays queued.
+ self.assertEqual(home.HomeDeliveryQueue().entries(), [])
+ # Counter-evidence against core's unconditional "MEDIA attachments were
+ # omitted" warning: the accounting keys sit in the same result dict.
+ self.assertEqual(result["media_count"], 1)
+ self.assertEqual(result["acked_count"], 2)
+ self.assertEqual(
+ result["delivery_ids"],
+ [message["deliveryId"] for message in server.sent[1:]],
+ )
+ self.assertIn("1 media file(s) delivered and acknowledged", result["note"])
+ # `message_id` keeps pointing at the first frame — upstream reads it.
+ self.assertEqual(result["message_id"], server.sent[1]["deliveryId"])
+
+ async def test_a_text_only_send_reports_no_media(self):
+ """The accounting keys are always present; only the note is conditional."""
+ server = MockDeliveryServer()
+ with self._serve(server):
+ result = await home.standalone_send(None, "home-thread", "Just text")
+
+ self.assertEqual(result["media_count"], 0)
+ self.assertEqual(result["acked_count"], 1)
+ self.assertEqual(result["delivery_ids"], [result["message_id"]])
+ self.assertNotIn("note", result)
+
+ async def test_a_media_only_send_delivers_without_a_text_frame(self):
+ """`MEDIA:/tmp/x.png` with no prose is a normal send, not an error.
+
+ Core rejects this outright for `t3`
+ (`tools/send_message_tool.py:1101-1107` @ v0.19.0); `coreshim` routes it
+ here instead, so the sender has to handle an empty message.
+ """
+ chart = pathlib.Path(self._tmp.name) / "chart.png"
+ chart.write_bytes(b"\x89PNG fake bytes")
+ server = MockDeliveryServer()
+ with self._serve(server):
+ result = await home.standalone_send(
+ None, "home-thread", "", media_files=[(str(chart), False)]
+ )
+
+ self.assertTrue(result["success"])
+ # No empty text frame rides along.
+ self.assertEqual(
+ [message["type"] for message in server.sent],
+ ["connection.hello", "media.deliver"],
+ )
+ self.assertEqual(result["media_count"], 1)
+ self.assertEqual(result["acked_count"], 1)
+ self.assertIn("1 media file(s) delivered and acknowledged", result["note"])
+
+ async def test_media_counts_exclude_a_skipped_file(self):
+ """A file that could not be read is not counted as delivered."""
+ good = pathlib.Path(self._tmp.name) / "good.png"
+ good.write_bytes(b"\x89PNG fake bytes")
+ missing = pathlib.Path(self._tmp.name) / "gone.png"
+ server = MockDeliveryServer()
+ with self._serve(server), self.assertLogs(home.logger, level="WARNING"):
+ result = await home.standalone_send(
+ None,
+ "home-thread",
+ "Two charts, one dead",
+ media_files=[(str(good), False), (str(missing), False)],
+ )
+
+ self.assertTrue(result["success"])
+ self.assertEqual(result["media_count"], 1)
+ self.assertIn("1 media file(s) delivered and acknowledged", result["note"])
+ self.assertIn("skipped", result["detail"])
+
+ async def test_standalone_send_skips_an_unreadable_media_file(self):
+ """One bad file must not sink the brief — and must never be queued."""
+ server = MockDeliveryServer()
+ with self._serve(server), self.assertLogs(home.logger, level="WARNING"):
+ result = await home.standalone_send(
+ None,
+ "home-thread",
+ "Brief with a dead attachment",
+ media_files=[(str(pathlib.Path(self._tmp.name) / "gone.png"), False)],
+ )
+
+ self.assertTrue(result["success"])
+ self.assertIn("skipped", result["detail"])
+ self.assertEqual(
+ [message["type"] for message in server.sent],
+ ["connection.hello", "home.deliver"],
+ )
+ self.assertEqual(home.HomeDeliveryQueue().entries(), [])
+
+ async def test_unacked_media_stays_queued_for_the_next_connect(self):
+ """The durable lifecycle applies to media exactly as it does to text."""
+ chart = pathlib.Path(self._tmp.name) / "chart.png"
+ chart.write_bytes(b"\x89PNG fake bytes")
+
+ async def _refuse(_url):
+ raise ConnectionRefusedError("T3 is down")
+
+ with unittest.mock.patch.object(connection, "_open_socket", _refuse):
+ result = await home.standalone_send(
+ None,
+ "home-thread",
+ "Nightly brief",
+ media_files=[(str(chart), False)],
+ )
+
+ self.assertTrue(result["success"])
+ self.assertTrue(result["queued"])
+ queued = home.HomeDeliveryQueue().entries()
+ self.assertEqual(
+ [entry["type"] for entry in queued], ["home.deliver", "media.deliver"]
+ )
+ # The queued media frame is self-contained: bytes, not a path.
+ self.assertEqual(queued[1]["name"], "chart.png")
+ self.assertTrue(queued[1]["data"])
+ # Nothing was acked, so the note must not claim delivery — but it must
+ # still contradict "omitted", because the file is durably on its way.
+ self.assertEqual(result["media_count"], 1)
+ self.assertEqual(result["acked_count"], 0)
+ self.assertIn("1 media file(s) queued for delivery", result["note"])
+ self.assertNotIn("acknowledged", result["note"])
+
+ async def test_a_mid_batch_queue_failure_cannot_report_durable_success(self):
+ """Every frame must be acked or queued; one durable text leg is not enough."""
+ chart = pathlib.Path(self._tmp.name) / "chart.png"
+ chart.write_bytes(b"\x89PNG fake bytes")
+
+ async def _refuse(_url):
+ raise ConnectionRefusedError("T3 is down")
+
+ with unittest.mock.patch.object(
+ home.HomeDeliveryQueue, "append", side_effect=[True, False]
+ ) as append, unittest.mock.patch.object(connection, "_open_socket", _refuse):
+ result = await home.standalone_send(
+ None,
+ "home-thread",
+ "Nightly brief",
+ media_files=[(str(chart), False)],
+ )
+
+ self.assertEqual(append.call_count, 2, "queueing must never short-circuit the batch")
+ self.assertIn("not durably queued", result["error"])
+
+
+class DesignationTests(unittest.TestCase):
+ def test_saving_the_designation_mirrors_it_into_this_process(self):
+ """The running gateway must not need a restart to see a new home."""
+ saved = {}
+
+ config = types.ModuleType("hermes_cli.config")
+ config.save_env_value = lambda key, value: saved.__setitem__(key, value)
+ package = types.ModuleType("hermes_cli")
+ package.__path__ = []
+ with unittest.mock.patch.dict(
+ sys.modules, {"hermes_cli": package, "hermes_cli.config": config}
+ ), unittest.mock.patch.dict(home.os.environ, {}, clear=False):
+ self.assertTrue(home.save_home_thread_id("thread-home"))
+ self.assertEqual(saved, {home.HOME_CHANNEL_ENV: "thread-home"})
+ self.assertEqual(home.home_thread_id(), "thread-home")
+
+ def test_an_unwritable_env_still_routes_for_this_process(self):
+ """A managed or read-only `.env` degrades; it must not break routing."""
+
+ def refuse(key, value):
+ raise PermissionError("managed .env")
+
+ config = types.ModuleType("hermes_cli.config")
+ config.save_env_value = refuse
+ package = types.ModuleType("hermes_cli")
+ package.__path__ = []
+ with unittest.mock.patch.dict(
+ sys.modules, {"hermes_cli": package, "hermes_cli.config": config}
+ ), unittest.mock.patch.dict(
+ home.os.environ, {}, clear=False
+ ), self.assertLogs(home.logger, level="WARNING"):
+ self.assertFalse(home.save_home_thread_id("thread-home"))
+ # Not durable, but routable: this gateway delivers to the right
+ # thread until it restarts, and re-reconciles on the next connect.
+ self.assertEqual(home.home_thread_id(), "thread-home")
+
+ def test_an_empty_designation_is_ignored(self):
+ with unittest.mock.patch.dict(
+ home.os.environ, {home.HOME_CHANNEL_ENV: "keep-me"}
+ ):
+ self.assertFalse(home.save_home_thread_id(" "))
+ self.assertEqual(home.home_thread_id(), "keep-me")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/integrations/hermes-t3-gateway/tests/test_protocol.py b/integrations/hermes-t3-gateway/tests/test_protocol.py
new file mode 100644
index 000000000000..4c5b58247ec9
--- /dev/null
+++ b/integrations/hermes-t3-gateway/tests/test_protocol.py
@@ -0,0 +1,556 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import pathlib
+import sys
+import types
+import unittest
+from contextlib import contextmanager
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "t3_gateway_protocol", ROOT / "protocol.py"
+)
+assert SPEC and SPEC.loader
+protocol = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(protocol)
+
+
+@contextmanager
+def fake_hermes_config(loader):
+ """Install a stand-in `hermes_cli.config` for the duration of a test."""
+ saved = {
+ name: sys.modules.get(name) for name in ("hermes_cli", "hermes_cli.config")
+ }
+ package = types.ModuleType("hermes_cli")
+ package.__path__ = []
+ config = types.ModuleType("hermes_cli.config")
+ if loader is not None:
+ config.load_config_readonly = loader
+ package.config = config
+ sys.modules["hermes_cli"] = package
+ sys.modules["hermes_cli.config"] = config
+ try:
+ yield
+ finally:
+ for name, module in saved.items():
+ if module is None:
+ sys.modules.pop(name, None)
+ else:
+ sys.modules[name] = module
+
+
+@contextmanager
+def fake_hermes_skills(skills_list=None, skill_view=None):
+ """Install a stand-in `tools.skills_tool` for the duration of a test."""
+ saved = {name: sys.modules.get(name) for name in ("tools", "tools.skills_tool")}
+ package = types.ModuleType("tools")
+ package.__path__ = []
+ skills_tool = types.ModuleType("tools.skills_tool")
+ if skills_list is not None:
+ skills_tool.skills_list = skills_list
+ if skill_view is not None:
+ skills_tool.skill_view = skill_view
+ package.skills_tool = skills_tool
+ sys.modules["tools"] = package
+ sys.modules["tools.skills_tool"] = skills_tool
+ try:
+ yield
+ finally:
+ for name, module in saved.items():
+ if module is None:
+ sys.modules.pop(name, None)
+ else:
+ sys.modules[name] = module
+
+
+class ProtocolTests(unittest.TestCase):
+ def test_hello_matches_v4_contract(self):
+ hello = protocol.connection_hello(
+ hermes_version="0.19.0",
+ authentication={"type": "enrollment-token", "token": "once"},
+ hello_request_id="request-1",
+ model="gpt-5.6-terra",
+ )
+ self.assertEqual(hello["type"], "connection.hello")
+ self.assertEqual(hello["requestId"], "request-1")
+ self.assertEqual(hello["protocolVersion"], 4)
+ # v4 pins `attachments` to the literal true — it is part of the
+ # contract, not a negotiated option.
+ self.assertTrue(hello["capabilities"]["attachments"])
+ self.assertTrue(hello["capabilities"]["streaming"])
+ self.assertEqual(hello["model"], "gpt-5.6-terra")
+
+ def test_hello_normalizes_supported_roles_and_falls_back_to_gateway(self):
+ authentication = {"type": "instance-credential", "credential": "secret"}
+ delivery = protocol.connection_hello(
+ hermes_version="0.19.0", authentication=authentication, role=" DELIVERY "
+ )
+ invalid = protocol.connection_hello(
+ hermes_version="0.19.0", authentication=authentication, role="admin"
+ )
+ self.assertEqual(delivery["role"], "delivery")
+ self.assertEqual(invalid["role"], "gateway")
+
+ def test_hello_reports_the_configured_hermes_model(self):
+ config = {"model": {"default": "gpt-5.6-terra"}}
+
+ with fake_hermes_config(lambda: config):
+ hello = protocol.connection_hello(
+ hermes_version="0.19.0",
+ authentication={"type": "enrollment-token", "token": "once"},
+ )
+
+ self.assertEqual(hello["model"], "gpt-5.6-terra")
+ # `load_config_readonly` returns the shared process-wide cache; the
+ # lookup must never mutate it.
+ self.assertEqual(config, {"model": {"default": "gpt-5.6-terra"}})
+
+ def test_hello_omits_model_when_hermes_cannot_report_one(self):
+ def missing_section():
+ return {"agent": {}}
+
+ def older_hermes():
+ raise ImportError("load_config_readonly is unavailable")
+
+ for loader in (missing_section, older_hermes, None):
+ with self.subTest(loader=getattr(loader, "__name__", "absent")):
+ with fake_hermes_config(loader):
+ hello = protocol.connection_hello(
+ hermes_version="0.19.0",
+ authentication={
+ "type": "enrollment-token",
+ "token": "once",
+ },
+ )
+ # Omitted entirely — never null or empty.
+ self.assertNotIn("model", hello)
+
+ def test_configured_model_ignores_blank_and_non_string_values(self):
+ for value in ("", " ", None, 5, {"default": "nested"}):
+ config = {"model": {"default": value}}
+ with (
+ self.subTest(value=value),
+ fake_hermes_config(lambda config=config: config),
+ ):
+ self.assertIsNone(protocol.configured_model())
+
+ def test_server_frame_validation_is_closed(self):
+ with self.assertRaisesRegex(ValueError, "unsupported"):
+ protocol.validate_server_frame({"type": "made.up", "protocolVersion": 4})
+ with self.assertRaisesRegex(ValueError, "version"):
+ # Protocol v3 peers must upgrade before sending runtime frames.
+ protocol.validate_server_frame({"type": "ping", "protocolVersion": 3})
+
+ def test_describe_frames_are_accepted_server_commands(self):
+ for frame_type in ("describe.request", "skill.body.request"):
+ with self.subTest(frame_type=frame_type):
+ message = {"type": frame_type, "protocolVersion": 4}
+ self.assertEqual(protocol.validate_server_frame(message), message)
+
+ def test_handoff_results_and_protocol_errors_are_server_frames(self):
+ for frame_type in ("handoff.created", "protocol.error"):
+ with self.subTest(frame_type=frame_type):
+ message = {"type": frame_type, "protocolVersion": 4}
+ self.assertEqual(protocol.validate_server_frame(message), message)
+
+ # ── describe.response ──────────────────────────────────────────────
+
+ def test_describe_response_round_trips_every_reported_field(self):
+ skills = [
+ {"name": "codex", "description": "Delegate coding.", "source": "agents"}
+ ]
+ response = protocol.describe_response(
+ request_id_value="describe-1",
+ hermes_version="0.19.0",
+ model="gpt-5.6-terra",
+ reasoning_effort="medium",
+ skills=skills,
+ )
+ self.assertEqual(response["type"], "describe.response")
+ self.assertEqual(response["requestId"], "describe-1")
+ self.assertEqual(response["protocolVersion"], 4)
+ self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION)
+ self.assertEqual(response["hermesVersion"], "0.19.0")
+ self.assertEqual(response["model"], "gpt-5.6-terra")
+ self.assertEqual(response["reasoningEffort"], "medium")
+ self.assertEqual(response["skills"], skills)
+ self.assertTrue(response["capabilities"]["attachments"])
+ self.assertTrue(response["describedAt"].endswith("Z"))
+ # Skill dicts are copied out: mutating the reply must not reach back
+ # into whatever the caller passed in.
+ response["skills"][0]["name"] = "mutated"
+ self.assertEqual(skills[0]["name"], "codex")
+
+ def test_describe_reports_the_configured_reasoning_effort(self):
+ config = {"agent": {"reasoning_effort": "medium"}}
+
+ with fake_hermes_config(lambda: config):
+ response = protocol.describe_response(
+ request_id_value="describe-2",
+ hermes_version="0.19.0",
+ skills=[],
+ )
+
+ self.assertEqual(response["reasoningEffort"], "medium")
+ # `load_config_readonly` returns the shared process-wide cache; the
+ # lookup must never mutate it.
+ self.assertEqual(config, {"agent": {"reasoning_effort": "medium"}})
+
+ def test_describe_omits_effort_when_hermes_cannot_report_one(self):
+ def missing_section():
+ return {"model": {}}
+
+ def older_hermes():
+ raise ImportError("load_config_readonly is unavailable")
+
+ for loader in (missing_section, older_hermes, None):
+ with self.subTest(loader=getattr(loader, "__name__", "absent")):
+ with fake_hermes_config(loader):
+ response = protocol.describe_response(
+ request_id_value="describe-3",
+ hermes_version="0.19.0",
+ skills=[],
+ )
+ # Omitted entirely — never null or empty.
+ self.assertNotIn("reasoningEffort", response)
+ self.assertNotIn("model", response)
+ # The plugin-owned block is always present regardless.
+ self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION)
+ self.assertEqual(response["skills"], [])
+
+ def test_configured_reasoning_effort_ignores_blank_and_non_string_values(self):
+ for value in ("", " ", None, 5, {"level": "high"}):
+ config = {"agent": {"reasoning_effort": value}}
+ with (
+ self.subTest(value=value),
+ fake_hermes_config(lambda config=config: config),
+ ):
+ self.assertIsNone(protocol.configured_reasoning_effort())
+
+ # ── skills enumeration ─────────────────────────────────────────────
+
+ def test_installed_skills_projects_only_documented_fields(self):
+ payload = json.dumps(
+ {
+ "success": True,
+ "skills": [
+ {
+ "name": " codex ",
+ "description": " Delegate coding. ",
+ "category": "autonomous-ai-agents",
+ "secret": "must-not-cross",
+ },
+ {"name": "bare"},
+ ],
+ }
+ )
+ with fake_hermes_skills(skills_list=lambda: payload):
+ self.assertEqual(
+ protocol.installed_skills(),
+ [
+ {
+ "name": "codex",
+ "enabled": True,
+ "description": "Delegate coding.",
+ "source": "autonomous-ai-agents",
+ },
+ {"name": "bare", "enabled": True},
+ ],
+ )
+
+ def test_installed_skills_degrades_to_empty_on_every_failure(self):
+ def older_hermes():
+ raise ImportError("skills_list is unavailable")
+
+ def not_json():
+ return "not json"
+
+ def unsuccessful():
+ return json.dumps({"success": False, "error": "boom"})
+
+ def malformed_entries():
+ return json.dumps({"success": True, "skills": ["a string", {}, 5]})
+
+ for loader in (older_hermes, not_json, unsuccessful, malformed_entries, None):
+ with self.subTest(loader=getattr(loader, "__name__", "absent")):
+ with fake_hermes_skills(skills_list=loader):
+ self.assertEqual(protocol.installed_skills(), [])
+
+ def test_describe_reports_an_empty_skill_list_when_hermes_has_none(self):
+ with fake_hermes_skills(
+ skills_list=lambda: json.dumps({"success": True, "skills": []})
+ ):
+ response = protocol.describe_response(
+ request_id_value="describe-4",
+ hermes_version="0.19.0",
+ model="gpt-5.6-terra",
+ reasoning_effort="medium",
+ )
+ # Always present: an empty list is the truthful answer, never omitted.
+ self.assertEqual(response["skills"], [])
+
+ # ── skill.body.response ────────────────────────────────────────────
+
+ def test_skill_body_response_round_trips(self):
+ response = protocol.skill_body_response(
+ request_id_value="body-1",
+ skill_name="codex",
+ markdown="# Codex\n\nDelegate coding.",
+ )
+ self.assertEqual(response["type"], "skill.body.response")
+ self.assertEqual(response["requestId"], "body-1")
+ self.assertEqual(response["protocolVersion"], 4)
+ self.assertEqual(response["skillName"], "codex")
+ self.assertEqual(response["markdown"], "# Codex\n\nDelegate coding.")
+
+ def test_skill_body_response_sends_explicit_null_when_unavailable(self):
+ for markdown in (None, ""):
+ with self.subTest(markdown=markdown):
+ response = protocol.skill_body_response(
+ request_id_value="body-2",
+ skill_name="missing",
+ markdown=markdown,
+ )
+ # Present but null — the caller asked about a named skill and
+ # must tell "nothing to show" from a dropped reply.
+ self.assertIn("markdown", response)
+ self.assertIsNone(response["markdown"])
+
+ def test_skill_body_reads_the_authored_markdown_without_preprocessing(self):
+ seen = {}
+
+ def skill_view(name, preprocess=True, **kwargs):
+ seen["name"] = name
+ seen["preprocess"] = preprocess
+ return json.dumps({"success": True, "content": "# Codex\n"})
+
+ with fake_hermes_skills(skill_view=skill_view):
+ self.assertEqual(protocol.skill_body(" codex "), "# Codex\n")
+ self.assertEqual(seen["name"], "codex")
+ # T3 renders the skill for a human; Hermes' template/inline-shell
+ # rendering must not run.
+ self.assertFalse(seen["preprocess"])
+
+ def test_skill_body_truncates_a_pathological_body(self):
+ oversized = "x" * (protocol.MAX_SKILL_BODY_CHARS + 5_000)
+ with fake_hermes_skills(
+ skill_view=lambda *a, **kw: json.dumps(
+ {"success": True, "content": oversized}
+ )
+ ):
+ body = protocol.skill_body("huge")
+ self.assertEqual(len(body), protocol.MAX_SKILL_BODY_CHARS)
+
+ def test_skill_body_degrades_to_none_on_every_failure(self):
+ def older_hermes(*a, **kw):
+ raise ImportError("skill_view is unavailable")
+
+ def not_json(*a, **kw):
+ return "not json"
+
+ def unknown_skill(*a, **kw):
+ return json.dumps({"success": False, "error": "Skill 'x' not found."})
+
+ def blank_content(*a, **kw):
+ return json.dumps({"success": True, "content": " "})
+
+ for viewer in (older_hermes, not_json, unknown_skill, blank_content, None):
+ with self.subTest(viewer=getattr(viewer, "__name__", "absent")):
+ with fake_hermes_skills(skill_view=viewer):
+ self.assertIsNone(protocol.skill_body("codex"))
+
+ # A blank request never reaches Hermes at all.
+ with fake_hermes_skills(skill_view=older_hermes):
+ self.assertIsNone(protocol.skill_body(" "))
+ self.assertIsNone(protocol.skill_body(None))
+
+ def test_tool_types_map_to_canonical_items(self):
+ self.assertEqual(
+ protocol.canonical_tool_item_type("terminal"), "command_execution"
+ )
+ self.assertEqual(
+ protocol.canonical_tool_item_type("apply_patch"), "file_change"
+ )
+ self.assertEqual(
+ protocol.canonical_tool_item_type("custom_vendor_tool"),
+ "dynamic_tool_call",
+ )
+
+ def test_tool_data_never_forwards_arbitrary_args(self):
+ self.assertEqual(
+ protocol.canonical_tool_data(
+ "terminal",
+ {"command": "pytest", "cwd": "/repo", "credential": "secret"},
+ ),
+ {"command": "pytest", "cwd": "/repo"},
+ )
+ self.assertIsNone(
+ protocol.canonical_tool_data(
+ "custom_vendor_tool", {"credential": "must-not-cross"}
+ )
+ )
+
+ # ── media.deliver ──────────────────────────────────────────────────
+
+ def test_media_deliver_encodes_the_payload_and_applies_every_wire_bound(self):
+ import base64
+
+ payload = b"\x89PNG fake bytes"
+ frame = protocol.media_deliver(
+ delivery_id_value="media-1",
+ thread_id="home-thread",
+ kind="cron",
+ label=" " + "L" * 400 + " ",
+ name="chart.png",
+ mime_type="image/png",
+ data=payload,
+ turn_id="turn-9",
+ caption="c" * (protocol.MAX_MEDIA_CAPTION_CHARS + 50),
+ created_at="2026-07-27T00:00:00Z",
+ )
+ self.assertEqual(frame["type"], "media.deliver")
+ self.assertEqual(frame["protocolVersion"], 4)
+ self.assertEqual(frame["deliveryId"], "media-1")
+ self.assertEqual(frame["threadId"], "home-thread")
+ self.assertEqual(frame["turnId"], "turn-9")
+ self.assertEqual(frame["kind"], "cron")
+ self.assertEqual(len(frame["label"]), protocol.MAX_HOME_DELIVERY_LABEL_CHARS)
+ self.assertEqual(frame["name"], "chart.png")
+ self.assertEqual(frame["mimeType"], "image/png")
+ # `sizeBytes` and `data` are derived from the same bytes, so they can
+ # never disagree — and the payload round-trips exactly.
+ self.assertEqual(frame["sizeBytes"], len(payload))
+ self.assertEqual(base64.b64decode(frame["data"]), payload)
+ self.assertEqual(len(frame["caption"]), protocol.MAX_MEDIA_CAPTION_CHARS)
+ self.assertEqual(frame["createdAt"], "2026-07-27T00:00:00Z")
+
+ def test_media_deliver_omits_optional_fields_rather_than_sending_empty(self):
+ frame = protocol.media_deliver(
+ delivery_id_value="media-2",
+ thread_id="home-thread",
+ kind="message",
+ label="Hermes",
+ name="brief.pdf",
+ mime_type="application/pdf",
+ data=b"%PDF",
+ )
+ self.assertNotIn("turnId", frame)
+ self.assertNotIn("caption", frame)
+
+ def test_media_deliver_degrades_provenance_but_never_the_payload_shape(self):
+ frame = protocol.media_deliver(
+ delivery_id_value="media-3",
+ thread_id="home-thread",
+ kind="not-a-kind",
+ label=" ",
+ name=" ",
+ mime_type="",
+ data=b"x",
+ )
+ # A misclassification must cost a badge, never a server rejection of a
+ # delivery the plugin has already queued.
+ self.assertEqual(frame["kind"], "other")
+ self.assertEqual(frame["label"], "Hermes")
+ self.assertEqual(frame["name"], "attachment.bin")
+ self.assertEqual(frame["mimeType"], "application/octet-stream")
+
+ def test_media_deliver_requires_a_delivery_id(self):
+ with self.assertRaisesRegex(ValueError, "deliveryId"):
+ protocol.media_deliver(
+ delivery_id_value=" ",
+ thread_id="home-thread",
+ kind="message",
+ label="Hermes",
+ name="a.bin",
+ mime_type="application/octet-stream",
+ data=b"x",
+ )
+
+ def test_media_deliver_rejects_an_empty_or_oversized_payload(self):
+ # Truncation would corrupt the file, so unlike text these fail loudly
+ # instead of being clamped — and never reach the durable queue.
+ for data, pattern in (
+ (b"", "non-empty"),
+ (b"x" * (protocol.MAX_MEDIA_BYTES + 1), "ceiling"),
+ ):
+ with self.subTest(size=len(data)):
+ with self.assertRaisesRegex(ValueError, pattern):
+ protocol.media_deliver(
+ delivery_id_value="media-4",
+ thread_id="home-thread",
+ kind="message",
+ label="Hermes",
+ name="big.bin",
+ mime_type="application/octet-stream",
+ data=data,
+ )
+
+ def test_media_deliver_ack_is_an_accepted_server_command(self):
+ message = {"type": "media.deliver.ack", "protocolVersion": 4}
+ self.assertEqual(protocol.validate_server_frame(message), message)
+
+ # ── inbound turn attachments ───────────────────────────────────────
+
+ def test_turn_attachments_decode_base64_to_bytes(self):
+ import base64
+
+ message = {
+ "type": "turn.start",
+ "attachments": [
+ {
+ "name": "notes.txt",
+ "mimeType": "text/plain",
+ "sizeBytes": 5,
+ "data": base64.b64encode(b"hello").decode("ascii"),
+ },
+ {"name": "blob", "data": base64.b64encode(b"\x00\x01").decode()},
+ ],
+ }
+ decoded = protocol.turn_attachments(message)
+ self.assertEqual(
+ decoded,
+ [
+ {"name": "notes.txt", "mimeType": "text/plain", "data": b"hello"},
+ # A missing MIME degrades to octet-stream, never empty.
+ {
+ "name": "blob",
+ "mimeType": "application/octet-stream",
+ "data": b"\x00\x01",
+ },
+ ],
+ )
+
+ def test_a_frame_without_attachments_decodes_to_an_empty_list(self):
+ self.assertEqual(protocol.turn_attachments({"type": "turn.start"}), [])
+
+ def test_malformed_turn_attachments_raise_rather_than_dropping_files(self):
+ # T3 validates against its schema before sending, so a bad entry here
+ # is version drift; silently losing a user's file is worse than a
+ # correlated protocol.error they can see.
+ for attachments in (
+ "not-a-list",
+ [{"mimeType": "text/plain", "data": "aGk="}], # no name
+ [{"name": "x.txt"}], # no data
+ [{"name": "x.txt", "data": "!!! not base64 !!!"}],
+ [{"name": "x.txt", "data": ""}],
+ ):
+ with self.subTest(attachments=attachments):
+ with self.assertRaises(ValueError):
+ protocol.turn_attachments({"attachments": attachments})
+
+ def test_an_oversized_turn_attachment_is_rejected(self):
+ import base64
+
+ oversized = base64.b64encode(
+ b"x" * (protocol.MAX_MEDIA_BYTES + 1)
+ ).decode("ascii")
+ with self.assertRaisesRegex(ValueError, "ceiling"):
+ protocol.turn_attachments(
+ {"attachments": [{"name": "huge.bin", "data": oversized}]}
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index f579453c27fc..c7ca823b8dfd 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -731,6 +731,28 @@ export function createServerEnvironmentAtoms(
key: ({ environmentId }) => environmentId,
},
}),
+ hermesGatewayCreateEnrollment: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:hermes-gateway:create-enrollment",
+ tag: WS_METHODS.hermesGatewayCreateEnrollment,
+ scheduler: configScheduler,
+ concurrency: configConcurrency,
+ }),
+ hermesGatewayGetInstanceStatus: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:hermes-gateway:get-instance-status",
+ tag: WS_METHODS.hermesGatewayGetInstanceStatus,
+ }),
+ hermesGatewayRevokeInstance: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:hermes-gateway:revoke-instance",
+ tag: WS_METHODS.hermesGatewayRevokeInstance,
+ scheduler: configScheduler,
+ concurrency: configConcurrency,
+ }),
+ hermesGatewayRemoveInstance: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:hermes-gateway:remove-instance",
+ tag: WS_METHODS.hermesGatewayRemoveInstance,
+ scheduler: configScheduler,
+ concurrency: configConcurrency,
+ }),
updateProvider: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:update-provider",
tag: WS_METHODS.serverUpdateProvider,
diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts
index e3922073455d..125848cb1e9b 100644
--- a/packages/contracts/src/assets.ts
+++ b/packages/contracts/src/assets.ts
@@ -1,7 +1,7 @@
import * as Schema from "effect/Schema";
import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
-import { ProjectFaviconPath } from "./orchestration.ts";
+import { ChatFileAttachment, ProjectFaviconPath } from "./orchestration.ts";
const ASSET_PATH_MAX_LENGTH = 1024;
@@ -12,6 +12,14 @@ export const AssetResource = Schema.Union([
}),
Schema.TaggedStruct("attachment", {
attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
+ /**
+ * Presentation hints for opaque non-image payloads. The server signs
+ * these into the asset capability and always serves hinted files as
+ * downloads, so caller-controlled MIME metadata cannot become executable
+ * same-origin content.
+ */
+ fileName: Schema.optional(ChatFileAttachment.fields.name),
+ mimeType: Schema.optional(ChatFileAttachment.fields.mimeType),
}),
Schema.TaggedStruct("project-favicon", {
cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
diff --git a/packages/contracts/src/hermesGateway.test.ts b/packages/contracts/src/hermesGateway.test.ts
new file mode 100644
index 000000000000..6826620432c7
--- /dev/null
+++ b/packages/contracts/src/hermesGateway.test.ts
@@ -0,0 +1,674 @@
+import * as Schema from "effect/Schema";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ DEFAULT_HERMES_MODEL,
+ DEFAULT_MODEL_BY_PROVIDER,
+ HERMES_DRIVER_KIND,
+ PROVIDER_DISPLAY_NAMES,
+} from "./model.ts";
+import {
+ HERMES_GATEWAY_PROTOCOL_VERSION,
+ HERMES_MEDIA_MAX_BYTES,
+ HermesGatewayCapabilities,
+ HermesGatewayConnectionHello,
+ HermesGatewayCreateEnrollmentInput,
+ HermesGatewayInstanceStatus,
+ HermesGatewayPluginToT3Message,
+ HermesGatewayResumeCursor,
+ HermesGatewayT3ToPluginMessage,
+} from "./hermesGateway.ts";
+import { WS_METHODS } from "./rpc.ts";
+import { DEFAULT_SERVER_SETTINGS, HermesSettings } from "./settings.ts";
+
+const decodeCreateEnrollment = Schema.decodeUnknownSync(HermesGatewayCreateEnrollmentInput);
+const decodeCapabilities = Schema.decodeUnknownSync(HermesGatewayCapabilities);
+const decodeInstanceStatus = Schema.decodeUnknownSync(HermesGatewayInstanceStatus);
+const decodeHello = Schema.decodeUnknownSync(HermesGatewayConnectionHello);
+const decodeResumeCursor = Schema.decodeUnknownSync(HermesGatewayResumeCursor);
+const decodeT3Message = Schema.decodeUnknownSync(HermesGatewayT3ToPluginMessage);
+const decodePluginMessage = Schema.decodeUnknownSync(HermesGatewayPluginToT3Message);
+const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings);
+
+describe("Hermes gateway management contracts", () => {
+ it("decodes an enrollment request without deriving identity from the nickname", () => {
+ expect(
+ decodeCreateEnrollment({
+ instanceId: "hermes-research",
+ nickname: " Research ",
+ connectorUrl: " https://t3.example.test:3774/hermes ",
+ }),
+ ).toEqual({
+ instanceId: "hermes-research",
+ nickname: "Research",
+ connectorUrl: "https://t3.example.test:3774/hermes",
+ });
+ });
+
+ it("rejects invalid provider ids and non-connector URL schemes", () => {
+ expect(() =>
+ decodeCreateEnrollment({
+ instanceId: "1-hermes",
+ nickname: "Research",
+ connectorUrl: "wss://t3.example.test/hermes",
+ }),
+ ).toThrow();
+ expect(() =>
+ decodeCreateEnrollment({
+ instanceId: "hermes-research",
+ nickname: "Research",
+ connectorUrl: "ftp://t3.example.test/hermes",
+ }),
+ ).toThrow();
+ });
+
+ it("represents connected and upgrade-required instances for the web UI", () => {
+ const connected = decodeInstanceStatus({
+ instanceId: "hermes-research",
+ nickname: "Research",
+ status: "connected",
+ connectorUrl: "wss://t3.example.test/hermes",
+ lastConnectedAt: "2026-07-23T12:00:00.000Z",
+ pluginVersion: "0.2.0",
+ hermesVersion: "1.2.3",
+ model: "gpt-5.6-terra",
+ connectionGeneration: 3,
+ activeSessionCount: 2,
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ capabilities: {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: true,
+ },
+ });
+ expect(connected.status).toBe("connected");
+ expect(connected.activeSessionCount).toBe(2);
+ expect(connected.model).toBe("gpt-5.6-terra");
+
+ // A plugin that predates the `model` field still produces a valid status;
+ // the picker falls back to the generic label rather than failing to decode.
+ const withoutModel = decodeInstanceStatus({ ...connected, model: null });
+ expect(withoutModel.model).toBeNull();
+
+ const upgradeRequired = decodeInstanceStatus({
+ ...connected,
+ status: "upgrade-required",
+ protocolVersion: 3,
+ capabilities: null,
+ });
+ expect(upgradeRequired.protocolVersion).toBe(3);
+ expect(upgradeRequired.capabilities).toBeNull();
+ });
+});
+
+describe("Hermes gateway handshake", () => {
+ it("accepts one-time enrollment authentication", () => {
+ const hello = decodeHello({
+ type: "connection.hello",
+ requestId: "hello-1",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ pluginVersion: "0.2.0",
+ hermesVersion: "1.2.3",
+ capabilities: {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: true,
+ },
+ authentication: {
+ type: "enrollment-token",
+ token: "enroll-secret",
+ },
+ });
+
+ expect(hello.authentication.type).toBe("enrollment-token");
+ });
+
+ it("accepts persistent instance authentication", () => {
+ const hello = decodeHello({
+ type: "connection.hello",
+ requestId: "hello-2",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ pluginVersion: "0.2.0",
+ hermesVersion: "1.2.3",
+ capabilities: {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: true,
+ },
+ model: "gpt-5.6-terra",
+ authentication: {
+ type: "instance-credential",
+ instanceId: "hermes-research",
+ credential: "persistent-secret",
+ },
+ });
+
+ expect(hello.authentication.type).toBe("instance-credential");
+ expect(hello.model).toBe("gpt-5.6-terra");
+ });
+
+ // The plugin ships separately from the server, so a plugin that predates the
+ // `model` field must still complete the handshake rather than failing the
+ // frame decoder. T3 falls back to the generic model label.
+ it("accepts a hello from a plugin that reports no model", () => {
+ const hello = decodeHello({
+ type: "connection.hello",
+ requestId: "hello-no-model",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ pluginVersion: "0.2.0",
+ hermesVersion: "1.2.3",
+ capabilities: {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: true,
+ },
+ authentication: {
+ type: "instance-credential",
+ instanceId: "hermes-research",
+ credential: "persistent-secret",
+ },
+ });
+
+ expect(hello.model).toBeUndefined();
+ });
+
+ it("decodes an other-version hello so the broker can reject it explicitly", () => {
+ // A v3 plugin (pre-media) must reach the broker's structured
+ // `version-incompatible` rejection rather than dying in the frame decoder.
+ const hello = decodeHello({
+ type: "connection.hello",
+ requestId: "hello-other-version",
+ protocolVersion: 3,
+ pluginVersion: "0.3.0",
+ hermesVersion: "2.0.0",
+ capabilities: {
+ protocolVersion: 3,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: false,
+ },
+ authentication: {
+ type: "enrollment-token",
+ token: "enroll-secret",
+ },
+ });
+
+ expect(hello.protocolVersion).toBe(3);
+ expect(hello.capabilities.protocolVersion).toBe(3);
+ });
+
+ it("requires attachments as part of the v4 contract itself", () => {
+ // Not a negotiated option: a v4 plugin that cannot handle attachments is
+ // a v3 plugin, and belongs at the version gate instead.
+ expect(() =>
+ decodeCapabilities({
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: false,
+ }),
+ ).toThrow();
+ });
+});
+
+describe("T3 to Hermes messages", () => {
+ it("decodes session creation and opaque resume cursors", () => {
+ expect(
+ decodeT3Message({
+ type: "session.ensure",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "ensure-1",
+ threadId: "thread-1",
+ resumeSessionId: "opaque/hermes/session/value",
+ }).type,
+ ).toBe("session.ensure");
+
+ expect(
+ decodeResumeCursor({
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ sessionId: "opaque/hermes/session/value",
+ }).sessionId,
+ ).toBe("opaque/hermes/session/value");
+ });
+
+ it("decodes start and steering as distinct turn operations", () => {
+ const context = {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "turn-command-1",
+ threadId: "thread-1",
+ sessionId: "session-1",
+ turnId: "turn-1",
+ text: "Keep the current turn running, but use this guidance.",
+ };
+
+ expect(decodeT3Message({ type: "turn.start", ...context }).type).toBe("turn.start");
+ expect(decodeT3Message({ type: "turn.steer", ...context }).type).toBe("turn.steer");
+ });
+
+ it("decodes interrupt, approval, structured input, stop, and ping", () => {
+ const turnContext = {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ threadId: "thread-1",
+ sessionId: "session-1",
+ turnId: "turn-1",
+ };
+
+ expect(
+ decodeT3Message({
+ type: "turn.interrupt",
+ requestId: "interrupt-1",
+ ...turnContext,
+ }).type,
+ ).toBe("turn.interrupt");
+ expect(
+ decodeT3Message({
+ type: "approval.respond",
+ requestId: "approval-1",
+ decision: "acceptForSession",
+ ...turnContext,
+ }).type,
+ ).toBe("approval.respond");
+ expect(
+ decodeT3Message({
+ type: "user-input.respond",
+ requestId: "question-1",
+ answers: { environment: "production" },
+ ...turnContext,
+ }).type,
+ ).toBe("user-input.respond");
+ expect(
+ decodeT3Message({
+ type: "session.stop",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "stop-1",
+ threadId: "thread-1",
+ sessionId: "session-1",
+ }).type,
+ ).toBe("session.stop");
+ expect(
+ decodeT3Message({
+ type: "ping",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "ping-1",
+ sentAt: "2026-07-23T12:00:00.000Z",
+ }).type,
+ ).toBe("ping");
+ });
+
+ it("rejects post-handshake frames from another protocol version", () => {
+ expect(() =>
+ decodeT3Message({
+ type: "ping",
+ // Protocol v1 peers must upgrade before sending post-handshake frames.
+ protocolVersion: 1,
+ requestId: "ping-1",
+ sentAt: "2026-07-23T12:00:00.000Z",
+ }),
+ ).toThrow();
+ });
+
+ it("defaults an unstated connection role to gateway", () => {
+ // The field is about intent, not tolerance: a hello that says nothing is
+ // the ordinary live plugin, which must never be read as a throwaway
+ // delivery socket.
+ const frame = {
+ type: "connection.hello",
+ requestId: "hello-role-default",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ pluginVersion: "0.2.0",
+ hermesVersion: "1.2.3",
+ capabilities: {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ streaming: true,
+ activity: true,
+ approvals: true,
+ userInput: true,
+ attachments: true,
+ },
+ authentication: { type: "instance-credential", instanceId: "hermes", credential: "secret" },
+ } as const;
+ const hello = decodeHello(frame);
+
+ expect(hello.role).toBe("gateway");
+ expect(decodeHello({ ...frame, role: "delivery" }).role).toBe("delivery");
+ });
+
+ it("carries the home thread designation on acceptance", () => {
+ const accepted = decodeT3Message({
+ type: "connection.accepted",
+ requestId: "hello-1",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ instanceId: "hermes",
+ nickname: "Remote Hermes",
+ homeThreadId: "thread-home-1",
+ });
+
+ expect(accepted.type).toBe("connection.accepted");
+ if (accepted.type === "connection.accepted") {
+ expect(accepted.homeThreadId).toBe("thread-home-1");
+ }
+
+ // Optional: a handshake whose home-thread resolution failed still accepts
+ // the plugin rather than refusing an authenticated connection.
+ const withoutHome = decodeT3Message({
+ type: "connection.accepted",
+ requestId: "hello-2",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ instanceId: "hermes",
+ nickname: "Remote Hermes",
+ });
+ expect(withoutHome.type).toBe("connection.accepted");
+ });
+});
+
+describe("Hermes home deliveries", () => {
+ const delivery = {
+ type: "home.deliver",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ deliveryId: "delivery-1",
+ threadId: "thread-home-1",
+ kind: "cron",
+ label: "Cron: daily-digest",
+ text: "Your digest is ready.",
+ createdAt: "2026-07-25T12:00:00.000Z",
+ } as const;
+
+ it("decodes a delivery and its acknowledgement", () => {
+ const decoded = decodePluginMessage(delivery);
+ expect(decoded.type).toBe("home.deliver");
+ if (decoded.type === "home.deliver") {
+ expect(decoded.kind).toBe("cron");
+ expect(decoded.deliveryId).toBe("delivery-1");
+ }
+
+ const ack = decodeT3Message({
+ type: "home.deliver.ack",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ deliveryId: "delivery-1",
+ });
+ expect(ack.type).toBe("home.deliver.ack");
+ });
+
+ it("requires a delivery id, since it is the dedupe key for retries", () => {
+ expect(() => decodePluginMessage({ ...delivery, deliveryId: "" })).toThrow();
+ });
+
+ it("rejects an unknown delivery kind rather than guessing a badge", () => {
+ expect(() => decodePluginMessage({ ...delivery, kind: "surprise" })).toThrow();
+ });
+
+ it("rejects multiline labels that could escape the rendered provenance quote", () => {
+ expect(() => decodePluginMessage({ ...delivery, label: "Cron\nInjected heading" })).toThrow();
+ });
+
+ it("rejects empty delivery text", () => {
+ expect(() => decodePluginMessage({ ...delivery, text: "" })).toThrow();
+ });
+});
+
+describe("Hermes media deliveries", () => {
+ const media = {
+ type: "media.deliver",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ deliveryId: "media-1",
+ threadId: "thread-home-1",
+ kind: "cron",
+ label: "Cron: daily-digest",
+ name: "digest-chart.png",
+ mimeType: "image/png",
+ sizeBytes: 4,
+ data: "AAAA",
+ createdAt: "2026-07-27T12:00:00.000Z",
+ } as const;
+
+ it("decodes turnless media, turn-scoped media, and the acknowledgement", () => {
+ const proactive = decodePluginMessage(media);
+ expect(proactive.type).toBe("media.deliver");
+ if (proactive.type === "media.deliver") {
+ expect(proactive.turnId).toBeUndefined();
+ expect(proactive.kind).toBe("cron");
+ }
+
+ const turnScoped = decodePluginMessage({ ...media, turnId: "turn-1", caption: "Today's run" });
+ if (turnScoped.type === "media.deliver") {
+ expect(turnScoped.turnId).toBe("turn-1");
+ expect(turnScoped.caption).toBe("Today's run");
+ }
+
+ const ack = decodeT3Message({
+ type: "media.deliver.ack",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ deliveryId: "media-1",
+ });
+ expect(ack.type).toBe("media.deliver.ack");
+ });
+
+ it("requires a delivery id, since it is the dedupe key for retries", () => {
+ expect(() => decodePluginMessage({ ...media, deliveryId: "" })).toThrow();
+ });
+
+ it("rejects empty payloads and zero-byte sizes", () => {
+ expect(() => decodePluginMessage({ ...media, data: "" })).toThrow();
+ expect(() => decodePluginMessage({ ...media, sizeBytes: 0 })).toThrow();
+ });
+
+ it("bounds the base64 payload at the frame ceiling", () => {
+ // One character past the 25MiB ceiling must fail at decode,
+ // before anything buffers or writes.
+ const overCeiling = "A".repeat(Math.ceil(HERMES_MEDIA_MAX_BYTES / 3) * 4 + 8);
+ expect(() => decodePluginMessage({ ...media, data: overCeiling })).toThrow();
+ });
+});
+
+describe("Hermes handoff thread correlation", () => {
+ it("decodes the public adapter request and its server response", () => {
+ const create = decodePluginMessage({
+ type: "handoff.create",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "handoff-1",
+ parentThreadId: "home-thread",
+ name: "Hermes — release prep",
+ });
+ expect(create.type).toBe("handoff.create");
+
+ const created = decodeT3Message({
+ type: "handoff.created",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "handoff-1",
+ threadId: "hermes-handoff-created",
+ });
+ expect(created.type).toBe("handoff.created");
+ });
+
+ it("accepts a correlated server protocol error for the documented Home fallback", () => {
+ const error = decodeT3Message({
+ type: "protocol.error",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "handoff-1",
+ code: "unsupported-message",
+ message: "Upgrade the T3 companion server.",
+ recoverable: true,
+ });
+ expect(error.type).toBe("protocol.error");
+ });
+});
+
+describe("Hermes to T3 events", () => {
+ const turnContext = {
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ threadId: "thread-1",
+ sessionId: "session-1",
+ turnId: "turn-1",
+ };
+
+ it("decodes session readiness, turn start, streaming text, and completion", () => {
+ const legacyReady = decodePluginMessage({
+ type: "session.ready",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "ensure-1",
+ threadId: "thread-1",
+ sessionId: "session-1",
+ resumed: false,
+ });
+ expect(legacyReady.type).toBe("session.ready");
+ const activeReady = decodePluginMessage({
+ type: "session.ready",
+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
+ requestId: "ensure-2",
+ threadId: "thread-1",
+ sessionId: "session-1",
+ resumed: true,
+ activeTurnId: "turn-1",
+ });
+ expect(activeReady.type).toBe("session.ready");
+ if (activeReady.type !== "session.ready") {
+ throw new Error("expected session.ready");
+ }
+ expect(activeReady.activeTurnId).toBe("turn-1");
+ expect(
+ decodePluginMessage({
+ type: "turn.started",
+ requestId: "turn-command-1",
+ ...turnContext,
+ }).type,
+ ).toBe("turn.started");
+ expect(
+ decodePluginMessage({
+ type: "content.delta",
+ streamKind: "assistant_text",
+ delta: "Hello",
+ ...turnContext,
+ }).type,
+ ).toBe("content.delta");
+ const snapshot = decodePluginMessage({
+ type: "content.snapshot",
+ streamKind: "assistant_text",
+ text: "",
+ itemId: "message-1",
+ contentIndex: 0,
+ ...turnContext,
+ });
+ expect(snapshot.type).toBe("content.snapshot");
+ if (snapshot.type !== "content.snapshot") {
+ throw new Error("expected content.snapshot");
+ }
+ expect(snapshot.text).toBe("");
+ expect(
+ decodePluginMessage({
+ type: "turn.completed",
+ state: "completed",
+ ...turnContext,
+ }).type,
+ ).toBe("turn.completed");
+ });
+
+ it("decodes activity lifecycle events with normalized and generic data", () => {
+ for (const type of ["item.started", "item.updated", "item.completed"] as const) {
+ expect(
+ decodePluginMessage({
+ type,
+ itemId: "tool-1",
+ itemType: "mcp_tool_call",
+ status: type === "item.completed" ? "completed" : "inProgress",
+ title: "Search",
+ detail: "Looking up the requested information",
+ data: { providerKind: "hermes-native-event" },
+ ...turnContext,
+ }).type,
+ ).toBe(type);
+ }
+ });
+
+ it("decodes approvals and structured user-input lifecycle events", () => {
+ expect(
+ decodePluginMessage({
+ type: "request.opened",
+ requestId: "approval-1",
+ requestType: "command_execution_approval",
+ detail: "Run the command?",
+ args: { command: "git status" },
+ ...turnContext,
+ }).type,
+ ).toBe("request.opened");
+ expect(
+ decodePluginMessage({
+ type: "request.resolved",
+ requestId: "approval-1",
+ requestType: "command_execution_approval",
+ decision: "accept",
+ ...turnContext,
+ }).type,
+ ).toBe("request.resolved");
+ expect(
+ decodePluginMessage({
+ type: "user-input.requested",
+ requestId: "question-1",
+ questions: [
+ {
+ id: "environment",
+ header: "Target",
+ question: "Which environment?",
+ options: [
+ {
+ label: "Staging",
+ description: "Deploy to the staging environment.",
+ },
+ ],
+ },
+ ],
+ ...turnContext,
+ }).type,
+ ).toBe("user-input.requested");
+ expect(
+ decodePluginMessage({
+ type: "user-input.resolved",
+ requestId: "question-1",
+ answers: { environment: "Staging" },
+ ...turnContext,
+ }).type,
+ ).toBe("user-input.resolved");
+ });
+});
+
+describe("Hermes provider integration constants", () => {
+ it("exposes Hermes as a single opaque model in the normal provider picker", () => {
+ expect(DEFAULT_MODEL_BY_PROVIDER[HERMES_DRIVER_KIND]).toBe(DEFAULT_HERMES_MODEL);
+ expect(PROVIDER_DISPLAY_NAMES[HERMES_DRIVER_KIND]).toBe("Hermes Agent");
+ });
+
+ it("keeps Hermes ACP enabled without companion configuration", () => {
+ expect(decodeHermesSettings({})).toEqual({
+ enabled: true,
+ binaryPath: "hermes-acp",
+ customModels: [],
+ });
+ expect(DEFAULT_SERVER_SETTINGS.providers.hermes).toEqual({
+ enabled: true,
+ binaryPath: "hermes-acp",
+ customModels: [],
+ });
+ });
+
+ it("registers the web-management RPC method names", () => {
+ expect(WS_METHODS.hermesGatewayCreateEnrollment).toBe("hermesGateway.createEnrollment");
+ expect(WS_METHODS.hermesGatewayGetInstanceStatus).toBe("hermesGateway.getInstanceStatus");
+ expect(WS_METHODS.hermesGatewayListInstances).toBe("hermesGateway.listInstances");
+ expect(WS_METHODS.hermesGatewayRevokeInstance).toBe("hermesGateway.revokeInstance");
+ });
+});
diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts
new file mode 100644
index 000000000000..211a02c65ab8
--- /dev/null
+++ b/packages/contracts/src/hermesGateway.ts
@@ -0,0 +1,908 @@
+/**
+ * Versioned contracts for the T3 Code gateway plugin hosted by Hermes.
+ *
+ * The web-management schemas are intentionally separate from the plugin wire
+ * protocol. Browser clients may receive one-time enrollment tokens, but never
+ * the persistent credential issued directly to the plugin after enrollment.
+ *
+ * @module hermesGateway
+ */
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+import {
+ IsoDateTime,
+ NonNegativeInt,
+ PositiveInt,
+ ThreadId,
+ TrimmedNonEmptyString,
+ TurnId,
+} from "./baseSchemas.ts";
+import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestration.ts";
+import { ProviderInstanceId } from "./providerInstance.ts";
+import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
+
+export const HERMES_GATEWAY_PROTOCOL_VERSION = 4 as const;
+
+/**
+ * Base64 payload ceiling for a single media frame, both directions.
+ *
+ * 25MiB of raw bytes is ~34MiB of base64; the schema bound is on the encoded
+ * string so an oversized frame fails at decode rather than after buffering.
+ * Deliberately no chunking protocol — a file that does not fit does not
+ * send, with a clear error. Chunking is the escape hatch if that ceiling
+ * ever genuinely hurts.
+ */
+export const HERMES_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
+const HERMES_MEDIA_MAX_BASE64_CHARS = Math.ceil(HERMES_MEDIA_MAX_BYTES / 3) * 4 + 4;
+
+export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
+export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
+
+export const HermesGatewayRequestId = TrimmedNonEmptyString.pipe(
+ Schema.brand("HermesGatewayRequestId"),
+);
+export type HermesGatewayRequestId = typeof HermesGatewayRequestId.Type;
+
+/**
+ * An opaque identifier owned entirely by Hermes. T3 persists and echoes it,
+ * but must not derive routing or other semantics from its contents.
+ */
+export const HermesGatewaySessionId = TrimmedNonEmptyString.pipe(
+ Schema.brand("HermesGatewaySessionId"),
+);
+export type HermesGatewaySessionId = typeof HermesGatewaySessionId.Type;
+
+export const HermesGatewayResumeCursor = Schema.Struct({
+ protocolVersion: HermesGatewayProtocolVersion,
+ sessionId: HermesGatewaySessionId,
+});
+export type HermesGatewayResumeCursor = typeof HermesGatewayResumeCursor.Type;
+
+export const HermesGatewayItemId = TrimmedNonEmptyString.pipe(Schema.brand("HermesGatewayItemId"));
+export type HermesGatewayItemId = typeof HermesGatewayItemId.Type;
+
+export const HermesGatewayEnrollmentToken = TrimmedNonEmptyString.pipe(
+ Schema.brand("HermesGatewayEnrollmentToken"),
+);
+export type HermesGatewayEnrollmentToken = typeof HermesGatewayEnrollmentToken.Type;
+
+export const HermesGatewayCredential = TrimmedNonEmptyString.pipe(
+ Schema.brand("HermesGatewayCredential"),
+);
+export type HermesGatewayCredential = typeof HermesGatewayCredential.Type;
+
+export const HermesGatewayNickname = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
+export type HermesGatewayNickname = typeof HermesGatewayNickname.Type;
+
+/**
+ * T3 accepts ordinary HTTP(S) URLs because the plugin command may normalize
+ * them to WebSocket URLs, as well as explicit WS(S) connector URLs.
+ */
+export const HermesGatewayConnectorUrl = TrimmedNonEmptyString.check(
+ Schema.isMaxLength(2_048),
+ Schema.isPattern(/^(?:https?|wss?):\/\/\S+$/i),
+);
+export type HermesGatewayConnectorUrl = typeof HermesGatewayConnectorUrl.Type;
+
+export const HermesGatewayCapabilities = Schema.Struct({
+ protocolVersion: HermesGatewayProtocolVersion,
+ streaming: Schema.Boolean,
+ activity: Schema.Boolean,
+ approvals: Schema.Boolean,
+ userInput: Schema.Boolean,
+ // Literal by design: attachments are part of the v4 contract itself, not a
+ // negotiated option. A plugin speaking v4 must handle them; one that cannot
+ // is a v3 plugin and is rejected at the version gate.
+ attachments: Schema.Literal(true),
+});
+export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type;
+
+/**
+ * Capability advertisement accepted at the initial handshake boundary.
+ *
+ * This deliberately permits capability shapes from a newer protocol so T3 can
+ * return a structured `version-incompatible` rejection instead of failing the
+ * WebSocket frame decoder. Accepted connections must subsequently validate
+ * this advertisement with `HermesGatewayCapabilities`.
+ */
+export const HermesGatewayHelloCapabilities = Schema.Struct({
+ protocolVersion: PositiveInt,
+ streaming: Schema.Boolean,
+ activity: Schema.Boolean,
+ approvals: Schema.Boolean,
+ userInput: Schema.Boolean,
+ attachments: Schema.Boolean,
+});
+export type HermesGatewayHelloCapabilities = typeof HermesGatewayHelloCapabilities.Type;
+
+export const HermesGatewayConnectionState = Schema.Literals([
+ "offline",
+ "connecting",
+ "connected",
+ "upgrade-required",
+ "revoked",
+]);
+export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.Type;
+
+/**
+ * Public instance state used by settings and provider-picker surfaces.
+ *
+ * `protocolVersion` is not restricted to v4 here so the UI can report the
+ * unsupported version observed from a plugin that needs an upgrade.
+ */
+export const HermesGatewayInstanceStatus = Schema.Struct({
+ instanceId: ProviderInstanceId,
+ nickname: HermesGatewayNickname,
+ status: HermesGatewayConnectionState,
+ connectorUrl: HermesGatewayConnectorUrl,
+ lastConnectedAt: Schema.NullOr(IsoDateTime),
+ pluginVersion: Schema.NullOr(TrimmedNonEmptyString),
+ hermesVersion: Schema.NullOr(TrimmedNonEmptyString),
+ /**
+ * The model the connected plugin reported at handshake, surfaced so the
+ * provider picker can name the model Hermes actually runs instead of a
+ * placeholder. Null when no plugin has connected yet, or when the connected
+ * plugin predates the `model` field on `connection.hello`.
+ */
+ model: Schema.NullOr(TrimmedNonEmptyString),
+ /**
+ * Monotonic id of the underlying connection, or null while offline.
+ *
+ * Consumers must key "this is a different plugin process now" off this
+ * rather than off `status` transitioning through `offline`. A replacement —
+ * the old socket dying as a new one is accepted — publishes a single
+ * `connected` status, so a connectedness edge detector never fires and
+ * anything that must be re-established per connection (notably
+ * `session.ensure`) is silently skipped.
+ */
+ connectionGeneration: Schema.NullOr(NonNegativeInt),
+ activeSessionCount: NonNegativeInt,
+ protocolVersion: Schema.NullOr(PositiveInt),
+ capabilities: Schema.NullOr(HermesGatewayCapabilities),
+});
+export type HermesGatewayInstanceStatus = typeof HermesGatewayInstanceStatus.Type;
+
+export const HermesGatewayCreateEnrollmentInput = Schema.Struct({
+ instanceId: ProviderInstanceId,
+ nickname: HermesGatewayNickname,
+ connectorUrl: HermesGatewayConnectorUrl,
+});
+export type HermesGatewayCreateEnrollmentInput = typeof HermesGatewayCreateEnrollmentInput.Type;
+
+/**
+ * Returned exactly once to the web client. The long-lived plugin credential
+ * is intentionally absent and is delivered only over the authenticated
+ * enrollment socket.
+ */
+export const HermesGatewayEnrollmentResult = Schema.Struct({
+ instanceId: ProviderInstanceId,
+ expiresAt: IsoDateTime,
+ connectorUrl: HermesGatewayConnectorUrl,
+ command: TrimmedNonEmptyString,
+ oneTimeToken: HermesGatewayEnrollmentToken,
+});
+export type HermesGatewayEnrollmentResult = typeof HermesGatewayEnrollmentResult.Type;
+
+export const HermesGatewayListInstancesResult = Schema.Array(HermesGatewayInstanceStatus);
+export type HermesGatewayListInstancesResult = typeof HermesGatewayListInstancesResult.Type;
+
+export const HermesGatewayGetInstanceStatusInput = Schema.Struct({
+ instanceId: ProviderInstanceId,
+});
+export type HermesGatewayGetInstanceStatusInput = typeof HermesGatewayGetInstanceStatusInput.Type;
+
+export const HermesGatewayRenameInstanceInput = Schema.Struct({
+ instanceId: ProviderInstanceId,
+ nickname: HermesGatewayNickname,
+});
+export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type;
+
+export const HermesGatewayRenameInstanceResult = HermesGatewayInstanceStatus;
+export type HermesGatewayRenameInstanceResult = typeof HermesGatewayRenameInstanceResult.Type;
+
+export const HermesGatewayRevokeInstanceInput = Schema.Struct({
+ instanceId: ProviderInstanceId,
+});
+export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanceInput.Type;
+
+export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus;
+export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type;
+
+export const HermesGatewayRemoveInstanceInput = Schema.Struct({
+ instanceId: ProviderInstanceId,
+});
+export type HermesGatewayRemoveInstanceInput = typeof HermesGatewayRemoveInstanceInput.Type;
+
+export const HermesGatewayRemoveInstanceResult = Schema.Struct({
+ instanceId: ProviderInstanceId,
+});
+export type HermesGatewayRemoveInstanceResult = typeof HermesGatewayRemoveInstanceResult.Type;
+
+export const HermesGatewayManagementOperation = Schema.Literals([
+ "create-enrollment",
+ "get-status",
+ "list-instances",
+ "rename-instance",
+ "revoke-instance",
+ "remove-instance",
+]);
+export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type;
+
+export const HermesGatewayManagementErrorCode = Schema.Literals([
+ "instance-not-found",
+ "nickname-conflict",
+ "invalid-connector-url",
+ "instance-revoked",
+ "instance-removed",
+ "instance-not-revoked",
+ "persistence-failed",
+ "internal-error",
+]);
+export type HermesGatewayManagementErrorCode = typeof HermesGatewayManagementErrorCode.Type;
+
+export class HermesGatewayManagementError extends Schema.TaggedErrorClass()(
+ "HermesGatewayManagementError",
+ {
+ operation: HermesGatewayManagementOperation,
+ code: HermesGatewayManagementErrorCode,
+ message: TrimmedNonEmptyString,
+ instanceId: Schema.optional(ProviderInstanceId),
+ },
+) {}
+
+const HermesGatewayEnrollmentAuthentication = Schema.Struct({
+ type: Schema.Literal("enrollment-token"),
+ token: HermesGatewayEnrollmentToken,
+});
+export type HermesGatewayEnrollmentAuthentication =
+ typeof HermesGatewayEnrollmentAuthentication.Type;
+
+const HermesGatewayCredentialAuthentication = Schema.Struct({
+ type: Schema.Literal("instance-credential"),
+ instanceId: ProviderInstanceId,
+ credential: HermesGatewayCredential,
+});
+export type HermesGatewayCredentialAuthentication =
+ typeof HermesGatewayCredentialAuthentication.Type;
+
+export const HermesGatewayAuthentication = Schema.Union([
+ HermesGatewayEnrollmentAuthentication,
+ HermesGatewayCredentialAuthentication,
+]);
+export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type;
+
+/**
+ * What a connecting socket intends to be.
+ *
+ * `gateway` is the instance's one live plugin connection: registered under
+ * generation fencing, pinged for liveness, and displacing any predecessor.
+ * `delivery` is a short-lived socket — an out-of-process cron run dialing in
+ * only to hand over a `home.deliver` and leave. Delivery connections are
+ * authenticated identically but are never registered as the primary
+ * connection, so they cannot kick a healthy gateway socket off its instance.
+ */
+export const HermesGatewayConnectionRole = Schema.Literals(["gateway", "delivery"]);
+export type HermesGatewayConnectionRole = typeof HermesGatewayConnectionRole.Type;
+
+/**
+ * `protocolVersion` accepts any positive integer at the initial boundary so
+ * T3 can reject incompatible plugins with a structured upgrade response.
+ * Once accepted, all remaining frames use the literal current-version schema.
+ */
+export const HermesGatewayConnectionHello = Schema.Struct({
+ type: Schema.Literal("connection.hello"),
+ requestId: HermesGatewayRequestId,
+ protocolVersion: PositiveInt,
+ pluginVersion: TrimmedNonEmptyString,
+ hermesVersion: TrimmedNonEmptyString,
+ capabilities: HermesGatewayHelloCapabilities,
+ authentication: HermesGatewayAuthentication,
+ /**
+ * The model Hermes is configured to run, reported so T3 can show something
+ * truthful in the picker instead of a placeholder. Read-only — Hermes owns
+ * model selection, and T3 declares `sessionModelSwitch: "unsupported"`.
+ *
+ * Optional so a plugin that predates this field still connects: an absent
+ * value degrades to the generic label rather than failing the handshake.
+ */
+ model: Schema.optional(TrimmedNonEmptyString),
+ /**
+ * Defaults to `"gateway"` on decode so the field stays honest about intent
+ * rather than making every caller repeat the common case. v4 requires both
+ * sides updated regardless, so this default is ergonomics, not tolerance.
+ */
+ role: HermesGatewayConnectionRole.pipe(Schema.withDecodingDefault(Effect.succeed("gateway"))),
+});
+export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type;
+
+export const HermesGatewayConnectionAccepted = Schema.Struct({
+ type: Schema.Literal("connection.accepted"),
+ requestId: HermesGatewayRequestId,
+ protocolVersion: HermesGatewayProtocolVersion,
+ instanceId: ProviderInstanceId,
+ nickname: HermesGatewayNickname,
+ credential: Schema.optional(HermesGatewayCredential),
+ /**
+ * The instance's durable home thread — where Hermes' proactive output lands
+ * when nothing named a destination. Sent on every successful handshake so
+ * the plugin reconciles its `T3_HOME_CHANNEL` cache each connect; T3's
+ * settings blob is the authoritative designation.
+ *
+ * Optional because resolving it must never fail a handshake: if the thread
+ * could not be created this connect, the plugin keeps whatever it had and
+ * reconciles on the next one.
+ */
+ homeThreadId: Schema.optional(ThreadId),
+});
+export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type;
+
+export const HermesGatewayConnectionRejectionCode = Schema.Literals([
+ "invalid-authentication",
+ "enrollment-expired",
+ "instance-revoked",
+ "version-incompatible",
+ "internal-error",
+]);
+export type HermesGatewayConnectionRejectionCode = typeof HermesGatewayConnectionRejectionCode.Type;
+
+export const HermesGatewayConnectionRejected = Schema.Struct({
+ type: Schema.Literal("connection.rejected"),
+ requestId: HermesGatewayRequestId,
+ code: HermesGatewayConnectionRejectionCode,
+ message: TrimmedNonEmptyString,
+ expectedProtocolVersion: HermesGatewayProtocolVersion,
+});
+export type HermesGatewayConnectionRejected = typeof HermesGatewayConnectionRejected.Type;
+
+export const HermesGatewayConnectionStatus = Schema.Struct({
+ type: Schema.Literal("connection.status"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ activeSessionCount: NonNegativeInt,
+});
+export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type;
+
+const HermesGatewaySessionContext = Schema.Struct({
+ threadId: ThreadId,
+ sessionId: HermesGatewaySessionId,
+});
+
+const HermesGatewayTurnContext = Schema.Struct({
+ ...HermesGatewaySessionContext.fields,
+ turnId: TurnId,
+});
+
+const HermesGatewayTurnText = Schema.String.check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(120_000),
+);
+
+/**
+ * A file riding a turn frame toward the plugin. Inline base64 on the frame
+ * itself: no side-channel fetch (the plugin may be on another machine with
+ * no authenticated route back), no chunking. The adapter enforces the
+ * per-turn total; the schema bounds each file.
+ */
+export const HermesGatewayTurnAttachment = Schema.Struct({
+ name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
+ mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
+ sizeBytes: PositiveInt,
+ data: Schema.String.check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(HERMES_MEDIA_MAX_BASE64_CHARS),
+ ),
+});
+export type HermesGatewayTurnAttachment = typeof HermesGatewayTurnAttachment.Type;
+
+export const HermesGatewaySessionEnsure = Schema.Struct({
+ type: Schema.Literal("session.ensure"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ threadId: ThreadId,
+ resumeSessionId: Schema.optional(HermesGatewaySessionId),
+});
+export type HermesGatewaySessionEnsure = typeof HermesGatewaySessionEnsure.Type;
+
+export const HermesGatewayTurnStart = Schema.Struct({
+ type: Schema.Literal("turn.start"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ ...HermesGatewayTurnContext.fields,
+ text: HermesGatewayTurnText,
+ attachments: Schema.optional(Schema.Array(HermesGatewayTurnAttachment)),
+});
+export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type;
+
+export const HermesGatewayTurnSteer = Schema.Struct({
+ type: Schema.Literal("turn.steer"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ ...HermesGatewayTurnContext.fields,
+ text: HermesGatewayTurnText,
+ attachments: Schema.optional(Schema.Array(HermesGatewayTurnAttachment)),
+});
+export type HermesGatewayTurnSteer = typeof HermesGatewayTurnSteer.Type;
+
+export const HermesGatewayTurnInterrupt = Schema.Struct({
+ type: Schema.Literal("turn.interrupt"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ ...HermesGatewayTurnContext.fields,
+});
+export type HermesGatewayTurnInterrupt = typeof HermesGatewayTurnInterrupt.Type;
+
+export const HermesGatewayApprovalResponse = Schema.Struct({
+ type: Schema.Literal("approval.respond"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ requestId: HermesGatewayRequestId,
+ decision: ProviderApprovalDecision,
+});
+export type HermesGatewayApprovalResponse = typeof HermesGatewayApprovalResponse.Type;
+
+export const HermesGatewayUserInputResponse = Schema.Struct({
+ type: Schema.Literal("user-input.respond"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ requestId: HermesGatewayRequestId,
+ answers: ProviderUserInputAnswers,
+});
+export type HermesGatewayUserInputResponse = typeof HermesGatewayUserInputResponse.Type;
+
+export const HermesGatewaySessionStop = Schema.Struct({
+ type: Schema.Literal("session.stop"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ ...HermesGatewaySessionContext.fields,
+});
+export type HermesGatewaySessionStop = typeof HermesGatewaySessionStop.Type;
+
+/**
+ * Ask a connected plugin to describe the agent it fronts — versions, model,
+ * reasoning effort, and installed skills. Backs the Agent page.
+ */
+export const HermesGatewayDescribeRequest = Schema.Struct({
+ type: Schema.Literal("describe.request"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+});
+export type HermesGatewayDescribeRequest = typeof HermesGatewayDescribeRequest.Type;
+
+/** Ask for one skill's markdown body. Fired on row expand, never eagerly. */
+export const HermesGatewaySkillBodyRequest = Schema.Struct({
+ type: Schema.Literal("skill.body.request"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ skillName: TrimmedNonEmptyString,
+});
+export type HermesGatewaySkillBodyRequest = typeof HermesGatewaySkillBodyRequest.Type;
+
+export const HermesGatewayPing = Schema.Struct({
+ type: Schema.Literal("ping"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ sentAt: IsoDateTime,
+});
+export type HermesGatewayPing = typeof HermesGatewayPing.Type;
+
+export const HermesGatewaySessionReady = Schema.Struct({
+ type: Schema.Literal("session.ready"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ threadId: ThreadId,
+ sessionId: HermesGatewaySessionId,
+ resumed: Schema.Boolean,
+ activeTurnId: Schema.optional(TurnId),
+});
+export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type;
+
+export const HermesGatewayTurnStarted = Schema.Struct({
+ type: Schema.Literal("turn.started"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ ...HermesGatewayTurnContext.fields,
+});
+export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type;
+
+export const HermesGatewayContentStreamKind = Schema.Literals([
+ "assistant_text",
+ "reasoning_text",
+ "reasoning_summary_text",
+ "plan_text",
+ "command_output",
+ "unknown",
+]);
+export type HermesGatewayContentStreamKind = typeof HermesGatewayContentStreamKind.Type;
+
+export const HermesGatewayContentDelta = Schema.Struct({
+ type: Schema.Literal("content.delta"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ itemId: Schema.optional(HermesGatewayItemId),
+ streamKind: HermesGatewayContentStreamKind,
+ delta: Schema.String,
+ contentIndex: Schema.optional(NonNegativeInt),
+});
+export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type;
+
+export const HermesGatewayContentSnapshot = Schema.Struct({
+ type: Schema.Literal("content.snapshot"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ itemId: Schema.optional(HermesGatewayItemId),
+ streamKind: HermesGatewayContentStreamKind,
+ text: Schema.String,
+ contentIndex: Schema.optional(NonNegativeInt),
+});
+export type HermesGatewayContentSnapshot = typeof HermesGatewayContentSnapshot.Type;
+
+export const HermesGatewayItemStatus = Schema.Literals([
+ "inProgress",
+ "completed",
+ "failed",
+ "declined",
+]);
+export type HermesGatewayItemStatus = typeof HermesGatewayItemStatus.Type;
+
+const HermesGatewayItemLifecycleFields = {
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ itemId: HermesGatewayItemId,
+ itemType: CanonicalItemType,
+ status: Schema.optional(HermesGatewayItemStatus),
+ title: Schema.optional(TrimmedNonEmptyString),
+ detail: Schema.optional(TrimmedNonEmptyString),
+ data: Schema.optional(Schema.Unknown),
+};
+
+export const HermesGatewayItemStarted = Schema.Struct({
+ type: Schema.Literal("item.started"),
+ ...HermesGatewayItemLifecycleFields,
+});
+export type HermesGatewayItemStarted = typeof HermesGatewayItemStarted.Type;
+
+export const HermesGatewayItemUpdated = Schema.Struct({
+ type: Schema.Literal("item.updated"),
+ ...HermesGatewayItemLifecycleFields,
+});
+export type HermesGatewayItemUpdated = typeof HermesGatewayItemUpdated.Type;
+
+export const HermesGatewayItemCompleted = Schema.Struct({
+ type: Schema.Literal("item.completed"),
+ ...HermesGatewayItemLifecycleFields,
+});
+export type HermesGatewayItemCompleted = typeof HermesGatewayItemCompleted.Type;
+
+const HermesGatewayInteractionContext = Schema.Struct({
+ ...HermesGatewayTurnContext.fields,
+ requestId: HermesGatewayRequestId,
+});
+
+export const HermesGatewayRequestOpened = Schema.Struct({
+ type: Schema.Literal("request.opened"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayInteractionContext.fields,
+ requestType: CanonicalRequestType,
+ detail: Schema.optional(TrimmedNonEmptyString),
+ args: Schema.optional(Schema.Unknown),
+});
+export type HermesGatewayRequestOpened = typeof HermesGatewayRequestOpened.Type;
+
+export const HermesGatewayRequestResolved = Schema.Struct({
+ type: Schema.Literal("request.resolved"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayInteractionContext.fields,
+ requestType: CanonicalRequestType,
+ decision: Schema.optional(TrimmedNonEmptyString),
+ resolution: Schema.optional(Schema.Unknown),
+});
+export type HermesGatewayRequestResolved = typeof HermesGatewayRequestResolved.Type;
+
+export const HermesGatewayUserInputRequested = Schema.Struct({
+ type: Schema.Literal("user-input.requested"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayInteractionContext.fields,
+ questions: Schema.Array(UserInputQuestion),
+});
+export type HermesGatewayUserInputRequested = typeof HermesGatewayUserInputRequested.Type;
+
+export const HermesGatewayUserInputResolved = Schema.Struct({
+ type: Schema.Literal("user-input.resolved"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayInteractionContext.fields,
+ answers: ProviderUserInputAnswers,
+});
+export type HermesGatewayUserInputResolved = typeof HermesGatewayUserInputResolved.Type;
+
+export const HermesGatewayTurnCompletionState = Schema.Literals(["completed", "failed"]);
+export type HermesGatewayTurnCompletionState = typeof HermesGatewayTurnCompletionState.Type;
+
+export const HermesGatewayTurnCompleted = Schema.Struct({
+ type: Schema.Literal("turn.completed"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ state: HermesGatewayTurnCompletionState,
+ stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ usage: Schema.optional(Schema.Unknown),
+ errorMessage: Schema.optional(TrimmedNonEmptyString),
+});
+export type HermesGatewayTurnCompleted = typeof HermesGatewayTurnCompleted.Type;
+
+export const HermesGatewayTurnAborted = Schema.Struct({
+ type: Schema.Literal("turn.aborted"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewayTurnContext.fields,
+ reason: TrimmedNonEmptyString,
+});
+export type HermesGatewayTurnAborted = typeof HermesGatewayTurnAborted.Type;
+
+export const HermesGatewaySessionExited = Schema.Struct({
+ type: Schema.Literal("session.exited"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ ...HermesGatewaySessionContext.fields,
+ reason: Schema.optional(TrimmedNonEmptyString),
+ recoverable: Schema.Boolean,
+});
+export type HermesGatewaySessionExited = typeof HermesGatewaySessionExited.Type;
+
+/**
+ * One skill as the plugin reports it.
+ *
+ * `source` is Hermes' category, the closest thing its public skills surface
+ * publishes to an install source — there is no on-disk path in that surface,
+ * so T3 must not expect one. Optional fields are *omitted* by the plugin when
+ * unreadable rather than sent as null.
+ */
+export const HermesGatewayDescribedSkill = Schema.Struct({
+ name: TrimmedNonEmptyString,
+ description: Schema.optional(TrimmedNonEmptyString),
+ source: Schema.optional(TrimmedNonEmptyString),
+ enabled: Schema.Boolean,
+});
+export type HermesGatewayDescribedSkill = typeof HermesGatewayDescribedSkill.Type;
+
+export const HermesGatewayDescribeResponse = Schema.Struct({
+ type: Schema.Literal("describe.response"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ pluginVersion: TrimmedNonEmptyString,
+ hermesVersion: TrimmedNonEmptyString,
+ capabilities: HermesGatewayHelloCapabilities,
+ // Optional on the wire: the plugin omits what it could not read from Hermes
+ // so T3 falls back to its own generic labels instead of rendering an empty
+ // value as if it were reported.
+ model: Schema.optional(TrimmedNonEmptyString),
+ reasoningEffort: Schema.optional(TrimmedNonEmptyString),
+ skills: Schema.Array(HermesGatewayDescribedSkill),
+ describedAt: IsoDateTime,
+});
+export type HermesGatewayDescribeResponse = typeof HermesGatewayDescribeResponse.Type;
+
+export const HermesGatewaySkillBodyResponse = Schema.Struct({
+ type: Schema.Literal("skill.body.response"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ skillName: TrimmedNonEmptyString,
+ // Explicitly nullable, unlike the omit-on-failure fields above: the request
+ // named a skill, so the caller must be able to tell "nothing to show for
+ // this one" apart from a reply that never arrived.
+ markdown: Schema.NullOr(Schema.String),
+});
+export type HermesGatewaySkillBodyResponse = typeof HermesGatewaySkillBodyResponse.Type;
+
+export const HermesGatewayPong = Schema.Struct({
+ type: Schema.Literal("pong"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ sentAt: IsoDateTime,
+});
+export type HermesGatewayPong = typeof HermesGatewayPong.Type;
+
+/**
+ * Plugin-minted, stable across retries. T3 dedupes on it, which is what makes
+ * the plugin's queue safe to flush more than once.
+ */
+export const HermesGatewayDeliveryId = TrimmedNonEmptyString.pipe(
+ Schema.brand("HermesGatewayDeliveryId"),
+);
+export type HermesGatewayDeliveryId = typeof HermesGatewayDeliveryId.Type;
+
+/**
+ * What produced a home delivery. Drives both the rendered badge and whether
+ * the delivery raises its hand: everything except `lifecycle` un-settles the
+ * thread and pushes; gateway online/shutdown notices land quietly.
+ *
+ * Classification is best-effort on the plugin side — Hermes' `adapter.send()`
+ * contract carries no structured provenance marker on every path — so a
+ * misclassification costs a wrong badge, never a lost delivery.
+ */
+export const HermesGatewayHomeDeliveryKind = Schema.Literals([
+ "cron",
+ "message",
+ "lifecycle",
+ "handoff",
+ "other",
+]);
+export type HermesGatewayHomeDeliveryKind = typeof HermesGatewayHomeDeliveryKind.Type;
+
+/**
+ * Hermes-initiated delivery into the instance's home thread.
+ *
+ * Deliberately not a turn: there is no provider session, no turn id, and no
+ * request the delivery answers. A delivery may arrive while the home thread
+ * has a live user turn and must not disturb it.
+ */
+export const HermesGatewayHomeDeliver = Schema.Struct({
+ type: Schema.Literal("home.deliver"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ deliveryId: HermesGatewayDeliveryId,
+ threadId: ThreadId,
+ kind: HermesGatewayHomeDeliveryKind,
+ /** Human source label rendered as the badge — "Cron: daily-digest". */
+ label: TrimmedNonEmptyString.check(Schema.isMaxLength(200), Schema.isPattern(/^[^\r\n]*$/)),
+ text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(120_000)),
+ /**
+ * When Hermes produced the content, not when it reached T3. These diverge
+ * whenever a queued delivery flushes after a reconnect.
+ */
+ createdAt: IsoDateTime,
+});
+export type HermesGatewayHomeDeliver = typeof HermesGatewayHomeDeliver.Type;
+
+/**
+ * Sent only after the delivery is durably written. The plugin purges its
+ * queued copy on this frame and nothing else, so acking early loses messages.
+ */
+export const HermesGatewayHomeDeliverAck = Schema.Struct({
+ type: Schema.Literal("home.deliver.ack"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ deliveryId: HermesGatewayDeliveryId,
+});
+export type HermesGatewayHomeDeliverAck = typeof HermesGatewayHomeDeliverAck.Type;
+
+/**
+ * Hermes-initiated media (an image, video, PDF, or arbitrary file) delivered
+ * as its own message rather than folded into a streaming turn.
+ *
+ * Shaped like `home.deliver` on purpose: self-contained, idempotent on
+ * `deliveryId`, acked only after the bytes are durably written, so the
+ * plugin's queued copy survives every disconnect between send and ack.
+ *
+ * Scope is carried by which ids are present:
+ * - `turnId` set — media produced during a live turn; lands in that thread
+ * sequenced next to the turn's text.
+ * - `turnId` absent — proactive media (a cron job's chart, an artifact from
+ * an agent-initiated task). `threadId` is advisory the same way it is for
+ * `home.deliver`: the server re-resolves the instance's home thread and
+ * refuses to write anywhere else, so a confused plugin cannot spray files
+ * into arbitrary threads. `kind`/`label` provenance renders the same
+ * notification header a text delivery gets.
+ */
+export const HermesGatewayMediaDeliver = Schema.Struct({
+ type: Schema.Literal("media.deliver"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ deliveryId: HermesGatewayDeliveryId,
+ threadId: ThreadId,
+ turnId: Schema.optional(TurnId),
+ kind: HermesGatewayHomeDeliveryKind,
+ /** Human source label rendered as the badge — "Cron: daily-digest". */
+ label: TrimmedNonEmptyString.check(Schema.isMaxLength(200), Schema.isPattern(/^[^\r\n]*$/)),
+ name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
+ mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
+ sizeBytes: PositiveInt,
+ /** Optional caption rendered under the media in the same message row. */
+ caption: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))),
+ data: Schema.String.check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(HERMES_MEDIA_MAX_BASE64_CHARS),
+ ),
+ /** When Hermes produced the media, not when it reached T3. */
+ createdAt: IsoDateTime,
+});
+export type HermesGatewayMediaDeliver = typeof HermesGatewayMediaDeliver.Type;
+
+/**
+ * Sent only after the media's bytes and its message row are durably written —
+ * the same pessimistic-ack contract as `home.deliver.ack`.
+ */
+export const HermesGatewayMediaDeliverAck = Schema.Struct({
+ type: Schema.Literal("media.deliver.ack"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ deliveryId: HermesGatewayDeliveryId,
+});
+export type HermesGatewayMediaDeliverAck = typeof HermesGatewayMediaDeliverAck.Type;
+
+/**
+ * Hermes' public `BasePlatformAdapter.create_handoff_thread` callback asking
+ * T3 to create the fresh destination required by `/handoff`.
+ */
+export const HermesGatewayHandoffCreate = Schema.Struct({
+ type: Schema.Literal("handoff.create"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ parentThreadId: ThreadId,
+ name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
+});
+export type HermesGatewayHandoffCreate = typeof HermesGatewayHandoffCreate.Type;
+
+/** Correlated result of `handoff.create`. */
+export const HermesGatewayHandoffCreated = Schema.Struct({
+ type: Schema.Literal("handoff.created"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: HermesGatewayRequestId,
+ threadId: ThreadId,
+});
+export type HermesGatewayHandoffCreated = typeof HermesGatewayHandoffCreated.Type;
+
+export const HermesGatewayProtocolErrorCode = Schema.Literals([
+ "invalid-message",
+ "unsupported-message",
+ "session-not-found",
+ "turn-not-active",
+ "request-not-found",
+ "internal-error",
+]);
+export type HermesGatewayProtocolErrorCode = typeof HermesGatewayProtocolErrorCode.Type;
+
+export const HermesGatewayProtocolError = Schema.Struct({
+ type: Schema.Literal("protocol.error"),
+ protocolVersion: HermesGatewayProtocolVersion,
+ requestId: Schema.optional(HermesGatewayRequestId),
+ code: HermesGatewayProtocolErrorCode,
+ message: TrimmedNonEmptyString,
+ recoverable: Schema.Boolean,
+});
+export type HermesGatewayProtocolError = typeof HermesGatewayProtocolError.Type;
+
+export const HermesGatewayT3ToPluginMessage = Schema.Union([
+ HermesGatewayConnectionAccepted,
+ HermesGatewayConnectionRejected,
+ HermesGatewaySessionEnsure,
+ HermesGatewayTurnStart,
+ HermesGatewayTurnSteer,
+ HermesGatewayTurnInterrupt,
+ HermesGatewayApprovalResponse,
+ HermesGatewayUserInputResponse,
+ HermesGatewaySessionStop,
+ HermesGatewayDescribeRequest,
+ HermesGatewaySkillBodyRequest,
+ HermesGatewayPing,
+ HermesGatewayHomeDeliverAck,
+ HermesGatewayMediaDeliverAck,
+ HermesGatewayHandoffCreated,
+ HermesGatewayProtocolError,
+]);
+export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type;
+
+export const HermesGatewayPluginToT3Message = Schema.Union([
+ HermesGatewayConnectionHello,
+ HermesGatewayConnectionStatus,
+ HermesGatewaySessionReady,
+ HermesGatewayTurnStarted,
+ HermesGatewayContentDelta,
+ HermesGatewayContentSnapshot,
+ HermesGatewayItemStarted,
+ HermesGatewayItemUpdated,
+ HermesGatewayItemCompleted,
+ HermesGatewayRequestOpened,
+ HermesGatewayRequestResolved,
+ HermesGatewayUserInputRequested,
+ HermesGatewayUserInputResolved,
+ HermesGatewayTurnCompleted,
+ HermesGatewayTurnAborted,
+ HermesGatewaySessionExited,
+ HermesGatewayDescribeResponse,
+ HermesGatewaySkillBodyResponse,
+ HermesGatewayPong,
+ HermesGatewayProtocolError,
+ HermesGatewayHomeDeliver,
+ HermesGatewayMediaDeliver,
+ HermesGatewayHandoffCreate,
+]);
+export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type;
+
+export const HermesGatewayWireMessage = Schema.Union([
+ HermesGatewayT3ToPluginMessage,
+ HermesGatewayPluginToT3Message,
+]);
+export type HermesGatewayWireMessage = typeof HermesGatewayWireMessage.Type;
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index c6daef8687ba..8603657e1dcd 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -31,3 +31,5 @@ export * from "./previewAutomation.ts";
export * from "./resourceTelemetry.ts";
export * from "./usage.ts";
export * from "./rpc.ts";
+
+export * from "./hermesGateway.ts";
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 0e3f93108b58..661c26978699 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,11 +132,13 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const DROID_DRIVER_KIND = ProviderDriverKind.make("droid");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
-const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes");
+export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
const PI_DRIVER_KIND = ProviderDriverKind.make("pi");
export const DEFAULT_MODEL = "gpt-5.6-sol";
+/** Stable ACP model slug for synthetic Hermes Home threads. */
+export const DEFAULT_HERMES_MODEL = "default";
/**
* Codex default-model preference, most preferred first. The provider snapshot
@@ -156,6 +158,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial