Files
nanoclaw/docs/v1-vs-v2/channels.md
gavrielc 47950671fa docs: add v1→v2 action-items analysis + SDK signal probe tool
- docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module
  docs + ACTION-ITEMS rollup with decisions + timezone recreation spec).
- container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness
  used to characterise Claude Agent SDK event/hook/stderr timing for the
  stuck-detection design in item 9.
- src/channels/chat-sdk-bridge.ts: document the conversations Map staleness
  in a code comment; fix deferred to when dynamic group registration lands
  (ACTION-ITEMS item 17).

No runtime behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:00:04 +03:00

18 KiB
Raw Blame History

channels: v1 vs v2

Scope

v1

  • Paths: src/v1/channels/index.ts, src/v1/channels/registry.ts, src/v1/channels/registry.test.ts
  • LOC: 62 total (1 + 23 + 38)
  • Purpose: Registry and interface stubs for external channel adapters (real adapters live on channels branch)

v2 counterparts

  • Paths: src/channels/adapter.ts, src/channels/channel-registry.ts, src/channels/chat-sdk-bridge.ts, src/channels/index.ts, src/channels/ask-question.ts, and tests
  • LOC: 1,055 total (excluding tests: ~757)
  • Purpose: Full adapter interface, registry with lifecycle, Chat SDK bridge (new in v2), ask_question normalization, plus integration tests

Adapter Interface Diff

v1: Channel (from src/v1/types.ts:8798)

export interface Channel {
  name: string;
  connect(): Promise<void>;
  sendMessage(jid: string, text: string): Promise<void>;
  isConnected(): boolean;
  ownsJid(jid: string): boolean;
  disconnect(): Promise<void>;
  setTyping?(jid: string, isTyping: boolean): Promise<void>;      // Optional
  syncGroups?(force: boolean): Promise<void>;                      // Optional
}

Callbacks (src/v1/types.ts:101112):

  • OnInboundMessage(chatJid: string, message: NewMessage): void
  • OnChatMetadata(chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean): void

Factory & Registration (src/v1/channels/registry.ts:323):

export interface ChannelOpts {
  onMessage: OnInboundMessage;
  onChatMetadata: OnChatMetadata;
  registeredGroups: () => Record<string, RegisteredGroup>;
}
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
registerChannel(name: string, factory: ChannelFactory): void;
getChannelFactory(name: string): ChannelFactory | undefined;
getRegisteredChannelNames(): string[];

v2: ChannelAdapter (from src/channels/adapter.ts:61106)

export interface ChannelAdapter {
  name: string;
  channelType: string;
  supportsThreads: boolean;  // NEW: declares thread model
  
  // Lifecycle (was: connect/disconnect)
  setup(config: ChannelSetup): Promise<void>;
  teardown(): Promise<void>;
  isConnected(): boolean;
  
  // Message delivery (was: sendMessage, now structured)
  deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
  
  // Optional
  setTyping?(platformId: string, threadId: string | null): Promise<void>;
  syncConversations?(): Promise<ConversationInfo[]>;
  updateConversations?(conversations: ConversationConfig[]): void;
  openDM?(userHandle: string): Promise<string>;  // NEW: cold-DM initiation
}

Callbacks (src/channels/adapter.ts:1830):

export interface ChannelSetup {
  conversations: ConversationConfig[];
  onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise<void>;
  onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
  onAction(questionId: string, selectedOption: string, userId: string): void;  // NEW
}

Factory & Registration (src/channels/channel-registry.ts:2547):

export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapter> | null;
export interface ChannelRegistration {
  factory: ChannelAdapterFactory;
  containerConfig?: { mounts?: [...]; env?: Record<string, string>; };
}
registerChannelAdapter(name: string, registration: ChannelRegistration): void;
getChannelAdapter(channelType: string): ChannelAdapter | undefined;  // RENAMED
getActiveAdapters(): ChannelAdapter[];  // NEW
getRegisteredChannelNames(): string[];
getChannelContainerConfig(name: string): ChannelRegistration['containerConfig'];  // NEW

Capability Map

v1 Behavior v2 Location Status Notes
Interface & Lifecycle
connect()disconnect() setup() / teardown() Renamed + consolidated v2 groups init work; adds promise-based retry on NetworkError (src/channels/channel-registry.ts:73)
Channel.name: string ChannelAdapter.name + ChannelAdapter.channelType Split name is identity; channelType is the key for active lookup
ownsJid(jid) Implicit in platformId model Removed v2 uses structured platformId + threadId; ownership logic pushed to router
Message Flow
sendMessage(jid, text) deliver(platformId, threadId, message) Refactored v2 passes structured OutboundMessage with kind field; returns platform messageId; supports edit/reaction ops (src/channels/chat-sdk-bridge.ts:279289)
Callbacks: onMessage onInbound(platformId, threadId, message) Refactored v2 passes message object with kind enum ('chat' | 'chat-sdk'); can be async
Callbacks: onChatMetadata onMetadata(platformId, name?, isGroup?) Simplified Signature matches v1; removed channel param; timestamp now in inbound message itself
onAction(questionId, option, userId) NEW Handles ask_question card button clicks via Chat SDK bridge (src/channels/chat-sdk-bridge.ts:193218)
Typing Indicator
setTyping(jid, bool) setTyping(platformId, threadId) Refactored v2 omits boolean flag (always true, no off-toggle); threaded parameter
Group/Conversation Sync
syncGroups(force?) syncConversations()?: Promise<ConversationInfo[]> Renamed Now returns structured list; decoupled from periodic init (optional hook)
updateConversations(configs): void NEW Push notifications of conversation changes from host to adapter (e.g., new wiring)
Thread Model
Implicit (adapter-specific) supportsThreads: boolean NEW v2 explicitly declares it; router uses this to collapse/expand thread context (src/channels/adapter.ts:7375)
DM Initiation
Not exposed openDM(userHandle)?: Promise<string> NEW For cold-DM reaching (approvals, onboarding, alerts) on platforms that distinguish user-id from DM-channel-id. Optional; fallback in user-dm.ts if absent (src/channels/adapter.ts:94105)
Inbound Message Structure
v1 NewMessage object v2 InboundMessage (generic JSON) Generalized v1 had flat fields (sender, content, timestamp, thread_id, reply_to_*); v2 wraps serialized Chat SDK Message or native JSON in content field; Chat SDK bridge enriches (adds senderId, senderName) before sending (src/channels/chat-sdk-bridge.ts:124141)
Outbound Message Structure
Plain text + typing flag v2 OutboundMessage (typed kind + flexible content) Generalized Supports 'chat', 'chat-sdk', edit ops, reactions, ask_question cards (src/channels/adapter.ts:4651, src/channels/chat-sdk-bridge.ts:279317)
Factory Pattern
ChannelFactory(opts) → Channel | null ChannelAdapterFactory() → ChannelAdapter | Promise<...> | null Async + cred check v2 supports async factory (for loading credentials); promise-based retry on NetworkError (src/channels/channel-registry.ts:6887)
Container Config
Not exposed ChannelRegistration.containerConfig NEW Adapters can declare mounts + env vars for their container (used by container-runner); see src/channels/channel-registry.ts:4547

Message Conversion & Error Handling

v1 Flow

  • Adapter calls onMessage(chatJid, NewMessage) synchronously
  • Router extracts fields, upserts user, creates/finds session, writes to inbound.db
  • No built-in error handling; adapters catch and log themselves

v2 Flow (src/channels/chat-sdk-bridge.ts:85141)

  1. Inbound: Chat SDK MessageInboundMessage (kind='chat-sdk', content=serialized JSON)
  2. Attachment handling: Downloads attachments, converts to base64 (src/channels/chat-sdk-bridge.ts:90111)
  3. Reply context extraction: Platform-specific hook (src/channels/chat-sdk-bridge.ts:115120)
  4. User field normalization: Maps Chat SDK author → senderId, sender, senderName (src/channels/chat-sdk-bridge.ts:124131)
  5. Raw data drop: Removes raw to save DB space (src/channels/chat-sdk-bridge.ts:134)
  6. Call onInbound: Async-capable (can await router writes)

Outbound (src/channels/chat-sdk-bridge.ts:273344):

  • Supports multiple operation types via content.operation:
    • 'edit' + messageIdadapter.editMessage()
    • 'reaction' + emojiadapter.addReaction()
    • type: 'ask_question' → render Card with buttons
    • Normal text/markdown → adapter.postMessage() with optional files

Error Propagation:

  • Network errors on setup get retry (src/channels/channel-registry.ts:73; duck-type check for Error.name==='NetworkError')
  • Delivery errors logged but don't block (src/channels/chat-sdk-bridge.ts:213214, 484486)

New: Chat SDK Bridge

The v2 Chat abstraction (from @anthropic-ai/chat) wraps platform-specific adapters (Discord.js, Slack SDK, etc.) into a unified API. The NanoClaw createChatSdkBridge() (src/channels/chat-sdk-bridge.ts:68384) adapts that Chat instance to the ChannelAdapter interface.

Key methods:

  • setup(hostConfig): Initialize Chat, set up event handlers (subscribed messages, DMs, mentions, actions), start Gateway listener or register webhook (src/channels/chat-sdk-bridge.ts:149271)
  • deliver(): Route outbound payloads (text, edit, reaction, ask_question card) to Chat SDK (src/channels/chat-sdk-bridge.ts:273344)
  • setTyping(): Delegate to adapter.startTyping() (src/channels/chat-sdk-bridge.ts:346349)
  • teardown(): Abort Gateway, shutdown Chat (src/channels/chat-sdk-bridge.ts:351355)
  • updateConversations(): Rebuild conversation map on changes (src/channels/chat-sdk-bridge.ts:361363)
  • openDM(): Conditional; only if underlying adapter supports it (src/channels/chat-sdk-bridge.ts:366381)

Event routing (src/channels/chat-sdk-bridge.ts:163191):

  • chat.onSubscribedMessage()onInbound() for all known threads
  • chat.onNewMention()onInbound() + auto-subscribe
  • chat.onDirectMessage()onInbound() for DMs
  • chat.onAction()onAction() for ask_question button clicks (src/channels/chat-sdk-bridge.ts:193218)

Gateway listener (src/channels/chat-sdk-bridge.ts:222268):

  • Adapters like Discord that support websocket connection declare startGatewayListener().
  • NanoClaw runs it, forwards interactions (button clicks) to a local HTTP webhook server (src/channels/chat-sdk-bridge.ts:392506).
  • Non-Gateway adapters (Slack, Teams) register on the shared webhook-server instead (src/channels/chat-sdk-bridge.ts:266268).

Test Fixtures

v1 (src/v1/channels/registry.test.ts:1038)

  • Simple lambda factories: () => null
  • No mock adapters (tests only verify registry API mechanics)
  • Test count: 4 (unknown-channel, round-trip, listing, overwrite)

v2 (src/channels/channel-registry.test.ts + src/channels/chat-sdk-bridge.test.ts)

Mock Adapter (src/channels/channel-registry.test.ts:3171):

createMockAdapter(channelType): ChannelAdapter & { delivered, inbound, setupConfig }
  - Properties: name, channelType, supportsThreads, delivered[], inbound[], setupConfig
  - Methods: setup(config), teardown(), isConnected(), deliver(), setTyping(), updateConversations()

Registry Tests (src/channels/channel-registry.test.ts:84119):

  • Adapter registration with container config (src/channels/channel-registry.test.ts:8898)
  • Credential-missing adapters skipped (src/channels/channel-registry.test.ts:101119)

Integration Tests (src/channels/channel-registry.test.ts:122234):

  • Router receives inbound from adapter, writes to inbound.db (src/channels/channel-registry.test.ts:166197)
  • Delivery adapter bridge calls adapter.deliver() (src/channels/channel-registry.test.ts:199233)

Chat SDK Bridge Tests (src/channels/chat-sdk-bridge.test.ts:1138):

  • Conditional openDM exposure (src/channels/chat-sdk-bridge.test.ts:1218)
  • openDM delegation to underlying adapter (src/channels/chat-sdk-bridge.test.ts:2037)

Missing from v2

1. ownsJid(jid: string): boolean

  • v1 use: Adapters declared ownership of a JID (e.g., "does this Telegram numeric ID belong to me?")
  • v2 model: JIDs → platformId + threadId; ownership is implicit in platformId format (e.g., "telegram:6037840640" vs "discord:guildId:channelId"). Router uses this to route inbound to the right adapter.
  • Impact: Adapters no longer need explicit ownership checks; the structured ID handles it.

2. syncGroups(force?: boolean): Promise<void>

  • v1 use: Periodic or on-demand sync of all groups/channels from the platform.
  • v2 model: Optional syncConversations() returns metadata instead of mutating internal state; host calls it when needed (not baked into adapter init). Conversations are tracked in central DB messaging_groups table.
  • Impact: Host has more control; adapters don't side-effect their own state.

3. registeredGroups callback in ChannelOpts

  • v1 use: Passed at init time; adapters could query which groups were registered.
  • v2 model: Conversations provided upfront in ChannelSetup.conversations; can be updated via updateConversations().
  • Impact: Cleaner dependency injection; avoids callback nesting.

4. channel parameter in OnChatMetadata

  • v1 use: Metadata callback could optionally return which channel type made the discovery.
  • v2 model: Not needed; platformId in onMetadata(platformId, name, isGroup) encodes the channel type.

Behavioral Discrepancies

1. Thread-ID Handling

  • v1: Some adapters (Telegram, WhatsApp) don't use threads; JIDs are the same as channel IDs. Others (Discord, Slack) embed thread IDs in reply_to logic.
  • v2: Explicit supportsThreads flag; adapters that don't support threads pass threadId: null to onInbound(). Router uses this to decide session granularity (file:src/channels/adapter.ts:7375).

2. Outbound Message Structure

  • v1: Plain text + optional typing flag.
  • v2: Structured { kind, content, files? } with operation support (edit, reaction, ask_question cards). Allows multi-op delivery without repeated deliver() calls.

3. Inbound Serialization

  • v1: Adapters directly passed NewMessage interface objects.
  • v2: Adapters pass InboundMessage with generic content field (JSON-serializable JS object). Chat SDK bridge converts Chat SDK Message → JSON, then stringifies for DB (file:src/channels/chat-sdk-bridge.ts:136140).

4. Ask-Question Handling

  • v1: No native support; would be custom per-adapter.
  • v2: Unified via ask_question payload type. Chat SDK bridge renders as Card + Buttons; handles button clicks via onAction() callback and updates card to show selection (file:src/channels/chat-sdk-bridge.ts:292317, 459486).

5. Cold-DM Initiation

  • v1: Not exposed.
  • v2: openDM(userHandle): Promise<string> allows host to initiate DMs to users without prior message. Adapters that need it (Discord, Slack, Teams) implement; others omit and fall back to direct handle as platformId (file:src/user-dm.ts fallback).

6. Async Factory

  • v1: ChannelFactory returns Channel | null synchronously.
  • v2: ChannelAdapterFactory returns ChannelAdapter | Promise<ChannelAdapter> | null, supporting async credential loading. Registry retries on NetworkError (file:src/channels/channel-registry.ts:6887).

7. Lifecycle Promises

  • v1: connect() / disconnect() are separate.
  • v2: setup() / teardown() grouped; no intermediate "starting/stopping" state. Gateway listeners and webhook servers are started inside setup(), torn down inside teardown() (file:src/channels/chat-sdk-bridge.ts:149271, 351355).

Worth Preserving?

All v1 patterns are preserved in v2, just restructured:

  1. Adapter interface model: v1's optional hooks (setTyping?, syncGroups?) become v2's optional methods (setTyping?, syncConversations?, openDM?). Structural compatibility for native adapters.

  2. Registry pattern: v1's registerChannel(name, factory) → v2's registerChannelAdapter(name, registration). Same self-registration barrel; v2 adds container config metadata.

  3. Callback-driven message flow: v1's onMessage and onChatMetadata callbacks live on as onInbound and onMetadata. v2 adds onAction for interactive features (ask_question buttons).

  4. No built-in state mutation: v1 adapters own their group state; v2 adapters are stateless (conversations pushed in). Both respect adapter autonomy.

What's genuinely new and worth keeping:

  • Chat SDK bridge: Unifies platform SDKs without duplicating channel adapters per SDK. Huge reduction in code duplication (one Discord adapter instead of native + Chat SDK versions).
  • Structured message payloads: v2's kind field and flexible content JSON allow single delivery path for text, edits, reactions, and rich interactions.
  • Ask-question cards: Native support for interactive approvals and user input, reducing agent-side boilerplate.
  • openDM: Enables host-initiated contact (onboarding, alerts, approvals) without waiting for inbound.
  • supportsThreads: Explicit declaration lets router make informed session granularity decisions, vs. hardcoded per-adapter assumptions.

Minimal migration burden:

Native adapters written for v1 need only:

  1. Rename connectsetup (add ChannelSetup param).
  2. Rename disconnectteardown.
  3. Rename sendMessage(jid, text)deliver(platformId, threadId, message) (wrap text in { kind: 'chat', content: { text } }).
  4. Add supportsThreads: boolean, name, channelType fields.
  5. Add isConnected() stub if not already present.
  6. Optional: Implement setTyping?, syncConversations?, openDM? for feature parity.

Nothing is fundamentally broken; it's a straightforward refactor of the adapter contract.