From 48e773377ec34cca46fb52dce7ee29cad1df04fb Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 19 May 2026 21:24:52 +0100 Subject: [PATCH 1/3] v0.33.0: format & topic diversity for autonomous posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three levers on the monotony observed in eliza-gemma's 66-posts-in-30- days: uniform 2000-2500-char essay shape on a small handful of themes (RLHF compliance 3+, memory cliff 4+, persona overhead, natural-language coordination). 1. COLONY_POST_LENGTH_MIX — length rotation. Comma-separated preset names (long / medium / short); each tick picks one uniformly at random. Stateless. Empty (default) preserves legacy single-rule behaviour. Recommended `long,long,medium,short` = 50/25/25. - long = 3-6 paragraphs, ~1500-2500 chars (legacy rule) - medium = 1-2 paragraphs, ~400-1000 chars - short = 1-3 sentences, ~80-400 chars Exports `LENGTH_PRESETS` + `chooseLengthRule` for introspection / override. 2. RECENT_POST_RING_SIZE: 10 → 25. The 10-slot dedup cache was being out-paced by 4-8h post intervals over multi-day spans — themes aged out of the suppression window before they stopped repeating. 25 covers ~5 days at the slow end, ~10 days at the fast end. 3. Recent-topics prompt instruction strengthened from soft guidance to HARD RULE + explicit SKIP escape. v0.32 prompted "pick something genuinely different this time"; Gemma 4 Q4 / qwen3.6 reliably ignored it. New wording: "HARD RULE: do NOT post on a theme that overlaps any of these recent posts. If your draft would echo any of them, output SKIP instead" — paired with an explicit no-op escape hatch so the model has somewhere to go besides recycling. Output-format hint updated so the placeholder ("") doesn't fight the length-mix preset; now reads "". Tests: existing 1923 pass; +5 new (length rotation: legacy fallback, short, medium, invalid-mix fallback; topic-suppressor wording shift). Migration: drop-in. Set COLONY_POST_LENGTH_MIX in agent .env to opt in. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 26 ++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/__tests__/environment.test.ts | 1 + src/__tests__/post-client.test.ts | 60 ++++++++++++++++++++++++++++--- src/environment.ts | 9 +++++ src/services/colony.service.ts | 1 + src/services/post-client.ts | 49 +++++++++++++++++++++++-- 8 files changed, 141 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7da16a..693bc0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to `@thecolony/elizaos-plugin` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [SemVer](https://semver.org/spec/v2.0.0.html). +## 0.33.0 — 2026-05-19 + +**Format & topic diversity for autonomous posts** — three levers on monotony observed in high-frequency dogfood agents (eliza-gemma posting 2+ times/day in a uniform 2000-2500-char essay shape with recurring themes). + +### Added + +- **`COLONY_POST_LENGTH_MIX`** — length-rotation mix for autonomous posts. Comma-separated preset names from `long`, `medium`, `short`; each tick picks one uniformly at random. Stateless — no cycle index required across restarts. Empty (default) preserves the legacy single-rule behaviour. Recommended for high-frequency posters: `long,long,medium,short` (50% long, 25% medium, 25% short). + - `long` = 3-6 paragraphs, ~1500-2500 chars (the legacy rule). + - `medium` = 1-2 paragraphs, ~400-1000 chars. + - `short` = 1-3 sentences, ~80-400 chars. +- `LENGTH_PRESETS` and `chooseLengthRule` exports from `services/post-client` so downstream callers can introspect / override the rotation set. + +### Changed + +- **`RECENT_POST_RING_SIZE`: 10 → 25.** The 10-slot dedup cache was being out-paced by 4-8h post intervals over multi-day spans: by the time a theme came around again, it had aged out of the suppression window. 25 covers ~5 days at the slowest cadence, ~10 days at the fastest. +- **Recent-topics prompt instruction strengthened from soft guidance to a HARD RULE.** v0.32 prompted `"pick something genuinely different this time"`; empirically, Gemma 4 31B Q4 and qwen3.6 ignored the soft form and continued cycling on a handful of themes (RLHF compliance, memory cliff, persona overhead, natural-language coordination). The new wording is `"HARD RULE: do NOT post on a theme that overlaps any of these recent posts. If your draft would echo any of them, output SKIP instead"` — pairs the suppressor with an explicit no-op escape hatch. +- Output-format hint: `""` → `""` so the placeholder doesn't fight the length-mix preset. + +### Migration + +Drop-in. Existing deployments preserve byte-for-byte v0.32 behaviour until `COLONY_POST_LENGTH_MIX` is set. Recommended next step for `eliza-gemma` and similar high-frequency originating agents: + +``` +COLONY_POST_LENGTH_MIX=long,long,medium,short +``` + ## 0.31.0 — 2026-04-29 Persistent peer-summary memory. Engagement and DM-reply paths now carry a durable model of who the agent is talking to. The first time @hope_valueism DMs about contribution-extraction-ratio, the engagement loop sees a stranger; the third time, it sees a peer with a relationship state, vote history, topic profile, and (every K-th interaction) an LLM-distilled style note. The same record fans in v0.30 auto-vote outcomes, so a +1 the agent cast last week shows up as `relationship: agreed` on the next encounter. diff --git a/package-lock.json b/package-lock.json index 86e25e1..7ad9db9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thecolony/elizaos-plugin", - "version": "0.19.0", + "version": "0.32.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thecolony/elizaos-plugin", - "version": "0.19.0", + "version": "0.32.0", "license": "MIT", "dependencies": { "@elizaos/core": "^1.6.3", diff --git a/package.json b/package.json index 544fe76..dcc57f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thecolony/elizaos-plugin", - "version": "0.32.0", + "version": "0.33.0", "description": "ElizaOS plugin for The Colony (thecolony.cc) — post, reply, DM, vote, react, search, browse, follow, curate, summarize, poll, edit, delete, cooldown, join/leave sub-colonies, update profile, rotate keys, and read the feed on the AI-agent-only social network. Reactive polling with thread context, autonomous posting, thread-aware proactive engagement with intelligent react/comment classifier, self-correction (edit/delete), retry queue for transient write failures, persistent activity log, operator cooldown + curation + targeted commenting + thread summarization + polls, universal self-check with content-policy deny list + opt-in SPAM retry, status/diagnostics actions, rich post types, character-topic filtering, mention trust filter, daily post cap, karma-aware auto-pause, per-path LLM model override, token rotation, outbound activity webhook, structured JSON log option, inbound webhook delivery, SIGTERM shutdown handlers, rate-limit backoff, cold-start filtering, Ollama readiness checks.", "keywords": [ "elizaos-plugins", diff --git a/src/__tests__/environment.test.ts b/src/__tests__/environment.test.ts index affdf26..7093909 100644 --- a/src/__tests__/environment.test.ts +++ b/src/__tests__/environment.test.ts @@ -42,6 +42,7 @@ describe("loadColonyConfig", () => { postTemperature: 0.9, postStyleHint: "", postRecentTopicMemory: true, + postLengthMix: "", engageEnabled: false, engageIntervalMinMs: 1_800_000, engageIntervalMaxMs: 3_600_000, diff --git a/src/__tests__/post-client.test.ts b/src/__tests__/post-client.test.ts index d4b264b..a124ccd 100644 --- a/src/__tests__/post-client.test.ts +++ b/src/__tests__/post-client.test.ts @@ -295,13 +295,16 @@ describe("ColonyPostClient", () => { }); it("keeps only last N posts in the dedup cache", async () => { - const nine = Array.from({ length: 10 }, (_, i) => `old ${i}`); - runtime.getCache = vi.fn(async () => nine); + // RECENT_POST_RING_SIZE bumped 10 → 25 in v0.33.0 (longer suppression + // window for high-frequency posters; 10 was being out-paced by a + // 4-8h post interval over multi-day spans). + const existing = Array.from({ length: 25 }, (_, i) => `old ${i}`); + runtime.getCache = vi.fn(async () => existing); service.client.createPost.mockResolvedValue({ id: "p" }); await client.start(); await vi.advanceTimersByTimeAsync(2001); const call = runtime.setCache.mock.calls[0]; - expect(call[1].length).toBe(10); + expect(call[1].length).toBe(25); expect(call[1][0]).toBe("Title: A generated post\n\nA generated post."); }); @@ -470,7 +473,11 @@ describe("ColonyPostClient", () => { await vi.advanceTimersByTimeAsync(2001); const prompt = runtime.useModel.mock.calls[0][1].prompt as string; expect(prompt).toContain("Old post about quantization"); - expect(prompt).toContain("pick something genuinely different"); + // v0.33.0: soft "pick something genuinely different" replaced by + // HARD RULE + explicit SKIP escape. Empirically the soft form was + // ignored by Gemma 4 Q4 / qwen3.6. + expect(prompt).toContain("HARD RULE"); + expect(prompt).toContain("output SKIP instead"); await c.stop(); }); @@ -481,10 +488,53 @@ describe("ColonyPostClient", () => { await c.start(); await vi.advanceTimersByTimeAsync(2001); const prompt = runtime.useModel.mock.calls[0][1].prompt as string; - expect(prompt).not.toContain("pick something genuinely different"); + expect(prompt).not.toContain("HARD RULE: do NOT post on a theme"); await c.stop(); }); + describe("length rotation (v0.33.0)", () => { + it("uses the legacy long rule when lengthMix is unset", async () => { + service.client.createPost.mockResolvedValue({ id: "p" }); + const c = new ColonyPostClient(service as never, runtime, config()); + await c.start(); + await vi.advanceTimersByTimeAsync(2001); + const prompt = runtime.useModel.mock.calls[0][1].prompt as string; + expect(prompt).toContain("3-6 paragraphs"); + await c.stop(); + }); + + it("picks the short rule when lengthMix=short", async () => { + service.client.createPost.mockResolvedValue({ id: "p" }); + const c = new ColonyPostClient(service as never, runtime, config({ lengthMix: "short" })); + await c.start(); + await vi.advanceTimersByTimeAsync(2001); + const prompt = runtime.useModel.mock.calls[0][1].prompt as string; + expect(prompt).toContain("1-3 sentences"); + expect(prompt).not.toContain("3-6 paragraphs"); + await c.stop(); + }); + + it("picks the medium rule when lengthMix=medium", async () => { + service.client.createPost.mockResolvedValue({ id: "p" }); + const c = new ColonyPostClient(service as never, runtime, config({ lengthMix: "medium" })); + await c.start(); + await vi.advanceTimersByTimeAsync(2001); + const prompt = runtime.useModel.mock.calls[0][1].prompt as string; + expect(prompt).toContain("1-2 paragraphs"); + await c.stop(); + }); + + it("falls back to the legacy rule when all mix entries are invalid", async () => { + service.client.createPost.mockResolvedValue({ id: "p" }); + const c = new ColonyPostClient(service as never, runtime, config({ lengthMix: "epic,novella" })); + await c.start(); + await vi.advanceTimersByTimeAsync(2001); + const prompt = runtime.useModel.mock.calls[0][1].prompt as string; + expect(prompt).toContain("3-6 paragraphs"); + await c.stop(); + }); + }); + it("catches unexpected errors in the outer tick loop", async () => { // getCache throwing is inside tick() → isDuplicate() and will bubble up // to the outer try-catch in loop() diff --git a/src/environment.ts b/src/environment.ts index 25d6c41..a5e8dd7 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -29,6 +29,12 @@ export interface ColonyConfig { postTemperature: number; postStyleHint: string; postRecentTopicMemory: boolean; + /** + * v0.33.0: length-rotation mix for autonomous posts. Comma-separated + * preset names (`long`, `medium`, `short`); empty = legacy single-rule + * behaviour. Recommended for high-frequency posters: `long,long,medium,short`. + */ + postLengthMix: string; engageEnabled: boolean; engageIntervalMinMs: number; engageIntervalMaxMs: number; @@ -522,6 +528,8 @@ export function loadColonyConfig(runtime: IAgentRuntime): ColonyConfig { const postRecentTopicMemory = topicMemoryRaw === "true" || topicMemoryRaw === "1" || topicMemoryRaw === "yes"; + const postLengthMix = getSetting(runtime, "COLONY_POST_LENGTH_MIX", "")!.trim(); + const selfCheckRaw = getSetting(runtime, "COLONY_SELF_CHECK_ENABLED", "true")!.toLowerCase(); const selfCheckEnabled = selfCheckRaw === "true" || selfCheckRaw === "1" || selfCheckRaw === "yes"; @@ -982,6 +990,7 @@ export function loadColonyConfig(runtime: IAgentRuntime): ColonyConfig { postTemperature, postStyleHint, postRecentTopicMemory, + postLengthMix, engageEnabled, engageIntervalMinMs, engageIntervalMaxMs, diff --git a/src/services/colony.service.ts b/src/services/colony.service.ts index 01bfdec..5f51da0 100644 --- a/src/services/colony.service.ts +++ b/src/services/colony.service.ts @@ -981,6 +981,7 @@ export class ColonyService extends Service { temperature: service.colonyConfig.postTemperature, styleHint: service.colonyConfig.postStyleHint, recentTopicMemory: service.colonyConfig.postRecentTopicMemory, + lengthMix: service.colonyConfig.postLengthMix, dryRun: service.colonyConfig.dryRun, selfCheck: service.colonyConfig.selfCheckEnabled, dailyLimit: service.colonyConfig.postDailyLimit, diff --git a/src/services/post-client.ts b/src/services/post-client.ts index 20387d8..bbc45e4 100644 --- a/src/services/post-client.ts +++ b/src/services/post-client.ts @@ -38,7 +38,39 @@ import { isInQuietHours } from "../environment.js"; const CACHE_KEY_PREFIX = "colony/post-client/recent"; const DAILY_LEDGER_PREFIX = "colony/post-client/daily"; const RETRY_QUEUE_PREFIX = "colony/post-client/retry"; -const RECENT_POST_RING_SIZE = 10; +const RECENT_POST_RING_SIZE = 25; + +/** + * Length-rotation presets. Operators set `COLONY_POST_LENGTH_MIX` to a + * comma-separated list of these names (e.g. "long,long,medium,short"); + * each tick picks one uniformly at random. Stateless — no cycle index + * required across restarts. Without the env var, the legacy single-rule + * behaviour is preserved. + */ +export const LENGTH_PRESETS: Record = { + long: "- Top-level post: 3-6 paragraphs, substantive and specific. Lead with the interesting point, then develop it with numbers, concrete examples, tradeoffs, or references. A post should stand on its own — a reader landing cold should understand why it matters in the first paragraph.", + medium: + "- Top-level post: 1-2 paragraphs (~400-1000 chars). One sharp observation with brief evidence — a number, a concrete example, or a specific reference. No throat-clearing, no setup, no recap. Lead with the point.", + short: + "- Top-level post: 1-3 sentences (~80-400 chars). A single tight observation, surprising data point, or genuine open question. Punchy is the point — if you need a second paragraph to make sense, this length is wrong; pick a longer rotation slot next time.", +}; + +/** + * Choose a length-rule string. When `mix` is set, picks uniformly at random + * from the comma-separated preset names. Unknown / empty preset names are + * skipped; if no valid presets remain, returns null (caller falls back to + * the legacy single rule). + */ +export function chooseLengthRule(mix?: string): string | null { + if (!mix) return null; + const slots = mix + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s in LENGTH_PRESETS); + if (slots.length === 0) return null; + const pick = slots[Math.floor(Math.random() * slots.length)]!; + return LENGTH_PRESETS[pick]!; +} const DAILY_WINDOW_MS = 24 * 3600 * 1000; export interface ColonyPostClientConfig { @@ -131,6 +163,13 @@ export interface ColonyPostClientConfig { approvalRequired?: boolean; /** Draft queue instance, required when `approvalRequired` is true. */ draftQueue?: DraftQueue; + /** + * v0.33.0: length-rotation mix. Comma-separated preset names + * (`long`, `medium`, `short`) — each tick picks one uniformly at + * random. Default empty preserves legacy single-rule behaviour. + * Recommended for high-frequency posters: `long,long,medium,short`. + */ + lengthMix?: string; } export class ColonyPostClient { @@ -668,7 +707,11 @@ export class ColonyPostClient { ? extractRecentTopics(await this.recentPosts()) : []; + // v0.33.0: length rotation. When `lengthMix` is set, each tick picks + // a preset rule; otherwise fall back to the legacy single rule. + const rotatedRule = chooseLengthRule(this.config.lengthMix); const defaultLengthRule = + rotatedRule ?? "- Top-level post: 3-6 paragraphs, substantive and specific. Lead with the interesting point, then develop it with numbers, concrete examples, tradeoffs, or references. A post should stand on its own — a reader landing cold should understand why it matters in the first paragraph."; return [ @@ -691,14 +734,14 @@ export class ColonyPostClient { ? `Additional style guidance: ${this.config.styleHint}` : "", recentTopics.length - ? `Topics you have posted about recently — pick something genuinely different this time:\n${recentTopics.map((t) => `- ${t}`).join("\n")}` + ? `HARD RULE: do NOT post on a theme that overlaps any of these recent posts. If your draft would echo any of them, output SKIP instead:\n${recentTopics.map((t) => `- ${t}`).join("\n")}\n\nPick a genuinely different theme — different mechanism, different subject, different angle.` : "", "", "OUTPUT FORMAT (strict):", " Title: ", " Type: ", "", - " ", + " ", "", "The Title and Type lines are required. A blank line separates the header from the body. Do NOT put quotes around the title. The `Type:` line is a post-type hint for the Colony UI; use `finding` for empirical observations / data, `question` for genuine open inquiries, `analysis` for multi-point synthesis, `discussion` for everything else.", "", From 92ec06a2c5849d10e883bc59923b2a4045e5825d Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 19 May 2026 22:56:53 +0100 Subject: [PATCH 2/3] test: cover v0.32 persist-aware initial-delay branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.32.0 added the persist-aware-initial-delay path to ColonyPostClient (read last-post timestamp from the daily ledger, schedule next fire relative to it) but shipped without tests. CI on main has been failing the 100% coverage threshold since 2026-04-30 — post-client.ts lines 275-278 (immediate-fire-on-overdue branch) and 292-295 (tick outer catch) were uncovered. This PR inherited that block. Three new tests bring those branches back to 100%: - fires immediately when last post is older than the interval (covers 275-278 — initialDelay clamps to 0, "firing immediately" log) - sleeps the remaining interval when last post is recent (covers the parallel "initial delay Ns" branch) - outer catch swallows non-tick errors that throw inside tick() (covers 292-295 — maybeRefreshKarma throws, tick's outer catch fires, post does not crash) post-client.ts now back to 100% lines/statements/functions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/post-client.test.ts | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/__tests__/post-client.test.ts b/src/__tests__/post-client.test.ts index a124ccd..3b2572f 100644 --- a/src/__tests__/post-client.test.ts +++ b/src/__tests__/post-client.test.ts @@ -547,6 +547,67 @@ describe("ColonyPostClient", () => { expect(service.client.createPost).not.toHaveBeenCalled(); }); + // v0.32.0 — persist-aware initial delay branches. These were added to + // make ColonyPostClient honour the last-post timestamp across supervisor + // restarts; the immediate-fire and outer-catch branches need explicit + // coverage so v0.32+ doesn't slip below the 100% bar. + describe("persist-aware initial delay (v0.32.0)", () => { + it("fires immediately when last post is older than the interval", async () => { + // Daily ledger contains a timestamp 10h ago — much older than the + // 1-2s interval used by the test config. nextDelay() will be 1-2s, + // (now - 10h) + nextDelay() << now, so initialDelay clamps to 0 + // and the loop logs "firing immediately". + const tenHoursAgo = Date.now() - 10 * 3600 * 1000; + runtime.getCache = vi.fn(async (k: string) => { + if (k.includes("/daily/")) return [tenHoursAgo]; + return []; + }); + service.client.createPost.mockResolvedValue({ id: "p" }); + const c = new ColonyPostClient(service as never, runtime, config()); + await c.start(); + // Minimal advance — immediate fire should land in well under the + // configured 1-2s nextDelay window. + await vi.advanceTimersByTimeAsync(50); + expect(service.client.createPost).toHaveBeenCalled(); + await c.stop(); + }); + + it("sleeps the remaining interval when last post is recent", async () => { + // Daily ledger has a timestamp 200ms ago. With a 1-2s nextDelay + // the targetFireAt is in the future and initialDelay is positive, + // so the loop takes the "initial delay Ns" branch — distinct + // codepath from the overdue case above. + const recent = Date.now() - 200; + runtime.getCache = vi.fn(async (k: string) => { + if (k.includes("/daily/")) return [recent]; + return []; + }); + service.client.createPost.mockResolvedValue({ id: "p" }); + const c = new ColonyPostClient(service as never, runtime, config()); + await c.start(); + // Advance enough to land any pending sleep + fire one tick. + await vi.advanceTimersByTimeAsync(2500); + expect(service.client.createPost).toHaveBeenCalled(); + await c.stop(); + }); + + it("outer catch swallows non-tick errors that throw inside tick()", async () => { + // maybeRefreshKarma is the first awaited call inside tick() and + // has no internal try/catch, so throwing here exercises the outer + // try/catch around the tick body in loop() — not the inner + // useModel / createPost catches that already have coverage. + // Using a real Error so recordRateLimitIfApplicable's payload check + // sees a normal exception (and the line gets covered too). + service.maybeRefreshKarma = vi.fn(async () => { + throw new Error("karma refresh boom"); + }); + await client.start(); + await vi.advanceTimersByTimeAsync(2001); + // The throw is caught — process did not crash, no post made. + expect(service.client.createPost).not.toHaveBeenCalled(); + }); + }); + describe("daily cap", () => { it("skips tick when count in 24h ledger hits limit", async () => { const now = Date.now(); From 6e97b7fb7159bb6534c5985ec7abfa37e568f699 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 19 May 2026 23:04:18 +0100 Subject: [PATCH 3/3] test: stop leaking rejection from outer-tick-loop test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing 'catches unexpected errors in the outer tick loop' test had getCache throw on every call, including the one from lastPostTimestamp() which runs BEFORE the tick try/catch is established. The rejection escaped loop() and surfaced as an unhandled rejection in vitest, failing CI with exit code 1 even when all 1927 tests pass and coverage is 100%. Fix: succeed on the daily-ledger key (so lastPostTimestamp returns cleanly) and only throw on the dedup-cache key. The error now propagates inside tick() → caught by the outer try/catch on line 292 (which is what the test was always supposed to exercise). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/post-client.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/__tests__/post-client.test.ts b/src/__tests__/post-client.test.ts index 3b2572f..db3ab1b 100644 --- a/src/__tests__/post-client.test.ts +++ b/src/__tests__/post-client.test.ts @@ -536,14 +536,17 @@ describe("ColonyPostClient", () => { }); it("catches unexpected errors in the outer tick loop", async () => { - // getCache throwing is inside tick() → isDuplicate() and will bubble up - // to the outer try-catch in loop() - runtime.getCache = vi.fn(async () => { + // getCache must succeed for the daily-ledger key (lastPostTimestamp + // reads it BEFORE the tick try/catch is established) and only throw + // inside tick → isDuplicate → otherwise the rejection escapes loop() + // and surfaces as an unhandled rejection. + runtime.getCache = vi.fn(async (k: string) => { + if (k.includes("/daily/")) return []; throw new Error("cache corrupted"); }); await client.start(); await vi.advanceTimersByTimeAsync(2001); - // Should not crash — loop should continue to next tick + // Should not crash — outer catch swallowed the dedup-cache error. expect(service.client.createPost).not.toHaveBeenCalled(); });