696cf7abb8
* fix: prompt dedup, double summarize, docker-mode stop, hermetic tests - observe: hash the hook payload when tool_input is absent so prompt_submit, notification, and lifecycle events dedup on content instead of collapsing onto one shared key that silently dropped every prompt after the first in a TTL window (#1173) - stop hook: drop the direct /agentmemory/summarize POST; /session/end already fans out event::session::stopped which runs mem::summarize, so every Stop dispatched two full summarizes (#1203) - cli: refuse to adopt or signal Docker/VM port holders (com.docker.backend, vpnkit, colima, ...) as the native engine unless --force; scope Docker-mode teardown to agentmemory's own compose services via rm -s -f instead of an unscoped down; reap the native worker before Docker teardown instead of deleting worker.pid with the process still running (#1151) - tests: isolate HOME/USERPROFILE for the whole vitest run so suites stop reading the developer's real ~/.agentmemory/.env (#1178) * fix(viewer): live stream port discovery, fresh tab data, honest states - resolve the stream WebSocket target from /agentmemory/livez (new streamsPort field) instead of viewerPort-1 arithmetic, which pointed at the wrong server whenever the viewer bound a fallback port and silently degraded live updates to 10s polling — verified reaching 'live' on the fallback-port case - refetch tab data on every tab entry; the loaded-once cache meant a memory saved by the agent never appeared until a hard browser reload (loading placeholders now render only on first load, so background refreshes don't flash) - memories: rows expand on click/Enter to the full stored record — content, id, project, created, supersedes, files — plus a collapsible raw JSON view - graph: a 503 with the structured disabled body renders 'Knowledge graph is off' with the enableHow text and docs link instead of a 'query failed / Retry' error that sends users to server logs - sessions: cards get role=button, tabindex, Enter/Space activation, and the detail panel scrolls into view on select; session ids truncate head…tail so the distinguishing suffix stays visible - style search inputs and toolbar buttons on lessons/actions/crystals/ replay (previously bare native controls); horizontal scroll containment for narrow viewports - demo: only print the semantic-recall success notice when the search actually hit; on 0 hits explain the missing embedding key instead * fix: thread agentId/project through save paths, per-session OpenCode scope - REST /agentmemory/remember accepts and forwards agentId to mem::remember; it previously dropped the field so per-request multi-agent scoping was impossible over REST (#1159) - memoryToObservation() carries the memory's agentId into the search-index shape; dropping it made every memory invisible to agent-scoped search (#1160) - MCP memory_save path: the tool schema now exposes agentId, the in-worker MCP server forwards it, and the standalone stdio package parses and forwards both agentId and project — the stdio pipeline previously dropped project even though its schema advertised it (#1197) - opencode plugin: project/cwd attribution is per-session (resolved from the session's own directory at session.created, pruned on session end) instead of module-level state that recorded every session in a multi-directory OpenCode process under whichever repo loaded the plugin first (#1188) Live-verified: memory saved with agentId=agent-alpha is returned by smart-search for agent-alpha and hidden from agent-beta. * feat: hybrid recall everywhere, indexed lessons, provenance, recall hygiene - mem::search ranks through the full BM25+vector+graph fusion when the vector index is populated (injected post-boot via setHybridRanker); the primary recall surface was keyword-only while only smart-search got hybrid ranking - fusion weights normalize per item over the streams that actually ranked it, with a small explicit cross-stream agreement bonus; the old every-enabled-stream denominator permanently penalized single-stream hits (the graph stream is empty on default installs). Result order is now deterministic (score, best rank, id) - lessons get a dedicated in-memory BM25 index built lazily from one KV list and maintained incrementally on save/delete/decay; recall previously listed and substring-scanned the whole corpus per query. Confidence x recency composite scoring is unchanged - mem::remember finds supersession candidates through the search index (top-50) instead of walking every memory per save, with a full-scan fallback while the index is cold; near-miss similarity (0.4-0.7) is reported back as an advisory similarTo hint - superseded memory versions leave the BM25 and vector indexes; the version chain stays in KV for history, but recall no longer returns an outdated fact as if current - every observation and memory now carries an immutable origin block (channel: user|agent|tool|import|shared, detail, capturedAt) stamped at capture, save, and import, and inherited through both compression paths — the base for trust-aware retrieval and ingest screening - regression tests: supersede index removal, similarTo hint, index-backed candidate discovery, lesson index recall/lazy rebuild/delete * feat(viewer): two-pane sessions, navigable dashboard, motion and copy polish - sessions: list + sticky detail panel side by side above 1100px (the detail previously rendered below the whole list, off-screen on any real corpus); selected/hover/active states with reserved left border so selection doesn't shift layout - dashboard stat cards for sessions/memories/lessons/crystals/graph navigate to their tabs (click or Enter), with hover affordance - observation subtitles that are raw serialized tool input now display the meaningful field (file path, command, pattern, url) instead of a JSON blob - expanded memory rows show the new origin provenance (channel + detail) - motion: 160ms view entrance, live-badge pulse, both gated behind prefers-reduced-motion; tabular numerals in tables - mobile: header stops wrapping the dateline into the badge row - lessons/crystals empty-state copy aligned with the header definitions (each concept was described two conflicting ways) * refactor: cleanup pass over the branch diff - shared test mocks: the three new test files use test/helpers/mocks (extended with update, store access, and an opt-in loose trigger) instead of three diverging inline copies - lessons: record cache beside the index takes recall to zero KV round-trips (was up to 50 gets per call); the observation adapter moved next to memoryToObservation so both record kinds thread new fields in one place; dead reset export removed - mem::search hybrid path carries the observations the ranker already loaded instead of refetching every result (halves KV I/O on the primary recall path); remember's candidate lookup skips ids that cannot resolve as memories and fails open to a full scan - fusion: derived tiebreak field no longer rides along past the sort; comment trimmed to the non-obvious history - cli: engine identity is a positive check (only the iii binary may be adopted or signaled; unknown port holders are refused, not just known VM names); worker reap extracted to one helper; demo notice picks its branch from a hoisted count - api: livez and health share one instanceInfo source (health now reports streamsPort too) computed once at boot instead of rebuilding the merged env per request - provenance: one importOrigin factory encodes the keep-or-mark rule at all three import sites - opencode plugin: project resolution memoized per directory (was a blocking git subprocess per session event); session.created uses the entry it just built - observe: origin channel derived from a named hook set, no nested ternary - viewer: toolbar buttons merged into the .btn rules, one 720px media block, generic keyboard activation for role-carrying cards, scroll-into-view only on the stacked layout, 5s freshness gate on tab refetch (replay stays fetch-once, reason documented), subtitle humanizer covers the capture-side key variants * feat(viewer): clarity pass and ambient refresh - health notes/alerts translate their machine slugs into sentences (memory_heap_tight_93%_rss111mb reads as heap usage with context) - lessons rows expand to full detail: rule, why-learned context, tags, learned/last-confirmed times, source sessions, raw record; column headers carry title hints for confidence and uses - actions tab gets the same intro card as the other tabs (status flow and frontier explained on the populated view, not just when empty) - timeline defaults to the session with the most observations instead of the newest, which was often a sparse just-started session - consolidation status and top-concepts zero states explain what fills them and which flags gate it - ambient background: the static dot grid becomes a slowly drifting ordered-dither field (quarter-res canvas, ~12fps, static frame under prefers-reduced-motion, theme-aware) - dark theme: layered near-black surfaces, hairline borders, softened accent — replaces the flat gray borders * feat(website): reskin on the near-black token system - neutral token foundation in globals.css: canvas/canvas-soft/card surfaces, hairline borders, ink/body/mute text scale, one warm accent used sparingly, 8px card radius + pill buttons, focus-visible rings - Inter display at weight 400 with tight tracking; mono uppercase eyebrows and captions; sentence-case body (case normalization only, no copy changes) - hero: two-tone lowercase wordmark, install command as a soft input card, ambient drifting dot field capped at 0.12 alpha with a static frame under prefers-reduced-motion - sections rebuilt on the card recipe: quiet background-shift hovers, hairline data table for the comparison, segmented tabs with polarity flip, per-vendor accent colors stripped from agent cards - fixed two latent token misuses that resolved to nothing - build green: 5/5 static pages, TypeScript clean * fix(viewer): make the graph tab legible without edges - nodes anchor to per-type cluster centers (captioned on the canvas) whenever relations are sparse; a pure force layout with no edges was an unlabeled scatter. Edge springs take over as real relations arrive - labels always render on graphs of 30 or fewer visible nodes instead of only past a zoom threshold - sidebar explains the entities-without-relations state and what produces edges; static legend removed (the type filter already carries color and shape) * fix(viewer): graph readability on sparse data - hover focus-fade only engages when the graph has edges; with none it faded every other node and suppressed all labels - cluster anchor pull reduced and initial scatter widened so type groups spread instead of collapsing into blobs - minimum node radius raised for degree-zero nodes; cluster captions offset above their groups * fix(viewer): graph fits the view; site copy grounded in the repo viewer graph: - container height leaves room for the footer instead of running under it - one-shot auto-fit zooms and pans to the node bounds once the layout settles, so first paint is framed instead of adrift - cluster captions and small-graph labels hide below readable zoom website copy (full pass, technical register): - every unverifiable number removed: benchmark percentages, latency claims, press strip, testimonials, invented terminal output; the comparison table usage dropped rather than shipping stale competitor figures - remaining stats are build-derived (54 MCP tools, 130 REST endpoints, 12 hooks, 1619 tests) or live from the GitHub API (stars) - feature copy corrected against src behavior: consolidation, graph extraction, and LLM compression activate with a provider key; provider list completed; install step numbering fixed - release-branch capabilities surfaced: agent-scoped save and recall, write-time provenance channels, hybrid ranking on the primary recall path, indexed lessons, near-duplicate save hints, superseded-version recall hygiene, JSONL import deriving crystals and lessons - em-dashes and slop phrasing removed throughout * fix: restore featured strip, label collision avoidance, optional no-think - website: FeaturedIn strip returns to the hero (its claims are the project's own credentials); rest of the grounded-copy pass unchanged - viewer graph: canvas cluster captions removed and labels place greedily into free space (selected/hovered always win), so zoomed-out views degrade to fewer labels instead of overlapping pills - graph extraction: AGENTMEMORY_LLM_NOTHINK=1 opt-in asks local reasoning models to skip their hidden thinking pass (several times faster, slight quality tradeoff); documented in .env.example, default behavior unchanged * feat(website): testimonials return, OpenCode joins the featured connectors - Testimonials section restored after LiveTerminal (launch-thread quotes are the project's own record) - OpenCode promoted from the marquee to the featured grid: it ships a native capture plugin with per-session project attribution; fills the empty eighth slot - full connector roster verified against src/cli/connect (18 dedicated adapters all present: featured grid + marquee) * fix(viewer): official icon as the favicon (was a text placeholder) * test(viewer): favicon assertion checks the served SVG, not a hex value * test(viewer): favicon checks assert served SVG shape, not old artwork * docs: readme grounded in source, changelog entry, env example consistency * revert(website): restore measured benchmark claims and comparison table The retrieval recall and token reduction figures are the project's own measurements and its adoption story; earlier scrubbing was over-strict. Backing them with a published run of the eval harness stays on the roadmap. * fix(website): drop the orphaned pause control on the hero field The old animated constellation earned a pause button; the subtle dither field does not, and prefers-reduced-motion already renders it static. * fix(website): official OpenCode brand mark on the featured card * chore: bump provider default models to current generations OpenAI gpt-4o-mini to gpt-5.6-luna, Anthropic claude-sonnet-4-20250514 to claude-sonnet-5, Gemini gemini-2.5-flash to gemini-3.7-flash, MiniMax M2.7 to M3, OpenRouter default to anthropic/claude-sonnet-5. Premium cost warning matches the Sol tier; cheap-model hints lead with deepseek/deepseek-v4-flash-0731. README local picks move to qwen3 / gpt-oss / deepseek-r1 with a NOTHINK callout; cost table refreshed with verified OpenRouter list prices. Embedding defaults unchanged. * feat: keyless heuristic graph extraction with LLM enrichment optional Entities and co-occurrence do not need a language model: files and concepts on compressed observations already name the nodes, and appearing in the same observation is an edge. mem::graph-extract now always runs this deterministic pass, so the graph populates for keyless installs; the LLM pass layers typed relations on top only when GRAPH_EXTRACTION_ENABLED is set and a real provider exists. Session end fires extraction unconditionally. * feat: DeepSeek Harness connector via home-level cordis patch layer agentmemory connect dsh appends an @deepseek-ai/dsh-mcp-client row to DSH_HOME/cordis.patch.yml (default ~/.dsh), the machine-local patch layer every Harness profile loads, so the MCP tools register as mcp__agentmemory__* before the first turn. Idempotent, --force replaces the row, dry-run supported. Config shape verified against the mcp-client README and publish docs in deepseek-ai/deepseek-harness. Website agents grid and README connector table updated. * feat: dsh --with-hooks auto-capture via Harness Claude Code bridge DeepSeek Harness ships a first-party @deepseek-ai/dsh-hooks-claude-code plugin that runs Claude Code shaped command hooks on the harness's own interception points. connect dsh --with-hooks writes the bundled hook manifest (absolute script paths, reusing the codex-hooks merge engine) to DSH_HOME/agentmemory.hooks.json and appends a second patch row pointing the bridge at it. Auto-capture on SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop; PreCompact is outside the bridge subset and skipped. Adapter recategorized native. MCP-only installs never touch the hooks row; --force replaces both. * feat(pi): automated connect install into pi's auto-discovery dir connect pi was a stub printing manual copy steps because integrations/ never shipped in the npm package. The extension source now ships (integrations/pi/ in files), and the adapter copies index.ts + security.ts into ~/.pi/agent/extensions/agentmemory/, which pi auto-discovers with no settings.json edit; /reload picks it up live. Idempotent by content compare, stale copies refresh with a backup, dry-run supported. integrations/pi is also a private local pi package (pi-package keyword, pi.extensions manifest limited to index.ts so security.ts is not loaded as its own extension, peer deps on the pi core packages) so pi install ./integrations/pi works from a checkout; never published to npm. Type import moved to @earendil-works/pi-coding-agent (upstream rename). * fix(codex): warn that hooks need one-time TUI trust approval Codex executes only hooks with a recorded trusted_hash in config.toml, and the Hooks-need-review approval prompt appears only in the interactive TUI. A codex exec-only workflow therefore never runs freshly installed hooks and gets no signal why. connect codex --with-hooks now warns to launch codex once and choose Trust all, and to re-approve after upgrades since the refreshed absolute paths change the hash. Verified live on codex 0.147.0: before trust, exec dispatched nothing for agentmemory hooks; after TUI approval, SessionStart and UserPromptSubmit fired and the observation landed in the daemon. * feat(pi): capture parity for the pi extension Session registration on session_start (ordered after the health check so it fires on the first session of a fresh process), prompt capture with client-side dedup and user-channel provenance, per-tool observations from tool_result with AGENTMEMORY_TOOL_OBSERVE=0 opt-out, turn slices raised to 8000, memory_save scoped to the current project, session end plus one consolidate run on real quit only (no client summarize: session/end already fans out the summary), health accepts status ok, and refreshStatus binds the status setter before awaiting so a session replacement mid-check cannot throw a stale-context error. Live-verified on pi 0.84.2 with a local model: prompt and turn observations landed and the session closed as completed on quit. * chore: regenerate skill reference docs npm run skills:gen after the adapter, env, and tool changes: 20 adapters including dsh, refreshed env defaults, tool listing. * refactor: trim oversized comments to constraint one-liners Connector and extension comments compressed to the constraints the code cannot show; narrative headers, source citations, and restated behavior removed. * docs(changelog): fold unreleased into the 0.9.29 release section This branch ships as 0.9.29 (npm latest is 0.9.28; the previous 0.9.29 section was prepped but never published). One section, dated 2026-08-15, upgrade notes preserved, all 44 bullets intact. * docs(readme): interactive-first install, dedupe, refresh stale counts Install leads with npx and the first-run wizard (agent multi-select, provider pick, global-install offer) instead of six manual commands; Windows, EACCES, npx-cache, and iii-pin notes collapse into details blocks. Quick Start drops the duplicated install prose for an everyday-commands list. Nav drops the redundant iii Console link. Gist badge updated to the live 1.6k stars / 230 forks; test count pill and alt text updated to 1,648. * fix: apply review round — ranking, indexes, lifecycle, connectors Hybrid scoring normalizes once per query by the best attainable weighted score over streams that produced results, so configured stream weights survive single-stream hits; expansion merge gets the same deterministic tie-break. Graph functions register unconditionally (keyless installs previously fired mem::graph-extract at an unregistered function every session end) and the trigger goes through fireVoid; heuristic edges accumulate observation provenance for repeated pairs instead of dropping it. Supersession candidate search waits for the memory index walk (new isMemoryIndexReady signal) instead of trusting idx.size, and mem::search falls back to keyword search when the hybrid ranker throws. Lesson recall over-fetches under project/confidence filters, refreshes the index entry when reinforcement changes indexed text, and resetLessonIndex clears the cache after import and replay write lessons directly. Observe preserves primitive payloads in the dedup key so distinct prompts never collapse (regression test added). pi extension dedups prompts per session and passes project on both smart-search calls. dsh reads a corrupt hooks manifest as absent via readJsonSafe; pi install returns skipped instead of throwing when the bundled source is missing; both use the shared writeTextAtomic. Docker-mode stop clears each pidfile/state only after its shutdown succeeded and matches compose services at any indentation. Viewer livez fetch gets a 5s timeout. opencode session.deleted prunes through pruneSessionMaps (was leaking sessionProjects). MiniMax MAX_TOKENS doc says the real 4096 default. README MCP catalog: base-tools table completed to the registry's 14, the 8-tool core mode and 7-tool standalone fallback distinguished, two missing resources listed. Tests restore env vars without writing the string undefined, similarTo assertions are unconditional, vitest test home is unique per run, website meta regenerated, deprecated word-break replaced. * docs: competitors refreshed — TencentDB Agent Memory column, entrants TencentDB Agent Memory (TencentCloud OSS, May 2026, 22K stars) gets a full column: team memory hub captured through an LLM proxy, four asset types, PersonaMem 76% self-reported, Docker Core+Hub+Proxy stack. Stale star counts refreshed against the live API (mem0 58K to 63K, Letta 24K, Khoj 36K, supermemory 29K). A newer-entrants table covers Zep/Graphiti, Cognee, LangMem, Cloudflare Agent Memory, and Memobase, with matching choose-if sections in benchmark/COMPARISON.md. Section badge subtitle updated. * fix: lesson index build races, rebuild ready flag, pi file backups * docs: drop competitor links from README * Update README.md
582 lines
24 KiB
TypeScript
582 lines
24 KiB
TypeScript
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
import { fetchWithTimeout } from "../src/providers/_fetch.js";
|
|
import { MinimaxProvider } from "../src/providers/minimax.js";
|
|
import { OpenRouterProvider } from "../src/providers/openrouter.js";
|
|
import { OpenAIProvider } from "../src/providers/openai.js";
|
|
import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js";
|
|
import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js";
|
|
import { CohereEmbeddingProvider } from "../src/providers/embedding/cohere.js";
|
|
import { VoyageEmbeddingProvider } from "../src/providers/embedding/voyage.js";
|
|
import { OpenRouterEmbeddingProvider } from "../src/providers/embedding/openrouter.js";
|
|
|
|
// A fetch mock that never resolves — simulates a hung upstream.
|
|
function hangingFetch(_url: string, _init?: RequestInit): Promise<Response> {
|
|
// honour AbortSignal so the timeout actually cancels us
|
|
const init = _init ?? {};
|
|
return new Promise<Response>((_resolve, reject) => {
|
|
if (init.signal) {
|
|
if (init.signal.aborted) {
|
|
reject(new DOMException("AbortError", "AbortError"));
|
|
return;
|
|
}
|
|
init.signal.addEventListener("abort", () => {
|
|
reject(new DOMException("AbortError", "AbortError"));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// fetchWithTimeout unit tests
|
|
// ─────────────────────────────────────────────────────────────
|
|
describe("fetchWithTimeout", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("resolves normally when fetch completes within the timeout", async () => {
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
|
);
|
|
const res = await fetchWithTimeout("https://example.com", {}, 1000);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("aborts with an AbortError when fetch hangs beyond the configured timeout", async () => {
|
|
await expect(
|
|
fetchWithTimeout("https://example.com", {}, 50),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("reads AGENTMEMORY_LLM_TIMEOUT_MS as the default timeout when no explicit ms is given", async () => {
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
// no explicit third arg — must pick up the env var
|
|
await expect(
|
|
fetchWithTimeout("https://example.com", {}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("falls back to 60 000 ms when AGENTMEMORY_LLM_TIMEOUT_MS is not set (type check only)", () => {
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(null, { status: 204 }),
|
|
);
|
|
const p = fetchWithTimeout("https://example.com", {});
|
|
expect(p).toBeInstanceOf(Promise);
|
|
return p;
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Bounded-retry total-deadline tests
|
|
//
|
|
// The retry wrapper must bound TOTAL elapsed time across every
|
|
// attempt + sleep — not per-attempt — so worst case never blows
|
|
// past the caller's budget or the iii 180s invocation timeout.
|
|
// ─────────────────────────────────────────────────────────────
|
|
describe("fetchWithTimeout bounded retry (total deadline)", () => {
|
|
// Builds a fetch mock that replays a queue of {status, headers} in order,
|
|
// repeating the last entry once the queue is exhausted. Records how many
|
|
// times it was invoked so we can assert attempt counts.
|
|
function queuedFetch(
|
|
responses: Array<{ status: number; headers?: Record<string, string> }>,
|
|
): { fetch: typeof fetch; calls: () => number } {
|
|
let i = 0;
|
|
const impl = (async () => {
|
|
const spec = responses[Math.min(i, responses.length - 1)];
|
|
i++;
|
|
return new Response(null, {
|
|
status: spec.status,
|
|
headers: spec.headers,
|
|
});
|
|
}) as typeof fetch;
|
|
return { fetch: impl, calls: () => i };
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
// 0. The FIRST attempt must honor the hard budget cap, not the raw caller
|
|
// timeout. Regression: fetchOnce was called with `ms` instead of the
|
|
// capped `budgetMs`, so a large caller timeout (e.g. 300s) could hang the
|
|
// initial request past HARD_BUDGET_CAP_MS (170s) and the iii 180s
|
|
// invocation timeout before any retry logic ran.
|
|
it("caps the first attempt at the hard budget, not the raw caller timeout", async () => {
|
|
const signals: AbortSignal[] = [];
|
|
const capturing = ((_url: string, init?: RequestInit) => {
|
|
const signal = init!.signal as AbortSignal;
|
|
signals.push(signal);
|
|
return new Promise<Response>((_resolve, reject) => {
|
|
signal.addEventListener("abort", () =>
|
|
reject(new DOMException("AbortError", "AbortError")),
|
|
);
|
|
});
|
|
}) as typeof fetch;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(capturing);
|
|
vi.useFakeTimers();
|
|
|
|
// 300s caller timeout — far above the 170s HARD_BUDGET_CAP_MS.
|
|
const p = fetchWithTimeout("https://example.com", {}, 300000);
|
|
p.catch(() => {}); // observe rejection so it isn't flagged unhandled
|
|
|
|
expect(signals).toHaveLength(1);
|
|
// Just before the cap the first attempt is still alive.
|
|
await vi.advanceTimersByTimeAsync(169999);
|
|
expect(signals[0].aborted).toBe(false);
|
|
// At the cap it must abort. Pre-fix (raw 300s) it would still be alive.
|
|
await vi.advanceTimersByTimeAsync(2);
|
|
expect(signals[0].aborted).toBe(true);
|
|
|
|
await expect(p).rejects.toThrow();
|
|
});
|
|
|
|
// 1. 429 then 200 → retried exactly once, resolves 200.
|
|
it("retries once on 429 then resolves the follow-up 200", async () => {
|
|
const q = queuedFetch([{ status: 429 }, { status: 200 }]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
|
|
const p = fetchWithTimeout("https://example.com", {}, 60000);
|
|
// Drain the backoff sleep (500ms default) so the retry fires.
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
const res = await p;
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(q.calls()).toBe(2);
|
|
});
|
|
|
|
// 2. 429 with a hostile Retry-After (100000s) must NOT wait that long. The
|
|
// delay collapses to the low per-delay cap (5000ms) so the retry fires
|
|
// quickly and total elapsed stays nowhere near 100000s.
|
|
it("does not honour a hostile Retry-After literally — caps it and stays within budget", async () => {
|
|
const q = queuedFetch([
|
|
{ status: 429, headers: { "Retry-After": "100000" } }, // 100000s
|
|
{ status: 200 },
|
|
]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
const start = Date.now();
|
|
|
|
const p = fetchWithTimeout("https://example.com", {}, 60000);
|
|
// runAllTimers drains every scheduled sleep; if the code honored 100000s
|
|
// literally this would advance 100_000_000ms of simulated time.
|
|
await vi.runAllTimersAsync();
|
|
const res = await p;
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(q.calls()).toBe(2);
|
|
// The only sleep was the capped 5000ms delay — total elapsed is bounded to
|
|
// the cap, nowhere near the 100_000_000ms the header requested.
|
|
expect(elapsed).toBeLessThanOrEqual(5000);
|
|
expect(elapsed).toBeLessThan(60000);
|
|
});
|
|
|
|
// 2c. When the capped delay still cannot fit inside a small budget, we do NOT
|
|
// retry and return the last 429 within bound.
|
|
it("returns the last 429 without retrying when even the capped delay overruns a small budget", async () => {
|
|
const q = queuedFetch([
|
|
{ status: 429, headers: { "Retry-After": "100000" } },
|
|
{ status: 200 },
|
|
]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
const start = Date.now();
|
|
|
|
// Budget 1000ms: capped delay 5000ms + floor 100ms > remaining, no retry.
|
|
const p = fetchWithTimeout("https://example.com", {}, 1000);
|
|
await vi.runAllTimersAsync();
|
|
const res = await p;
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(res.status).toBe(429);
|
|
expect(q.calls()).toBe(1);
|
|
expect(elapsed).toBeLessThan(1000);
|
|
});
|
|
|
|
// 2b. Retry-After present but LARGER than the low per-delay cap collapses to
|
|
// MAX_RETRY_DELAY_MS (5000), still bounded, still retries when budget
|
|
// allows.
|
|
it("caps an oversized Retry-After to the max delay and still retries within budget", async () => {
|
|
const q = queuedFetch([
|
|
{ status: 503, headers: { "Retry-After": "60" } }, // 60s requested
|
|
{ status: 200 },
|
|
]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
|
|
const p = fetchWithTimeout("https://example.com", {}, 60000);
|
|
// Requested 60s, but the honored delay is capped at 5000ms.
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
const res = await p;
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(q.calls()).toBe(2);
|
|
});
|
|
|
|
// 3. Persistent 503 → stops after the bounded attempts AND within the total
|
|
// deadline. Attempt count is bounded at MAX_ATTEMPTS (3) and total
|
|
// simulated elapsed stays under the budget.
|
|
it("stops persistent 503 at the attempt cap and within the deadline", async () => {
|
|
const q = queuedFetch([{ status: 503 }]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
const start = Date.now();
|
|
|
|
const p = fetchWithTimeout("https://example.com", {}, 60000);
|
|
// Two retries with 500ms + 1000ms exponential backoff.
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
const res = await p;
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(res.status).toBe(503);
|
|
// Initial attempt + 2 retries = MAX_ATTEMPTS.
|
|
expect(q.calls()).toBe(3);
|
|
expect(elapsed).toBeLessThan(60000);
|
|
// Total simulated sleep was only the two backoffs.
|
|
expect(elapsed).toBeLessThanOrEqual(1500);
|
|
});
|
|
|
|
// 3b. A tiny budget forces us to bail before even the first retry sleep —
|
|
// the sleep + minimal attempt floor already overruns the remaining
|
|
// budget, so we return the first 503 without retrying.
|
|
it("does not retry when the budget is too small to fit a retry", async () => {
|
|
const q = queuedFetch([{ status: 503 }, { status: 200 }]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
|
|
// Budget of 200ms: backoff 500ms + floor 100ms > remaining, so no retry.
|
|
const p = fetchWithTimeout("https://example.com", {}, 200);
|
|
await vi.runAllTimersAsync();
|
|
const res = await p;
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(q.calls()).toBe(1);
|
|
});
|
|
|
|
// 5. Retry-After as an HTTP-date is parsed, capped to the max delay, and the
|
|
// retry fires within budget.
|
|
it("parses an HTTP-date Retry-After and caps the honored delay", async () => {
|
|
// 2s in the future — under the max delay cap, so honored as-is.
|
|
const future = new Date(Date.now() + 2000).toUTCString();
|
|
const q = queuedFetch([
|
|
{ status: 429, headers: { "Retry-After": future } },
|
|
{ status: 200 },
|
|
]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
|
|
const p = fetchWithTimeout("https://example.com", {}, 60000);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
const res = await p;
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(q.calls()).toBe(2);
|
|
});
|
|
|
|
// 5b. A far-future HTTP-date collapses to the capped delay (5000ms), which
|
|
// still exceeds a small budget → no retry, last 503 returned in bound.
|
|
it("does not retry on a far-future HTTP-date Retry-After that overruns a small budget", async () => {
|
|
const farFuture = new Date(Date.now() + 3600_000).toUTCString(); // 1h out
|
|
const q = queuedFetch([
|
|
{ status: 503, headers: { "Retry-After": farFuture } },
|
|
{ status: 200 },
|
|
]);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch);
|
|
vi.useFakeTimers();
|
|
const start = Date.now();
|
|
|
|
// Budget 1000ms: capped delay 5000ms + floor > remaining, so no retry.
|
|
const p = fetchWithTimeout("https://example.com", {}, 1000);
|
|
await vi.runAllTimersAsync();
|
|
const res = await p;
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(q.calls()).toBe(1);
|
|
expect(elapsed).toBeLessThan(1000);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Provider hang regression tests
|
|
// Each provider must call fetchWithTimeout, which honours the
|
|
// AbortSignal when the explicit timeoutMs is tiny (50 ms).
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
describe("Provider hang regression — MinimaxProvider", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("compress() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new MinimaxProvider("test-key", "MiniMax-M3", 800);
|
|
await expect(provider.compress("system", "user")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Provider hang regression — OpenRouterProvider (covers Gemini LLM path)", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("compress() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new OpenRouterProvider(
|
|
"test-key",
|
|
"gemini-3.7-flash",
|
|
1024,
|
|
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
);
|
|
await expect(provider.compress("system", "user")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Provider hang regression — GeminiEmbeddingProvider", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("embedBatch() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new GeminiEmbeddingProvider("test-key");
|
|
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Provider hang regression — OpenAIEmbeddingProvider", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("embedBatch() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new OpenAIEmbeddingProvider("test-key");
|
|
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Provider hang regression — CohereEmbeddingProvider", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("embedBatch() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new CohereEmbeddingProvider("test-key");
|
|
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Provider hang regression — VoyageEmbeddingProvider", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("embedBatch() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new VoyageEmbeddingProvider("test-key");
|
|
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Provider hang regression — OpenRouterEmbeddingProvider", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("embedBatch() aborts after timeout when upstream hangs", async () => {
|
|
const provider = new OpenRouterEmbeddingProvider("test-key");
|
|
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// #446 — OpenAI LLM provider env-var precedence
|
|
//
|
|
// v0.9.17 shipped OPENAI_TIMEOUT_MS (OpenAI-scoped). PR #379 then
|
|
// shipped AGENTMEMORY_LLM_TIMEOUT_MS (shared). The provider now
|
|
// honours both: OPENAI_TIMEOUT_MS wins for back-compat, with
|
|
// AGENTMEMORY_LLM_TIMEOUT_MS as the global fall-back.
|
|
// ─────────────────────────────────────────────────────────────
|
|
describe("OpenAIProvider timeout env precedence (#446)", () => {
|
|
beforeEach(() => {
|
|
delete process.env["OPENAI_TIMEOUT_MS"];
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env["OPENAI_TIMEOUT_MS"];
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
|
|
it("OPENAI_TIMEOUT_MS alone aborts the OpenAI LLM call", async () => {
|
|
process.env["OPENAI_TIMEOUT_MS"] = "30";
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
await expect(provider.compress("system", "user")).rejects.toThrow(
|
|
/timed out after 30ms/,
|
|
);
|
|
});
|
|
|
|
it("AGENTMEMORY_LLM_TIMEOUT_MS alone aborts the OpenAI LLM call", async () => {
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "30";
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
await expect(provider.compress("system", "user")).rejects.toThrow(
|
|
/timed out after 30ms/,
|
|
);
|
|
});
|
|
|
|
it("OPENAI_TIMEOUT_MS wins when both are set (back-compat)", async () => {
|
|
process.env["OPENAI_TIMEOUT_MS"] = "30";
|
|
// Set the global to a much larger value — if precedence is wrong,
|
|
// we'd time out at 5000ms and the test would hang past the 5s
|
|
// vitest default. We assert the message ms to lock the precedence.
|
|
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "5000";
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
await expect(provider.compress("system", "user")).rejects.toThrow(
|
|
/timed out after 30ms/,
|
|
);
|
|
});
|
|
|
|
it("falls back to the 60 000 ms default when neither is set", () => {
|
|
// We don't actually wait 60s — the provider stores timeoutMs at
|
|
// construction. Construct, then assert the bound via the error
|
|
// message after the hang aborts at a tiny pre-set value.
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
// Access the resolved timeout via the constructed field name. The
|
|
// class keeps `timeoutMs` private; reaching in via the index
|
|
// access keeps the test on the public observed behaviour: the ms
|
|
// value reported in the timeout error message must be 60000.
|
|
const ms = (provider as unknown as { timeoutMs: number }).timeoutMs;
|
|
expect(ms).toBe(60_000);
|
|
});
|
|
|
|
it("rejects malformed env values like '30ms' or '1_000' (CodeRabbit catch)", () => {
|
|
// parseInt would have silently returned 30 / 1 for these typos —
|
|
// strict parse now rejects them and the provider falls back to
|
|
// the 60 000 ms default so a malformed env doesn't masquerade as
|
|
// an aggressive bound.
|
|
// Whitespace-only padding (" 30 ") is legitimate env-var handling — we
|
|
// trim before validating. The cases below are real typos parseInt would
|
|
// silently swallow.
|
|
for (const bad of ["30ms", "1_000", "60s", "30abc", "-30", "0"]) {
|
|
process.env["OPENAI_TIMEOUT_MS"] = bad;
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
const ms = (provider as unknown as { timeoutMs: number }).timeoutMs;
|
|
expect(ms).toBe(60_000);
|
|
delete process.env["OPENAI_TIMEOUT_MS"];
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// #627 — OpenAI provider must read message.reasoning_content
|
|
// DeepSeek V4 / Qwen3 / GLM / Kimi return reasoning_content (with
|
|
// underscore); only checking `reasoning` left thinking-model output
|
|
// dropped on the floor and tripped the compress circuit breaker.
|
|
// ─────────────────────────────────────────────────────────────
|
|
describe("OpenAIProvider thinking-model fallback (#627)", () => {
|
|
beforeEach(() => {
|
|
delete process.env["OPENAI_TIMEOUT_MS"];
|
|
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mockOpenAIResponse(body: object): void {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
(async () =>
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})) as typeof fetch,
|
|
);
|
|
}
|
|
|
|
it("returns reasoning_content when content is empty (DeepSeek V4 / Qwen3 shape)", async () => {
|
|
mockOpenAIResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: "",
|
|
reasoning_content: "thinking-mode output",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
const out = await provider.compress("system", "user");
|
|
expect(out).toBe("thinking-mode output");
|
|
});
|
|
|
|
it("still returns reasoning (no underscore) for older o-series shape", async () => {
|
|
mockOpenAIResponse({
|
|
choices: [{ message: { content: "", reasoning: "older shape" } }],
|
|
});
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
const out = await provider.compress("system", "user");
|
|
expect(out).toBe("older shape");
|
|
});
|
|
|
|
it("content wins over both reasoning fields when present", async () => {
|
|
mockOpenAIResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: "real content",
|
|
reasoning: "ignore",
|
|
reasoning_content: "also ignore",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const provider = new OpenAIProvider("test-key", "gpt-5.6-luna", 1024);
|
|
const out = await provider.compress("system", "user");
|
|
expect(out).toBe("real content");
|
|
});
|
|
});
|
|
|