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>
This commit is contained in:
gavrielc
2026-04-20 01:00:04 +03:00
parent 5ed5b72f10
commit 47950671fa
26 changed files with 3653 additions and 0 deletions

305
docs/v1-vs-v2/channels.md Normal file
View File

@@ -0,0 +1,305 @@
# 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)
```typescript
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):
```typescript
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)
```typescript
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):
```typescript
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):
```typescript
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 `Message``InboundMessage` (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'` + `messageId``adapter.editMessage()`
- `'reaction'` + `emoji``adapter.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):
```typescript
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 `connect``setup` (add `ChannelSetup` param).
2. Rename `disconnect``teardown`.
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.