Skip to main content
The channel-emulation harness (under test/live/) drives Comis end-to-end through the real channel adapter — the production Telegram adapter, unmodified — against an isolated, $0, fully offline daemon. It exists to exercise the surfaces the chat-API test path cannot reach: the delivery layer, inbound media routing, callbacks and edits, the adapter resilience fallbacks, group/forum addressing, and reactions as the Verified-Learning outcome signal. The integration relies on a single seam already present in the product — the channel adapter’s apiRoot / baseUrl redirect — so the harness points the real adapter at an in-process emulator instead of the live Bot API. No production code changes are needed to run it; the framework, the emulator, the rig, and the chan/tg CLI all live under the test tree.
This is a developer / contributor tool, not an operator feature. Nothing here ships in the published comisai package — the harness is forbidden from bundledDependencies and is never a comis CLI subcommand (see the harness never ships, below). Everything it touches in @comis/* is consumed from each package’s built dist/, so run pnpm build first — a stale dist/ silently masks src/ changes.

Anatomy

There are two ways to drive the rig: the in-process startRig / buildRig path (a vitest scenario boots the emulator and daemon inside the test process — the certified default), and the chan/tg CLI (a thin client that talks to a running rig over its recorded endpoints). The CLI also drives a detached rig shell-only and unattended across separate processes — see Driving from a cold shell.

The in-process path: startRig / buildRig

The strongest, most deterministic path. A scenario boots the emulator + an isolated daemon in-process, drives it, and asserts against ground truth — no separate shell, no human step.
Always run test/live/ suites with the live config. A bare pnpm vitest run test/live/... resolves the root vitest.config.ts, whose projects exclude test/live/ — so it collects 0 files and exits 0, a silent false green. Every run (and every TDD RED) MUST pass -c test/live/vitest.config.ts and confirm the file actually RAN (N passed / N failed, never 0 files):

Stage-B vs Stage-C

Every scenario splits on const isLive = !!process.env["COMIS_LIVE"]:
  • Stage-B (always runs, in-process, no model) — the deterministic wiring proof. Drives the real exported product function or the bare adapter against the emulator-built shape: group/forum mapping, the General-Topic thread asymmetry, the four outbound fallbacks under fault injection, error classification, Tier-3 platform actions, the forum-service negative. CI-able, offline, needs no provider keys.
  • Stage-C (describe.skipIf(!isLive), operator-gated) — the agent-content legs against a real keyless model on localhost:11434 (ollama). A full-daemon reply, the Verified-Learning A-then-B reaction loop, a DAG pipeline, the injection-gauntlet sweep. An operator runs these with COMIS_LIVE=1; offline they skip, never fail.

The chan / tg CLI

chan is the standalone shell/agent driver for the channel surface; tg is the alias for chan --channel telegram. It is a thin tsx client over the three surfaces a running rig records in its handle file: the emulator /control/* API (the drive verbs), the gateway /rpc over WebSocket (RPC passthrough + the curated explain / system + trigger), and the rig lifecycle. Invoke it through tsx (it is not a comis subcommand):
The load-bearing property is the no-false-success honest-exit contract: every verb accepts --json and exits non-zero, reason-coded on a no-reply timeout, an RPC error, a dead handle, or malformed JSON. A no-reply is an honest empty body, never a fabricated success.

Verbs

Pass --agent <id> to target a non-default agent (for tg trigger), --endpoint <url> to target a specific rig, and --json for a machine-readable body.

The dual-oracle workflow

The harness corroborates every assertion against two independent oracles, so a passing test cannot be a false green:
  1. The channel oracle — the emulator’s recorded wire bytes (what the adapter actually sent: tg last / emu.outbound(chat)).
  2. The Comis oracle — the daemon’s own record of what it delivered: the delivery_mirror row, the session trajectory, and the isolated memory.db (tg mirror / tg traj / tg db).
After the reply sync point (waitForReply in-process, or tg wait from the shell), the HARD cross-check asserts the channel oracle’s text equals delivery_mirror.text (assertChannelTrace). A disagreement is a real bug, not flake.
Assert on structure and state (a reply arrived, a row exists, the reaction landed), never on exact model wording — content-sensitive checks use pass-at-k with N at least 3. Read delivery_mirror only after the reply sync point with a bounded poll: the after-delivery hook is fire-and-forget, so a synchronous read right after send races it.

The webhook flag

The emulator has a webhook-POST mode for testing its own secret-token gate, but the product does not expose a Telegram webhook receiver. Product tests assert that setting webhookUrl fails adapter startup before polling or external token validation begins.
Comis is polling-only. A webhook update does not reach the agent, and adapter startup rejects webhook configuration instead of reporting a connection that cannot receive messages. A future webhook receiver must mount the platform callback with secret-token verification before this harness mode can become a product scenario.

Driving from a cold shell (shell-only, unattended)

Two autonomy paths, both certified: The in-process (vitest) path is the certified default — a scenario boots, drives, and tears down the rig with zero human steps, exactly as the acceptance suites run. The chan/tg CLI’s drive verbs (send, react, rpc, trigger, wait, last, db, …) work against a running rig from any shell. The cold-shell (detached) path drives the rig shell-only, unattended, across separate processes — start it with tg up --detached:
tg up --detached spawns a detached-subprocess rig (harness/rig-daemon.ts) that survives the launching process. It runs its own emulator + a real Comis daemon (booted as a grandchild from packages/daemon/dist/daemon.js, the production entrypoint) + a loopback-only, token-owner-checked rig-control HTTP surface, and records a handle carrying a real pid and a dedicated rigControlEndpoint (≠ the gateway). A separate-shell tg send / tg rpc reaches the surviving rig via that handle. The cold-shell lifecycle verbs drive it for real: tg down SIGTERMs the rig’s process group (the rig-daemon and its daemon grandchild) and confirms the process is gone before returning; tg restart / tg reset --deep / tg reconfigure POST the owner-checked rig-control endpoint. Orphan reaping: the rig self-terminates (reaping its daemon) when its handle file disappears, so a tg down or a stray rm of the handle never leaves a zombie daemon.
The “shell-only, unattended” claim is backed by a green cross-process acceptance test (scenarios/channels/telegram-cold-shell.test.ts, Stage-C): a real tg up --detached (process 1) → tg status / tg rpc / tg send (process 2) → tg down (process 3) sequence runs across separate OS processes, asserting the rig OUTLIVES tg up, a fresh process reaches it, and tg down leaves no leaked daemon/port (the pid is gone, the gateway port is free). A false success is a hard fail. Without --detached, tg up boots an in-process rig (the certified default) that the lifecycle verbs honestly reason-code as lifecycle_in_process_only / down_not_owned_in_process from a cold shell — never a faked cross-process success.

The harness never ships

The harness is a contributor tool and is forbidden from the published package:
  • It must never enter bundledDependencies, and adds no @comis/* dependency edge.
  • chan / tg is a standalone tsx entry under test/live/bin/, never a comis CLI subcommand — there is no .command("chan") / .command("tg") registration to forward-protect those names.
  • There is no package.json under test/live/**.
These invariants are re-verified by the architecture test suite on every run.

Custom Adapters

Build a channel adapter from the ChannelPort interface

Data Directory

The ~/.comis-chanlive/<channel>.json handle file the rig writes

Architecture

Port interfaces and the hexagonal pattern

CLI Reference

The operator comis CLI (distinct from the harness chan/tg)