From 865068b71c4aa8b7c511539cc6ef05317fac0c29 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 18 Feb 2026 10:14:45 -0800 Subject: [PATCH] feat: add announce delivery mode for automation runs --- README.md | 5 +++-- config/default.yaml | 1 + docs/plans/state.json | 22 ++++++++++++++++++++-- src/automation/cron.test.ts | 16 ++++++++++++++++ src/automation/cron.ts | 9 +++++++-- src/automation/webhooks.test.ts | 20 ++++++++++++++++++++ src/automation/webhooks.ts | 9 +++++++-- src/config/schema.test.ts | 10 ++++++++++ src/config/schema.ts | 2 +- 9 files changed, 85 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a196cfd..20f4d7e 100644 --- a/README.md +++ b/README.md @@ -644,6 +644,7 @@ Schedule automated messages on cron schedules. Each job fires an inbound message Set `automation.delivery_mode` to control automation session behavior: - `shared_session` (default): reuse one session per cron job/webhook name. - `isolated_job`: create a fresh session per cron trigger/webhook request. +- `announce`: create a fresh ephemeral announce-style run per trigger (no shared automation history). ```yaml automation: @@ -704,7 +705,7 @@ automation: | Field | Required | Description | |-------|----------|-------------| -| `automation.delivery_mode` | no | Automation session strategy: `shared_session` or `isolated_job` (default: `shared_session`) | +| `automation.delivery_mode` | no | Automation session strategy: `shared_session`, `isolated_job`, or `announce` (default: `shared_session`) | | `name` | yes | Unique job identifier | | `schedule` | yes | Cron expression (standard 5-field) | | `message` | yes | Text sent to the agent when the job fires | @@ -803,7 +804,7 @@ Webhooks are available at `POST /webhooks/:name` on the gateway HTTP server. The | Field | Required | Description | |-------|----------|-------------| -| `automation.delivery_mode` | no | Automation session strategy: `shared_session` or `isolated_job` (default: `shared_session`) | +| `automation.delivery_mode` | no | Automation session strategy: `shared_session`, `isolated_job`, or `announce` (default: `shared_session`) | | `name` | yes | Unique webhook identifier (used in URL path) | | `secret` | no | HMAC secret for `X-Webhook-Signature` header verification (SHA-256) | | `message` | no | Template for the message sent to the agent (default: `{{body}}`) | diff --git a/config/default.yaml b/config/default.yaml index 0ac5980..48fad23 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -324,6 +324,7 @@ hooks: # automation: # # shared_session: keep one session per cron job/webhook name. # # isolated_job: create a fresh session per cron trigger/webhook request. +# # announce: create a fresh announce-style run per trigger (no shared automation history). # delivery_mode: shared_session # cron: # - name: daily-summary diff --git a/docs/plans/state.json b/docs/plans/state.json index aecfb60..4c2de8f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5196,10 +5196,28 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/session-bridge.test.ts src/gateway/handlers/agent.test.ts + pnpm typecheck passing" + }, + "automation-announce-delivery-mode": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Implemented Tier A3 proactive announce delivery mode as a first-class automation option (`automation.delivery_mode: announce`) for cron/webhooks. Added explicit announce-mode sender/metadata wiring, schema/docs/default-config updates, and regression tests.", + "files_modified": [ + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/automation/cron.ts", + "src/automation/cron.test.ts", + "src/automation/webhooks.ts", + "src/automation/webhooks.test.ts", + "README.md", + "config/default.yaml", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/automation/cron.test.ts src/automation/webhooks.test.ts src/config/schema.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 1908, + "total_test_count": 1911, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -5219,7 +5237,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Implement Tier A3 from the OpenClaw roadmap: proactive announce delivery mode for automation jobs" + "next_up": "Implement Tier A4 from the OpenClaw roadmap: TTS voice output with channel-aware audio responses" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/automation/cron.test.ts b/src/automation/cron.test.ts index 929380f..96ff828 100644 --- a/src/automation/cron.test.ts +++ b/src/automation/cron.test.ts @@ -98,6 +98,22 @@ describe('CronScheduler', () => { expect(messages[0].metadata?.deliveryMode).toBe('isolated_job'); }); + it('uses announce sender IDs and metadata when delivery mode is announce', async () => { + const jobs = [makeCronJob()]; + scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry), 'announce'); + + const messages: InboundMessage[] = []; + scheduler.onMessage((msg: InboundMessage) => messages.push(msg)); + await scheduler.connect(); + scheduler.triggerJob('test-job'); + + expect(messages).toHaveLength(1); + expect(messages[0].senderId).toMatch(/^test-job:announce:run-/); + expect(messages[0].metadata?.replyPeerId).toBe('test-job'); + expect(messages[0].metadata?.deliveryMode).toBe('announce'); + expect(messages[0].metadata?.announce).toBe(true); + }); + it('forwards response to output channel on send()', async () => { const mockOutputAdapter = { send: vi.fn().mockResolvedValue(undefined), diff --git a/src/automation/cron.ts b/src/automation/cron.ts index b92221f..d06432a 100644 --- a/src/automation/cron.ts +++ b/src/automation/cron.ts @@ -9,7 +9,7 @@ interface ChannelLookup { get(name: string): { send(peerId: string, message: OutboundMessage): Promise } | undefined; } -type DeliveryMode = 'shared_session' | 'isolated_job'; +type DeliveryMode = 'shared_session' | 'isolated_job' | 'announce'; export class CronScheduler implements ChannelAdapter { readonly name = 'cron'; @@ -99,7 +99,11 @@ export class CronScheduler implements ChannelAdapter { this.lastTriggeredLocalDateByJob.set(jobName, dayKey); } const runId = `run-${randomUUID()}`; - const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName; + const senderId = this.deliveryMode === 'shared_session' + ? jobName + : this.deliveryMode === 'announce' + ? `${jobName}:announce:${runId}` + : `${jobName}:${runId}`; const msg: InboundMessage = { id: `cron-${jobName}-${Date.now()}`, @@ -113,6 +117,7 @@ export class CronScheduler implements ChannelAdapter { scheduled: true, modelTier: job.model_tier, deliveryMode: this.deliveryMode, + announce: this.deliveryMode === 'announce', runId, replyPeerId: jobName, }, diff --git a/src/automation/webhooks.test.ts b/src/automation/webhooks.test.ts index 36e93a0..53c73d1 100644 --- a/src/automation/webhooks.test.ts +++ b/src/automation/webhooks.test.ts @@ -137,6 +137,26 @@ describe('WebhookHandler', () => { expect(messages[0].metadata?.deliveryMode).toBe('isolated_job'); }); + it('handleRequest uses announce sender IDs and metadata when delivery mode is announce', async () => { + const webhooks = [makeWebhook()]; + handler = new WebhookHandler(webhooks, asWebhookChannelLookup(mockChannelRegistry), 'announce'); + + const messages: InboundMessage[] = []; + handler.onMessage((msg: InboundMessage) => messages.push(msg)); + await handler.connect(); + + const req = mockRequest('hello world'); + const res = mockResponse(); + const result = await handler.handleRequest('test-hook', req, res); + + expect(result).toBe(true); + expect(messages).toHaveLength(1); + expect(messages[0].senderId).toMatch(/^test-hook:announce:run-/); + expect(messages[0].metadata?.replyPeerId).toBe('test-hook'); + expect(messages[0].metadata?.deliveryMode).toBe('announce'); + expect(messages[0].metadata?.announce).toBe(true); + }); + it('returns false for unknown webhook', async () => { handler = new WebhookHandler([], asWebhookChannelLookup(mockChannelRegistry)); await handler.connect(); diff --git a/src/automation/webhooks.ts b/src/automation/webhooks.ts index d8bfcf3..51797f1 100644 --- a/src/automation/webhooks.ts +++ b/src/automation/webhooks.ts @@ -10,7 +10,7 @@ interface ChannelLookup { get(name: string): { send(peerId: string, message: OutboundMessage): Promise } | undefined; } -type DeliveryMode = 'shared_session' | 'isolated_job'; +type DeliveryMode = 'shared_session' | 'isolated_job' | 'announce'; /** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */ function verifyHmac(body: string, secret: string, signature: string): boolean { @@ -162,7 +162,11 @@ export class WebhookHandler implements ChannelAdapter { // Render message template const text = renderTemplate(webhook.message, body); const runId = `run-${randomUUID()}`; - const senderId = this.deliveryMode === 'isolated_job' ? `${webhookName}:${runId}` : webhookName; + const senderId = this.deliveryMode === 'shared_session' + ? webhookName + : this.deliveryMode === 'announce' + ? `${webhookName}:announce:${runId}` + : `${webhookName}:${runId}`; const msg: InboundMessage = { id: `webhook-${webhookName}-${Date.now()}`, @@ -175,6 +179,7 @@ export class WebhookHandler implements ChannelAdapter { webhookName, body, deliveryMode: this.deliveryMode, + announce: this.deliveryMode === 'announce', runId, replyPeerId: webhookName, }, diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 014526d..67f5ddd 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1059,6 +1059,16 @@ describe('configSchema automation', () => { expect(result.automation.delivery_mode).toBe('isolated_job'); }); + it('accepts announce automation delivery mode', () => { + const result = configSchema.parse({ + ...baseConfig, + automation: { + delivery_mode: 'announce', + }, + }); + expect(result.automation.delivery_mode).toBe('announce'); + }); + it('accepts config with cron jobs', () => { const result = configSchema.parse({ ...baseConfig, diff --git a/src/config/schema.ts b/src/config/schema.ts index c495eb0..daee92c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -417,7 +417,7 @@ const minioSyncAutomationSchema = z.object({ notify_on_success: z.boolean().default(false), }).default({}); -const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job']); +const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job', 'announce']); const automationSchema = z.object({ /** Session strategy for automation-triggered runs (cron/webhooks/gmail). */