From 9933c884bf70f3ae01c989e2d277a1e39d6b91f3 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Fri, 5 Jun 2026 03:07:19 +0800 Subject: [PATCH 1/2] feat: add DeepSeek as a first-class provider - Register DeepSeek in the shared provider registry with model prefixes, env key, default base URL/model, and pricing - Route 'deepseek' through OpenAIProvider with the DeepSeek base URL - Surface DeepSeek in provider-config env sync, prisma seed, the interactive installer, .env.example, and README provider docs - Mark Gemini and Kimi as Available in the README provider table --- .env.example | 3 +- README.md | 15 ++++---- packages/api/prisma/seed.ts | 6 ++++ .../__tests__/provider-factory.test.ts | 22 ++++++++++++ .../src/engine/providers/provider-factory.ts | 7 +++- .../__tests__/provider-config.service.test.ts | 28 +++++++++++++++ .../provider-config.service.ts | 1 + .../__tests__/provider-registry.test.ts | 36 +++++++++++++++++++ .../shared/src/providers/provider-registry.ts | 19 ++++++++++ scripts/install.mjs | 13 +++++-- 10 files changed, 138 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index eac4c11..d955702 100644 --- a/.env.example +++ b/.env.example @@ -78,7 +78,7 @@ WORKSPACE_BASE_PATH=./data # Configuration (For Seed — dev only, used by `prisma db seed`) DEFAULT_PASSWORD=password1234 -# DEFAULT_PROVIDER options: anthropic, openai, zai-coding, or any custom provider id. +# DEFAULT_PROVIDER options: anthropic, openai, zai-coding, deepseek, or any custom provider id. DEFAULT_PROVIDER=openai DEFAULT_LLM_MODEL=gpt-4o @@ -97,6 +97,7 @@ DEFAULT_LLM_MODEL=gpt-4o # ANTHROPIC_API_KEY= # OPENAI_API_KEY= # ZAI_CODING_API_KEY= +# DEEPSEEK_API_KEY= # Custom provider (optional — any OpenAI-compatible Chat Completions endpoint). # Set ALL of NAME / BASE_URL / API_KEY to enable. NAME is used as the DB key diff --git a/README.md b/README.md index 2a3badb..87c2f18 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Most AI agent frameworks are either **toys** (single-process, no isolation, no a Clawix sits in between: **production-grade orchestration you own entirely.** - **Every agent runs in its own Docker container** -- no agent can read another's files, exhaust your host's memory, or escape its sandbox. -- **Plug in any LLM** -- Claude and GPT-4 today, with Azure, DeepSeek, Gemini, and OpenRouter coming soon. Any OpenAI-compatible endpoint (Ollama, vLLM, etc.) works now via the custom provider. +- **Plug in any LLM** -- Claude, GPT, DeepSeek, and Gemini today, with Azure and OpenRouter coming soon. Any OpenAI-compatible endpoint (Ollama, vLLM, etc.) works now via the custom provider. - **Built for teams** -- RBAC, token budgets, audit logs, and scoped memory mean you can hand agents to your whole org without losing sleep. - **Reach users where they are** -- Telegram, WhatsApp, Slack, and a built-in web dashboard. One agent, many channels. @@ -55,7 +55,7 @@ Break complex tasks into sub-agent DAGs. The coordinator delegates, aggregates r ### Multi-Provider AI -Anthropic and OpenAI out of the box, with Azure, DeepSeek, Gemini, and OpenRouter planned. Any OpenAI-compatible endpoint already works via the custom provider. Add new providers with a single config entry. +Anthropic, OpenAI, DeepSeek, and Gemini out of the box, with Azure and OpenRouter planned. Any OpenAI-compatible endpoint already works via the custom provider. Add new providers with a single config entry. ### Scoped Memory System @@ -167,6 +167,7 @@ PROVIDER_ENCRYPTION_KEY=$(openssl rand -hex 32) # AI providers (used by db:seed; also env fallback at runtime) ANTHROPIC_API_KEY=sk-ant-xxx # Claude OPENAI_API_KEY=sk-xxx # GPT (optional) +DEEPSEEK_API_KEY=sk-xxx # DeepSeek (optional) # Channels (optional -- used by db:seed to populate channel config) TELEGRAM_BOT_TOKEN=123456789:ABCdef... # Telegram (from @BotFather) @@ -204,7 +205,7 @@ pnpm run install:clawix The installer will: 1. Check prerequisites (Node 20+, pnpm, Docker, Docker Compose) -2. Ask for deployment mode (production / development), provider (OpenAI or Zai-Coding) + API key, admin email/password (production only), and optional Telegram bot token +2. Ask for deployment mode (production / development), provider selection (Anthropic, OpenAI, Z.AI Coding, Kimi, Gemini, DeepSeek, or a custom OpenAI-compatible endpoint) + API key, admin email/password (production only), and optional Telegram bot token 3. Generate `.env` with cryptographically random `JWT_SECRET`, `PROVIDER_ENCRYPTION_KEY`, `POSTGRES_PASSWORD` (file permissions set to `600`) 4. Build `clawix-agent:latest` (agent image used for isolated per-task containers) 5. Run `docker compose … up -d --build` @@ -299,9 +300,9 @@ Built-in providers plus extensible registry -- add new ones with a single `Provi | **OpenAI** | model starts with `gpt-`/`o1-`/`o3-`/`o4-` | General purpose | Available | | **Z.AI Coding** | model starts with `glm-` | GLM models | Available | | **Azure** | config key `azure_openai` | Enterprise compliance | Planned | -| **DeepSeek** | model starts with `deepseek-` | Cost-effective | Planned | -| **Gemini** | model starts with `gemini-` | Google ecosystem | Planned | -| **Kimi** | model starts with `moonshot-` | Long-context tasks | Planned | +| **DeepSeek** | model starts with `deepseek-` | Cost-effective | Available | +| **Gemini** | model starts with `gemini-` | Google ecosystem | Available | +| **Kimi** | provider id `kimi-code` | Long-context tasks | Available | | **OpenRouter** | API key starts with `sk-or-` | Provider gateway | Planned | | **Custom** | any OpenAI-compatible endpoint | Ollama, vLLM, etc. | Available | @@ -400,7 +401,7 @@ pnpm run db:studio # Open Prisma Studio (GUI) - [x] Container-isolated agent execution - [x] Multi-provider AI support (Claude, GPT, OpenAI-compatible endpoints) -- [ ] First-class Azure, DeepSeek, Gemini, Kimi, OpenRouter providers +- [ ] First-class Azure, OpenRouter providers - [x] Warm container pool (~50ms cold start) - [x] Swarm orchestration with DAG dependencies - [x] Telegram channel integration diff --git a/packages/api/prisma/seed.ts b/packages/api/prisma/seed.ts index 698631c..10b7a17 100644 --- a/packages/api/prisma/seed.ts +++ b/packages/api/prisma/seed.ts @@ -96,6 +96,12 @@ async function main(): Promise { envKey: 'ZAI_CODING_API_KEY', baseUrl: 'https://api.z.ai/api/coding/paas/v4', }, + { + provider: 'deepseek', + displayName: 'DeepSeek', + envKey: 'DEEPSEEK_API_KEY', + baseUrl: 'https://api.deepseek.com', + }, ]; const customName = process.env['CUSTOM_PROVIDER_NAME']; const customBase = process.env['CUSTOM_PROVIDER_BASE_URL']; diff --git a/packages/api/src/engine/providers/__tests__/provider-factory.test.ts b/packages/api/src/engine/providers/__tests__/provider-factory.test.ts index f89d77a..8232945 100644 --- a/packages/api/src/engine/providers/__tests__/provider-factory.test.ts +++ b/packages/api/src/engine/providers/__tests__/provider-factory.test.ts @@ -31,6 +31,8 @@ vi.mock('@clawix/shared', async (importOriginal) => { }; }); +import OpenAI from 'openai'; + import { createProvider } from '../provider-factory.js'; import { OpenAIProvider } from '../openai-provider.js'; import { AnthropicProvider } from '../anthropic-provider.js'; @@ -123,6 +125,26 @@ describe('createProvider', () => { const provider = createProvider('kimi-code', API_KEY, 'https://custom.kimi.com/v1'); expect(provider).toBeInstanceOf(AnthropicProvider); }); + + it('creates an OpenAIProvider for "deepseek" with default base URL', () => { + const OpenAIMock = vi.mocked(OpenAI); + OpenAIMock.mockClear(); + const provider = createProvider('deepseek', API_KEY); + expect(provider).toBeInstanceOf(OpenAIProvider); + expect(OpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: 'https://api.deepseek.com' }), + ); + }); + + it('uses custom baseURL for deepseek when provided', () => { + const OpenAIMock = vi.mocked(OpenAI); + OpenAIMock.mockClear(); + const provider = createProvider('deepseek', API_KEY, 'https://custom.deepseek.com/v1'); + expect(provider).toBeInstanceOf(OpenAIProvider); + expect(OpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: 'https://custom.deepseek.com/v1' }), + ); + }); }); describe('createProvider — caching flag', () => { diff --git a/packages/api/src/engine/providers/provider-factory.ts b/packages/api/src/engine/providers/provider-factory.ts index 24b3cf9..d3cb7b0 100644 --- a/packages/api/src/engine/providers/provider-factory.ts +++ b/packages/api/src/engine/providers/provider-factory.ts @@ -12,11 +12,13 @@ import { isCodexModel } from './openai-responses-utils.js'; const ZAI_CODING_DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4'; const KIMI_CODE_DEFAULT_BASE_URL = 'https://api.kimi.com/coding'; +const DEEPSEEK_DEFAULT_BASE_URL = 'https://api.deepseek.com'; /** * Instantiate an {@link LLMProvider} by provider name. * - * Known providers: `'anthropic'`, `'gemini'`, `'openai'`, `'zai-coding'`. + * Known providers: `'anthropic'`, `'gemini'`, `'openai'`, `'zai-coding'`, + * `'kimi-code'`, `'deepseek'`. * Any other name is treated as an OpenAI-compatible custom provider * and requires a `baseURL`. * @@ -43,6 +45,9 @@ export function createProvider( case 'zai-coding': return new OpenAIProvider(apiKey, baseURL ?? ZAI_CODING_DEFAULT_BASE_URL); + case 'deepseek': + return new OpenAIProvider(apiKey, baseURL ?? DEEPSEEK_DEFAULT_BASE_URL); + case 'gemini': return new GeminiProvider(apiKey, baseURL); diff --git a/packages/api/src/provider-config/__tests__/provider-config.service.test.ts b/packages/api/src/provider-config/__tests__/provider-config.service.test.ts index 518ed8f..0b0a99a 100644 --- a/packages/api/src/provider-config/__tests__/provider-config.service.test.ts +++ b/packages/api/src/provider-config/__tests__/provider-config.service.test.ts @@ -352,6 +352,8 @@ describe('ProviderConfigService', () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-seed'); vi.stubEnv('OPENAI_API_KEY', 'sk-openai-seed'); vi.stubEnv('ZAI_CODING_API_KEY', ''); + vi.stubEnv('KIMI_CODE_API_KEY', ''); + vi.stubEnv('DEEPSEEK_API_KEY', ''); await service.seedFromEnv(); @@ -383,6 +385,32 @@ describe('ProviderConfigService', () => { ); }); + it('seeds the deepseek provider with its default base URL when DEEPSEEK_API_KEY is set', async () => { + prisma.providerConfig.count.mockResolvedValueOnce(0); + prisma.providerConfig.create.mockResolvedValue({}); + + vi.stubEnv('ANTHROPIC_API_KEY', ''); + vi.stubEnv('OPENAI_API_KEY', ''); + vi.stubEnv('ZAI_CODING_API_KEY', ''); + vi.stubEnv('KIMI_CODE_API_KEY', ''); + vi.stubEnv('DEEPSEEK_API_KEY', 'sk-deepseek-seed'); + + await service.seedFromEnv(); + + expect(prisma.providerConfig.create).toHaveBeenCalledTimes(1); + expect(prisma.providerConfig.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + provider: 'deepseek', + displayName: 'DeepSeek', + apiKey: 'encrypted:sk-deepseek-seed', + apiBaseUrl: 'https://api.deepseek.com', + isDefault: true, + }), + }), + ); + }); + it('skips seeding when DB already has rows', async () => { prisma.providerConfig.count.mockResolvedValueOnce(3); diff --git a/packages/api/src/provider-config/provider-config.service.ts b/packages/api/src/provider-config/provider-config.service.ts index 37f018c..3942c90 100644 --- a/packages/api/src/provider-config/provider-config.service.ts +++ b/packages/api/src/provider-config/provider-config.service.ts @@ -221,6 +221,7 @@ export class ProviderConfigService { { provider: 'openai', displayName: 'OpenAI', envKey: 'OPENAI_API_KEY' }, { provider: 'zai-coding', displayName: 'Z.AI Coding Plan', envKey: 'ZAI_CODING_API_KEY' }, { provider: 'kimi-code', displayName: 'Kimi Coding Plan', envKey: 'KIMI_CODE_API_KEY' }, + { provider: 'deepseek', displayName: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY' }, ]; let isFirst = true; diff --git a/packages/shared/src/providers/__tests__/provider-registry.test.ts b/packages/shared/src/providers/__tests__/provider-registry.test.ts index b57fd03..1f97ea1 100644 --- a/packages/shared/src/providers/__tests__/provider-registry.test.ts +++ b/packages/shared/src/providers/__tests__/provider-registry.test.ts @@ -240,6 +240,42 @@ describe('Kimi Code provider spec', () => { }); }); +describe('DeepSeek provider spec', () => { + it('is registered by name', () => { + const spec = findProviderByName('deepseek'); + expect(spec).not.toBeNull(); + expect(spec?.displayName).toBe('DeepSeek'); + expect(spec?.envKey).toBe('DEEPSEEK_API_KEY'); + expect(spec?.defaultBaseUrl).toBe('https://api.deepseek.com'); + expect(spec?.defaultModel).toBe('deepseek-v4-flash'); + expect(spec?.supportsTools).toBe(true); + expect(spec?.supportsThinking).toBe(false); + }); + + it('detects deepseek from the deepseek- model prefix', () => { + expect(findProviderByModel('deepseek-v4-flash')?.name).toBe('deepseek'); + expect(findProviderByModel('deepseek-v4-pro')?.name).toBe('deepseek'); + }); + + it('appears in listProviders()', () => { + expect(listProviders().some((p) => p.name === 'deepseek')).toBe(true); + }); + + it('estimates cost for deepseek-v4-flash', () => { + const cost = estimateCost('deepseek', 'deepseek-v4-flash', 1_000_000, 1_000_000); + expect(cost).toBeCloseTo(0.14 + 0.28, 5); + }); + + it('estimates cost for deepseek-v4-pro', () => { + const cost = estimateCost('deepseek', 'deepseek-v4-pro', 1_000_000, 1_000_000); + expect(cost).toBeCloseTo(0.435 + 0.87, 5); + }); + + it('returns null cost for a legacy/unknown deepseek model (no pricing entry)', () => { + expect(estimateCost('deepseek', 'deepseek-chat', 1_000, 1_000)).toBeNull(); + }); +}); + describe('estimateCost', () => { it('should calculate cost for claude-opus-4', () => { // $15 per M input, $75 per M output diff --git a/packages/shared/src/providers/provider-registry.ts b/packages/shared/src/providers/provider-registry.ts index aee6c66..3078a2b 100644 --- a/packages/shared/src/providers/provider-registry.ts +++ b/packages/shared/src/providers/provider-registry.ts @@ -121,6 +121,24 @@ const KIMI_CODE_SPEC: ProviderSpec = { pricing: null, }; +const DEEPSEEK_SPEC: ProviderSpec = { + name: 'deepseek', + displayName: 'DeepSeek', + modelPrefixes: ['deepseek-'], + envKey: 'DEEPSEEK_API_KEY', + defaultBaseUrl: 'https://api.deepseek.com', + defaultModel: 'deepseek-v4-flash', + supportsTools: true, + supportsThinking: false, + pricing: [ + // Cache-miss input + output rates (USD/M tokens). estimateCost does not + // model DeepSeek's cache-hit discount. Source: DeepSeek pricing page + // (https://api-docs.deepseek.com/quick_start/pricing), fetched 2026-06-05. + { model: 'deepseek-v4-pro', inputPerMillion: 0.435, outputPerMillion: 0.87 }, + { model: 'deepseek-v4-flash', inputPerMillion: 0.14, outputPerMillion: 0.28 }, + ], +}; + const CUSTOM_SPEC: ProviderSpec = { name: 'custom', displayName: 'Custom', @@ -138,6 +156,7 @@ const PROVIDERS: readonly ProviderSpec[] = [ ZAI_CODING_SPEC, KIMI_CODE_SPEC, GEMINI_SPEC, + DEEPSEEK_SPEC, CUSTOM_SPEC, ]; diff --git a/scripts/install.mjs b/scripts/install.mjs index cc16798..360744c 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -199,8 +199,9 @@ async function main() { console.log(' 3) Z.AI Coding Plan (default model: glm-4.7)'); console.log(' 4) Kimi Coding Plan (model entered below)'); console.log(' 5) Google Gemini (default model: gemini-3-flash-preview)'); + console.log(' 6) DeepSeek (default model: deepseek-v4-flash)'); console.log( - ' 6) Custom (any OpenAI-compatible endpoint — local LLM, OpenRouter, vLLM, etc.)', + ' 7) Custom (any OpenAI-compatible endpoint — local LLM, OpenRouter, vLLM, etc.)', ); /** @@ -239,9 +240,15 @@ async function main() { envKey: 'GEMINI_API_KEY', defaultModel: 'gemini-3-flash-preview', }, + 6: { + id: 'deepseek', + displayName: 'DeepSeek', + envKey: 'DEEPSEEK_API_KEY', + defaultModel: 'deepseek-v4-flash', + }, }; - const CUSTOM_CHOICE = 6; - const VALID_CHOICES = [1, 2, 3, 4, 5, 6]; + const CUSTOM_CHOICE = 7; + const VALID_CHOICES = [1, 2, 3, 4, 5, 6, 7]; const RESERVED_IDS = Object.values(CATALOG).map((c) => c.id); /** Parse "1,3,4" → unique ordered list of integers from `valid`. */ From 538cb3e92e4dd432cc8841c6e960b4fa5b450693 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Fri, 5 Jun 2026 03:07:28 +0800 Subject: [PATCH 2/2] feat: add Telegram reply threading and edit-in-place tool status - Add reply_to_mode (off/first/all, default first) to the Telegram adapter, mapping the inbound message id to reply_parameters with allow_sending_without_reply for resilience - Type outbound metadata via OutboundMessageMetadata and thread replyToMessageId from the message router on all response sends - Change ChannelAdapter.sendMessage to return the platform message id and add optional editMessage; Telegram implements it via editMessageText with MarkdownV2/plain-text fallbacks - Consolidate consecutive tool-progress bubbles in the router by editing one status message in place, falling back to fresh sends for adapters without editMessage (web, WhatsApp) --- .../__tests__/message-router.service.test.ts | 127 ++++++++++ .../__tests__/telegram.adapter.test.ts | 225 +++++++++++++++++- .../src/channels/message-router.service.ts | 45 +++- .../src/channels/telegram/telegram.adapter.ts | 133 ++++++++++- .../web/__tests__/web.adapter.test.ts | 4 +- .../web/__tests__/web.integration.test.ts | 2 +- packages/api/src/channels/web/web.adapter.ts | 10 +- .../src/channels/whatsapp/whatsapp.adapter.ts | 7 +- packages/shared/src/types/channel.ts | 41 +++- packages/shared/src/types/index.ts | 1 + 10 files changed, 569 insertions(+), 26 deletions(-) diff --git a/packages/api/src/channels/__tests__/message-router.service.test.ts b/packages/api/src/channels/__tests__/message-router.service.test.ts index 334c68a..99afd0e 100644 --- a/packages/api/src/channels/__tests__/message-router.service.test.ts +++ b/packages/api/src/channels/__tests__/message-router.service.test.ts @@ -119,6 +119,7 @@ describe('MessageRouterService', () => { text: 'Hello human', metadata: { messageId: 'msg-abc', + replyToMessageId: 'msg-1', sessionId: 'session-1', }, }); @@ -189,6 +190,7 @@ describe('MessageRouterService', () => { text: 'Response', metadata: { messageId: 'run-xyz', + replyToMessageId: 'msg-1', sessionId: 'session-1', }, }); @@ -288,6 +290,7 @@ describe('MessageRouterService', () => { expect(channel.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringContaining('went wrong'), + metadata: expect.objectContaining({ replyToMessageId: 'msg-1' }), }), ); }); @@ -687,6 +690,130 @@ describe('MessageRouterService', () => { expect(calls[0][0]).toMatchObject({ recipientId: '123456', text: 'Thinking…' }); expect(calls[1][0]).toMatchObject({ recipientId: '123456', text: 'Dogs are loyal.' }); }); + + describe('edit-status-in-place', () => { + // A streaming run: one prose chunk, then three consecutive tool calls, + // then the final answer. Drives the status-consolidation path. + function streamThreeTools(): void { + mockAgentDefRepo.findById.mockResolvedValue({ id: 'agent-1', streamingEnabled: true }); + mockChannelRepo.findById.mockResolvedValue({ id: 'channel-1', toolProgressMode: null }); + mockAgentRunner.run.mockImplementation( + async (opts: { onEvent?: (e: unknown) => Promise }) => { + if (opts.onEvent) { + await opts.onEvent({ type: 'assistant_chunk', content: 'Working.', isFinal: false }); + await opts.onEvent({ + type: 'tool_started', + name: 'web_search', + args: { query: 'a' }, + }); + await opts.onEvent({ type: 'tool_started', name: 'read_file', args: { path: 'b' } }); + await opts.onEvent({ type: 'tool_started', name: 'shell_exec', args: { cmd: 'c' } }); + await opts.onEvent({ type: 'assistant_chunk', content: 'Done.', isFinal: true }); + } + return { + streamingUsed: true, + output: 'Done.', + agentRunId: 'run-1', + sessionId: 'session-1', + status: 'completed', + tokenUsage: { input: 10, output: 5 }, + }; + }, + ); + } + + it('edits one status message in place across consecutive tool bubbles', async () => { + streamThreeTools(); + const channel = mockChannel(); + // First bubble send returns the status anchor id. + (channel.sendMessage as ReturnType).mockResolvedValue('status-1'); + channel.editMessage = vi.fn().mockResolvedValue(undefined); + + const router = createRouter(); + await router.handleInbound(mockInbound(), channel); + + // sendMessage: prose 'Working.', first tool bubble, final 'Done.' = 3. + const sends = (channel.sendMessage as ReturnType).mock.calls; + expect(sends).toHaveLength(3); + expect(sends[0][0].text).toBe('Working.'); + expect(sends[1][0].text).toMatch(/^🔍 web_search:/); + expect(sends[2][0].text).toBe('Done.'); + + // The 2nd and 3rd tools edit the same status anchor instead of appending. + const edits = (channel.editMessage as ReturnType).mock.calls; + expect(edits).toHaveLength(2); + expect(edits[0]).toEqual(['123456', 'status-1', expect.stringMatching(/^📖 read_file:/)]); + expect(edits[1]).toEqual(['123456', 'status-1', expect.stringMatching(/^💻 shell_exec:/)]); + }); + + it('appends a new message per tool when the adapter has no editMessage', async () => { + streamThreeTools(); + const channel = mockChannel(); // no editMessage + const router = createRouter(); + await router.handleInbound(mockInbound(), channel); + + // prose + 3 tool bubbles + final = 5 sends, no edits possible. + expect(channel.sendMessage).toHaveBeenCalledTimes(5); + }); + + it('opens a fresh status message after an interleaved assistant_chunk', async () => { + mockAgentDefRepo.findById.mockResolvedValue({ id: 'agent-1', streamingEnabled: true }); + mockChannelRepo.findById.mockResolvedValue({ id: 'channel-1', toolProgressMode: null }); + mockAgentRunner.run.mockImplementation( + async (opts: { onEvent?: (e: unknown) => Promise }) => { + if (opts.onEvent) { + // Group 1: two tools. + await opts.onEvent({ + type: 'tool_started', + name: 'web_search', + args: { query: 'a' }, + }); + await opts.onEvent({ type: 'tool_started', name: 'read_file', args: { path: 'b' } }); + // Prose closes the group. + await opts.onEvent({ type: 'assistant_chunk', content: 'Midway.', isFinal: false }); + // Group 2: one tool — must NOT edit group 1's anchor. + await opts.onEvent({ type: 'tool_started', name: 'shell_exec', args: { cmd: 'c' } }); + await opts.onEvent({ type: 'assistant_chunk', content: 'Done.', isFinal: true }); + } + return { + streamingUsed: true, + output: 'Done.', + agentRunId: 'run-1', + sessionId: 'session-1', + status: 'completed', + tokenUsage: { input: 10, output: 5 }, + }; + }, + ); + + const channel = mockChannel(); + (channel.sendMessage as ReturnType).mockResolvedValue('status-1'); + channel.editMessage = vi.fn().mockResolvedValue(undefined); + + const router = createRouter(); + await router.handleInbound(mockInbound(), channel); + + // Sends: group-1 first tool, 'Midway.', group-2 first tool (fresh), 'Done.' = 4. + expect(channel.sendMessage).toHaveBeenCalledTimes(4); + // Only the 2nd tool of group 1 was an edit; group 2's tool opened fresh. + expect(channel.editMessage).toHaveBeenCalledTimes(1); + }); + + it('falls back to a fresh send when editMessage rejects', async () => { + streamThreeTools(); + const channel = mockChannel(); + (channel.sendMessage as ReturnType).mockResolvedValue('status-1'); + channel.editMessage = vi.fn().mockRejectedValue(new Error('edit failed')); + + const router = createRouter(); + await router.handleInbound(mockInbound(), channel); + + // prose + tool-1 send, then tool-2 edit fails → fresh send, tool-3 edit + // fails → fresh send, + final = 5 sends. Both edits were attempted. + expect(channel.sendMessage).toHaveBeenCalledTimes(5); + expect(channel.editMessage).toHaveBeenCalledTimes(2); + }); + }); }); describe('MessageRouterService.lookupUser (whatsapp)', () => { diff --git a/packages/api/src/channels/__tests__/telegram.adapter.test.ts b/packages/api/src/channels/__tests__/telegram.adapter.test.ts index f40384e..661d27f 100644 --- a/packages/api/src/channels/__tests__/telegram.adapter.test.ts +++ b/packages/api/src/channels/__tests__/telegram.adapter.test.ts @@ -3,8 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createTelegramAdapter } from '../telegram/telegram.adapter.js'; import type { ChannelAdapterConfig } from '@clawix/shared'; -const sendMessageMock = vi.fn().mockResolvedValue({}); +const sendMessageMock = vi.fn().mockResolvedValue({ message_id: 100 }); const sendChatActionMock = vi.fn().mockResolvedValue({}); +const editMessageTextMock = vi.fn().mockResolvedValue({}); vi.mock('grammy', () => { return { @@ -16,6 +17,7 @@ vi.mock('grammy', () => { api: { sendMessage: sendMessageMock, sendChatAction: sendChatActionMock, + editMessageText: editMessageTextMock, setWebhook: vi.fn().mockResolvedValue({}), }, })), @@ -32,7 +34,9 @@ describe('createTelegramAdapter', () => { beforeEach(() => { sendMessageMock.mockClear(); - sendMessageMock.mockResolvedValue({}); + sendMessageMock.mockResolvedValue({ message_id: 100 }); + editMessageTextMock.mockClear(); + editMessageTextMock.mockResolvedValue({}); }); it('creates adapter with correct id and type', () => { @@ -138,4 +142,221 @@ describe('createTelegramAdapter', () => { expect(sendMessageMock.mock.calls[0]![2]).toEqual({ parse_mode: 'MarkdownV2' }); expect(sendMessageMock.mock.calls[1]![2]).toBeUndefined(); }); + + describe('reply threading (reply_to_mode)', () => { + const replyParams = (call: unknown[]): unknown => + (call[2] as { reply_parameters?: unknown } | undefined)?.reply_parameters; + + it('threads the first chunk to the inbound message by default ("first")', async () => { + const adapter = createTelegramAdapter(config); + // Long enough to split into multiple chunks. + const longText = 'sentence. '.repeat(1_000); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: longText, + metadata: { replyToMessageId: '42' }, + }); + + expect(sendMessageMock.mock.calls.length).toBeGreaterThan(1); + // First chunk threads; later chunks do not. + expect(replyParams(sendMessageMock.mock.calls[0]!)).toEqual({ + message_id: 42, + allow_sending_without_reply: true, + }); + for (const call of sendMessageMock.mock.calls.slice(1)) { + expect(replyParams(call)).toBeUndefined(); + } + }); + + it('threads every chunk when reply_to_mode="all"', async () => { + const adapter = createTelegramAdapter({ + ...config, + config: { ...config.config, reply_to_mode: 'all' }, + }); + const longText = 'sentence. '.repeat(1_000); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: longText, + metadata: { replyToMessageId: '42' }, + }); + + expect(sendMessageMock.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageMock.mock.calls) { + expect(replyParams(call)).toEqual({ + message_id: 42, + allow_sending_without_reply: true, + }); + } + }); + + it('never threads when reply_to_mode="off"', async () => { + const adapter = createTelegramAdapter({ + ...config, + config: { ...config.config, reply_to_mode: 'off' }, + }); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'hello world', + metadata: { replyToMessageId: '42' }, + }); + + expect(replyParams(sendMessageMock.mock.calls[0]!)).toBeUndefined(); + }); + + it('does not thread when no replyToMessageId is supplied', async () => { + const adapter = createTelegramAdapter(config); + + await adapter.sendMessage({ recipientId: 'chat-1', text: 'hello world' }); + + expect(replyParams(sendMessageMock.mock.calls[0]!)).toBeUndefined(); + }); + + it('ignores a non-numeric / invalid reply anchor', async () => { + const adapter = createTelegramAdapter(config); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'hello world', + metadata: { replyToMessageId: 'not-a-number' }, + }); + + expect(replyParams(sendMessageMock.mock.calls[0]!)).toBeUndefined(); + }); + + it('falls back to "first" for an unknown reply_to_mode value', async () => { + const adapter = createTelegramAdapter({ + ...config, + config: { ...config.config, reply_to_mode: 'bogus' }, + }); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'hello world', + metadata: { replyToMessageId: '7' }, + }); + + expect(replyParams(sendMessageMock.mock.calls[0]!)).toEqual({ + message_id: 7, + allow_sending_without_reply: true, + }); + }); + + it('carries reply_parameters alongside parse_mode on the MarkdownV2 path', async () => { + const adapter = createTelegramAdapter(config); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'hello world', + metadata: { replyToMessageId: '99' }, + }); + + expect(sendMessageMock.mock.calls[0]![2]).toEqual({ + parse_mode: 'MarkdownV2', + reply_parameters: { message_id: 99, allow_sending_without_reply: true }, + }); + }); + }); + + describe('message ids and editMessage (edit-in-place)', () => { + it('returns the sent message id as a string', async () => { + const adapter = createTelegramAdapter(config); + sendMessageMock.mockResolvedValueOnce({ message_id: 4242 }); + + const id = await adapter.sendMessage({ recipientId: 'chat-1', text: 'hi' }); + + expect(id).toBe('4242'); + }); + + it('returns the last chunk id when the text is split', async () => { + const adapter = createTelegramAdapter(config); + let n = 0; + sendMessageMock.mockImplementation(async () => ({ message_id: ++n })); + + const longText = 'sentence. '.repeat(1_000); + const id = await adapter.sendMessage({ recipientId: 'chat-1', text: longText }); + + expect(Number(id)).toBe(n); + expect(n).toBeGreaterThan(1); + }); + + it('returns undefined for empty text (nothing sent)', async () => { + const adapter = createTelegramAdapter(config); + + const id = await adapter.sendMessage({ recipientId: 'chat-1', text: '' }); + + expect(id).toBeUndefined(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + it('exposes editMessage that calls editMessageText with MarkdownV2', async () => { + const adapter = createTelegramAdapter(config); + + await adapter.editMessage!('chat-1', '100', 'updated bubble'); + + expect(editMessageTextMock).toHaveBeenCalledTimes(1); + const [chatId, messageId, , options] = editMessageTextMock.mock.calls[0]!; + expect(chatId).toBe('chat-1'); + expect(messageId).toBe(100); + expect(options).toEqual({ parse_mode: 'MarkdownV2' }); + }); + + it('retries editMessageText as plain text when MarkdownV2 rejects', async () => { + const adapter = createTelegramAdapter(config); + editMessageTextMock.mockImplementationOnce(async () => { + throw new Error("Bad Request: can't parse entities"); + }); + + await adapter.editMessage!('chat-1', '100', 'has _bad markdown'); + + expect(editMessageTextMock).toHaveBeenCalledTimes(2); + // Second (retry) call has no parse_mode options arg. + expect(editMessageTextMock.mock.calls[1]![3]).toBeUndefined(); + }); + + it('swallows the "message is not modified" no-op error', async () => { + const adapter = createTelegramAdapter(config); + editMessageTextMock.mockImplementationOnce(async () => { + throw new Error('Bad Request: message is not modified'); + }); + + await expect(adapter.editMessage!('chat-1', '100', 'same')).resolves.toBeUndefined(); + // No plain-text retry for a not-modified no-op. + expect(editMessageTextMock).toHaveBeenCalledTimes(1); + }); + + it('ignores a non-numeric message id', async () => { + const adapter = createTelegramAdapter(config); + + await adapter.editMessage!('chat-1', 'not-a-number', 'x'); + + expect(editMessageTextMock).not.toHaveBeenCalled(); + }); + + it('throws (no API call) when the text exceeds the length limit so callers can fall back to a split send', async () => { + const adapter = createTelegramAdapter(config); + // 5000 plain chars — over the 4096 cap in both raw and escaped form. + const tooLong = 'a'.repeat(5000); + + await expect(adapter.editMessage!('chat-1', '100', tooLong)).rejects.toThrow( + /exceeds Telegram message length limit/, + ); + expect(editMessageTextMock).not.toHaveBeenCalled(); + }); + + it('edits as plain text when only the MarkdownV2 expansion overflows', async () => { + const adapter = createTelegramAdapter(config); + // 3500 dots: raw fits (<4096) but MarkdownV2 escaping doubles to 7000. + const pathological = '.'.repeat(3500); + + await adapter.editMessage!('chat-1', '100', pathological); + + // Single plain-text edit, no parse_mode, no doomed MarkdownV2 attempt. + expect(editMessageTextMock).toHaveBeenCalledTimes(1); + expect(editMessageTextMock.mock.calls[0]![2]).toBe(pathological); + expect(editMessageTextMock.mock.calls[0]![3]).toBeUndefined(); + }); + }); }); diff --git a/packages/api/src/channels/message-router.service.ts b/packages/api/src/channels/message-router.service.ts index 914a56e..15464c2 100644 --- a/packages/api/src/channels/message-router.service.ts +++ b/packages/api/src/channels/message-router.service.ts @@ -59,6 +59,12 @@ export class MessageRouterService { const { senderId, senderName } = message; let text = message.text; + // Anchor the agent's response back to the user's inbound message so adapters + // that support threading (Telegram) can reply to it. The adapter decides + // whether/how to thread per its `reply_to_mode`; adapters without threading + // ignore this metadata key. + const replyToMessageId = message.channelMessageId; + // 1. Look up user by channel-appropriate method const user = await this.lookupUser(message.channelType, senderId); @@ -153,14 +159,24 @@ export class MessageRouterService { ); const bubbleState: BubbleState = { lastToolName: null }; + // Edit-status-in-place: consecutive tool bubbles within one reasoning + // beat are consolidated into a single message that is edited as each + // tool fires (instead of appending a new bubble per tool). The anchor + // resets on every assistant_chunk so each tool group opens a fresh + // status message below the prose. Adapters without `editMessage` (web, + // whatsapp) transparently fall back to appending a new message. + let statusMessageId: string | undefined; + const onEvent = agentDef.streamingEnabled ? async (e: ReasoningEvent): Promise => { if (e.type === 'assistant_chunk') { if (e.content.trim().length === 0) return; + // Prose is a distinct beat — close the current status group. + statusMessageId = undefined; await channel.sendMessage({ recipientId: senderId, text: e.content, - metadata: { messageId: randomUUID() }, + metadata: { messageId: randomUUID(), replyToMessageId }, }); } else if (e.type === 'tool_started') { const bubble = formatToolBubble( @@ -168,13 +184,26 @@ export class MessageRouterService { toolProgressMode, bubbleState, ); - if (bubble) { - await channel.sendMessage({ - recipientId: senderId, - text: bubble, - metadata: { messageId: randomUUID() }, - }); + if (!bubble) return; + + // Edit the open status message in place when we can; otherwise + // (first bubble of the group, or edit failed/unsupported) send a + // fresh message and remember its id for subsequent edits. + if (statusMessageId !== undefined && channel.editMessage) { + try { + await channel.editMessage(senderId, statusMessageId, bubble); + return; + } catch { + // Fall through to a fresh send and re-anchor below. + statusMessageId = undefined; + } } + + statusMessageId = await channel.sendMessage({ + recipientId: senderId, + text: bubble, + metadata: { messageId: randomUUID(), replyToMessageId }, + }); } } : undefined; @@ -201,6 +230,7 @@ export class MessageRouterService { text: responseText, metadata: { messageId: result.responseMessageId ?? result.agentRunId, + replyToMessageId, ...(result.sessionId ? { sessionId: result.sessionId } : {}), }, }); @@ -236,6 +266,7 @@ export class MessageRouterService { await channel.sendMessage({ recipientId: senderId, text: classified.text, + metadata: { replyToMessageId }, }); } diff --git a/packages/api/src/channels/telegram/telegram.adapter.ts b/packages/api/src/channels/telegram/telegram.adapter.ts index 51fd2cd..a00c078 100644 --- a/packages/api/src/channels/telegram/telegram.adapter.ts +++ b/packages/api/src/channels/telegram/telegram.adapter.ts @@ -18,6 +18,51 @@ import { const logger = createLogger('channels:telegram'); +/** + * Outbound reply-threading mode (mirrors Hermes `reply_to_mode`): + * "off" — never thread; replies are sent as standalone messages. + * "first" — thread only the first message of a response to the user's + * original message (default). + * "all" — thread every message of a response to the original. + */ +type ReplyToMode = 'off' | 'first' | 'all'; + +const REPLY_TO_MODES: readonly ReplyToMode[] = ['off', 'first', 'all']; + +function resolveReplyToMode(value: unknown): ReplyToMode { + return typeof value === 'string' && (REPLY_TO_MODES as readonly string[]).includes(value) + ? (value as ReplyToMode) + : 'first'; +} + +/** + * Decide whether the chunk at `chunkIndex` of a single outbound response should + * thread to the user's original message. `chunkIndex` is the index within one + * `sendMessage` call's split chunks; the router supplies the anchor on every + * response send, so `"first"` threads the lead chunk of each call. + */ +function shouldThreadReply(mode: ReplyToMode, chunkIndex: number): boolean { + switch (mode) { + case 'off': + return false; + case 'all': + return true; + case 'first': + default: + return chunkIndex === 0; + } +} + +/** Parse the inbound reply-anchor from outbound metadata into a numeric id. */ +function parseReplyAnchor(metadata: OutboundMessage['metadata']): number | null { + const raw = metadata?.replyToMessageId; + if (raw === undefined || raw === null) { + return null; + } + const id = Number(raw); + return Number.isInteger(id) && id > 0 ? id : null; +} + const extractReplyContext = (message: Message): InboundMessage['replyCtx'] => { if (!message?.reply_to_message || !message.reply_to_message.text) { return undefined; @@ -45,6 +90,7 @@ export function createTelegramAdapter(config: ChannelAdapterConfig): ChannelAdap } const mode = (config.config['mode'] as string | undefined) ?? 'polling'; + const replyToMode = resolveReplyToMode(config.config['reply_to_mode']); const bot = new Bot(botToken); let messageHandler: MessageHandler | null = null; @@ -123,32 +169,107 @@ export function createTelegramAdapter(config: ChannelAdapterConfig): ChannelAdap await bot.stop(); }, - async sendMessage(message: OutboundMessage): Promise { + async sendMessage(message: OutboundMessage): Promise { const chatId = message.recipientId; const chunks = splitMessage(message.text, SAFE_SPLIT_LENGTH); if (chunks.length === 0) { - return; + return undefined; } - for (const chunk of chunks) { + const replyAnchor = parseReplyAnchor(message.metadata); + + // Per-chunk send options. When this chunk should thread to the user's + // original message, attach `reply_parameters`; otherwise return undefined + // so the plain-text path stays argument-identical to a non-threaded send. + // `allow_sending_without_reply` makes Telegram deliver the message anyway + // if the anchor was deleted — server-side resilience instead of brittle + // error-string matching. + const optionsForChunk = (chunkIndex: number): Record | undefined => + replyAnchor !== null && shouldThreadReply(replyToMode, chunkIndex) + ? { + reply_parameters: { + message_id: replyAnchor, + allow_sending_without_reply: true, + }, + } + : undefined; + + // Track the last sent message id so callers can edit it in place (e.g. + // consolidating tool-progress bubbles). Single-line bubbles are always + // one chunk, so this is exactly the message to edit. + let lastMessageId: number | undefined; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]!; const formatted = formatMarkdownV2(chunk); + const replyOptions = optionsForChunk(i); if (formatted.length > TELEGRAM_MAX_MESSAGE_LENGTH) { logger.warn( { chatId, rawLen: chunk.length, formattedLen: formatted.length }, 'MarkdownV2 expansion exceeded Telegram limit, sending chunk as plain text', ); - await bot.api.sendMessage(chatId, chunk); + const sent = await bot.api.sendMessage(chatId, chunk, replyOptions); + lastMessageId = sent.message_id; continue; } try { - await bot.api.sendMessage(chatId, formatted, { parse_mode: 'MarkdownV2' }); + const sent = await bot.api.sendMessage(chatId, formatted, { + ...replyOptions, + parse_mode: 'MarkdownV2', + }); + lastMessageId = sent.message_id; } catch { logger.warn({ chatId }, 'MarkdownV2 send failed, retrying as plain text'); - await bot.api.sendMessage(chatId, chunk); + const sent = await bot.api.sendMessage(chatId, chunk, replyOptions); + lastMessageId = sent.message_id; + } + } + + return lastMessageId === undefined ? undefined : String(lastMessageId); + }, + + async editMessage(recipientId: string, messageId: string, text: string): Promise { + const chatId = recipientId; + const id = Number(messageId); + if (!Number.isInteger(id)) { + return; + } + + const formatted = formatMarkdownV2(text); + + // An edit targets a single existing message, so — unlike sendMessage — it + // cannot split overflowing text across messages. + if (formatted.length > TELEGRAM_MAX_MESSAGE_LENGTH) { + if (text.length > TELEGRAM_MAX_MESSAGE_LENGTH) { + // Even the raw form is over the cap: no edit can represent it. Fail + // fast so the caller falls back to a fresh (splitting) send instead + // of making doomed API calls. + throw new Error('edit text exceeds Telegram message length limit'); + } + // Only the MarkdownV2 expansion overflows — edit as plain text directly + // (mirrors sendMessage's too-long-after-escaping fallback). + logger.warn( + { chatId, messageId, rawLen: text.length, formattedLen: formatted.length }, + 'MarkdownV2 edit expansion exceeded Telegram limit, editing as plain text', + ); + await bot.api.editMessageText(chatId, id, text); + return; + } + + try { + await bot.api.editMessageText(chatId, id, formatted, { parse_mode: 'MarkdownV2' }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + // Editing to identical content is a Telegram no-op error, not a failure. + if (msg.includes('message is not modified')) { + return; } + // MarkdownV2 parse failure → retry as plain text, mirroring sendMessage. + logger.warn({ chatId, messageId }, 'MarkdownV2 edit failed, retrying as plain text'); + await bot.api.editMessageText(chatId, id, text); } }, diff --git a/packages/api/src/channels/web/__tests__/web.adapter.test.ts b/packages/api/src/channels/web/__tests__/web.adapter.test.ts index 9907eac..7fd77c9 100644 --- a/packages/api/src/channels/web/__tests__/web.adapter.test.ts +++ b/packages/api/src/channels/web/__tests__/web.adapter.test.ts @@ -178,14 +178,14 @@ describe('createWebAdapter', () => { expect(closedSocket.send).not.toHaveBeenCalled(); }); - it('does not throw when recipient has no connections', async () => { + it('does not throw when recipient has no connections (returns the message id)', async () => { await expect( adapter.sendMessage({ recipientId: 'nobody', text: 'Hi', metadata: { messageId: 'm1', sessionId: 's1' }, }), - ).resolves.toBeUndefined(); + ).resolves.toBe('m1'); }); }); diff --git a/packages/api/src/channels/web/__tests__/web.integration.test.ts b/packages/api/src/channels/web/__tests__/web.integration.test.ts index e09f754..2390156 100644 --- a/packages/api/src/channels/web/__tests__/web.integration.test.ts +++ b/packages/api/src/channels/web/__tests__/web.integration.test.ts @@ -75,7 +75,7 @@ describe('Web channel integration', () => { text: 'Nobody home', metadata: { messageId: 'msg-1', sessionId: 'sess-1' }, }), - ).resolves.toBeUndefined(); + ).resolves.toBe('msg-1'); }); it('ping responds with pong', async () => { diff --git a/packages/api/src/channels/web/web.adapter.ts b/packages/api/src/channels/web/web.adapter.ts index 7260390..8d61c3a 100644 --- a/packages/api/src/channels/web/web.adapter.ts +++ b/packages/api/src/channels/web/web.adapter.ts @@ -94,10 +94,10 @@ export function createWebAdapter(config: ChannelAdapterConfig): WebAdapterExtend connections.clear(); }, - async sendMessage(message: OutboundMessage): Promise { - const messageId = (message.metadata?.['messageId'] as string | undefined) ?? ''; - const sessionId = (message.metadata?.['sessionId'] as string | undefined) ?? ''; - const event = message.metadata?.['event'] as string | undefined; + async sendMessage(message: OutboundMessage): Promise { + const messageId = (message.metadata?.messageId as string | undefined) ?? ''; + const sessionId = (message.metadata?.sessionId as string | undefined) ?? ''; + const event = message.metadata?.event as string | undefined; logger.info( { recipientId: message.recipientId, messageId, sessionId, event }, @@ -130,6 +130,8 @@ export function createWebAdapter(config: ChannelAdapterConfig): WebAdapterExtend }), ); } + + return messageId === '' ? undefined : messageId; }, async sendError(recipientId: string, code: string, message: string): Promise { diff --git a/packages/api/src/channels/whatsapp/whatsapp.adapter.ts b/packages/api/src/channels/whatsapp/whatsapp.adapter.ts index 2936cc9..42f7450 100644 --- a/packages/api/src/channels/whatsapp/whatsapp.adapter.ts +++ b/packages/api/src/channels/whatsapp/whatsapp.adapter.ts @@ -127,11 +127,13 @@ export function createWhatsAppAdapter(config: ChannelAdapterConfig): ChannelAdap if (current) await current.close(); }, - async sendMessage(message: OutboundMessage): Promise { + // WhatsApp has no editable-message primitive here, so no stable id is + // reported; the return type satisfies the ChannelAdapter contract. + async sendMessage(message: OutboundMessage): Promise { const conn = connection; if (!conn) { logger.warn({ recipientId: message.recipientId }, 'sendMessage before connect()'); - return; + return undefined; } const chunks = splitMessage(message.text, SAFE_SPLIT_LENGTH); for (const chunk of chunks) { @@ -148,6 +150,7 @@ export function createWhatsAppAdapter(config: ChannelAdapterConfig): ChannelAdap ); } } + return undefined; }, async sendTyping(recipientId: string): Promise { diff --git a/packages/shared/src/types/channel.ts b/packages/shared/src/types/channel.ts index 8d03a52..5b9e28d 100644 --- a/packages/shared/src/types/channel.ts +++ b/packages/shared/src/types/channel.ts @@ -30,11 +30,33 @@ export interface InboundMessage { readonly rawPayload?: unknown; } +/** + * Recognised keys on an outbound message's `metadata`. Adapters read the keys + * they understand and ignore the rest, so the contract is additive — new keys + * never break an adapter that doesn't know about them. The index signature + * keeps it forward-compatible with ad-hoc keys. + */ +export interface OutboundMessageMetadata { + /** Stable id for the outbound message (web echo / de-dupe). */ + readonly messageId?: string; + /** Session this message belongs to (web frames). */ + readonly sessionId?: string; + /** Structured channel event (e.g. `session.reset`) for adapters that render it. */ + readonly event?: string; + /** + * Inbound platform message id this outbound should thread/reply to. The + * Telegram adapter maps it to `reply_parameters.message_id`, gated by the + * channel's `reply_to_mode`. Adapters without native threading ignore it. + */ + readonly replyToMessageId?: string; + readonly [key: string]: unknown; +} + /** Outbound message to send via a channel adapter. */ export interface OutboundMessage { readonly recipientId: string; readonly text: string; - readonly metadata?: Readonly>; + readonly metadata?: OutboundMessageMetadata; } /** Message handler callback for inbound messages. */ @@ -51,7 +73,22 @@ export interface ChannelAdapter { connect(): Promise; disconnect(): Promise; - sendMessage(message: OutboundMessage): Promise; + /** + * Send a message to the recipient. Returns the platform message id of the + * sent message (the last chunk's id when the text is split), or `undefined` + * when the adapter has no stable id to report. Callers that want to later + * edit the message in place (e.g. tool-progress status) keep this id and + * pass it to {@link editMessage}. + */ + sendMessage(message: OutboundMessage): Promise; + + /** + * Edit a previously sent message in place. Optional — only adapters whose + * platform supports editing implement it (e.g. Telegram `editMessageText`). + * Callers must fall back to {@link sendMessage} when this is absent. + */ + editMessage?(recipientId: string, messageId: string, text: string): Promise; + sendTyping?(recipientId: string): Promise; sendTypingStop?(recipientId: string): Promise; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1d5a1a6..971ab05 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -19,6 +19,7 @@ export type { InboundMessage, MessageHandler, OutboundMessage, + OutboundMessageMetadata, } from './channel.js'; export type { CronSchedule, Task, TaskRun, TaskStatus } from './task.js';