Files
rohitg00__agentmemory/test/fetch-timeout.test.ts
Rohit Ghumare f027c20309 fix(multi): stability pass for #627 #640 #474 #638 #431 #544 #563 (#648)
* fix(multi): stability pass for #627 #640 #474 #638 #431 #544 #563

Six issues, one PR. Each lands with a targeted regression test;
1119/1119 vitest pass.

#627 OpenAI thinking-model fallback
  src/providers/openai.ts now reads message.reasoning_content alongside
  message.reasoning. DeepSeek V4 / Qwen3 / GLM / Kimi return the
  underscored field — previously compress silently failed (0/700 calls)
  and the circuit breaker tripped.

#640 + #474 stop reaps the worker process
  src/index.ts writes ~/.agentmemory/worker.pid on registerWorker, clears
  it on graceful shutdown. src/cli.ts runStop now reads the worker pidfile
  and signals SIGTERM alongside the engine pids. Fixes both: the daemon
  wrapper surviving stop (#640) and the iii engine retaining stale
  function registrations because the worker reconnected to the new
  engine (#474).

#638 OpenCode session implicit-create on observe
  src/functions/observe.ts now creates the session record on the first
  observation when project + cwd are present and no session exists.
  OpenCode plugins (and any caller that skips POST /session/start) no
  longer leak observations into a session memory_sessions never lists,
  and summarize stops bailing with 'Session not found'.

#431 OpenCode auto-context (zero-config injection)
  plugin/opencode/agentmemory-capture.ts captures the context returned
  by POST /session/start into a per-session cache. The existing
  experimental.chat.system.transform hook now reads from the cache
  first, falls back to /context. Cleanup on session.deleted.

#544 paginated /memories + /export
  src/triggers/api.ts adds three query modes to /memories:
    ?count=true       — totals only, viewer status badge
    ?limit=N&offset=M — paged slice, default unlimited
  /export now forwards maxSessions + offset query params to mem::export
  (which already supported them). Viewer dashboard caps the memories
  fetch at 500; the memories tab at 2000. Both stop the iii invocation
  timeout from masking real corpora as 0 memories.

#563 viewer graph cool-down on >1000 nodes
  src/viewer/index.html adds tick-decayed damping (coolBoost), per-node
  velocity caps tiered by node count, and quiescence-based raf parking.
  Mousedown wakes the parked loop. Dense graphs settle instead of
  bouncing forever; CPU returns to idle once the layout is quiet.

#637 Windows em-dash ByteString — deferred to follow-up
  Cannot reproduce on macOS / Linux. The user-suggested defensive
  encoding fix is unsafe without a Windows repro confirming the actual
  exception path. Will land separately once a Windows runner or the
  reporter can validate.

* fix(multi): address review findings on PR #648

Addresses inline review on PR #648 — verified each finding against
current code and fixed the still-valid ones.

opencode plugin: snapshot activeSessionId into a local 'sessionId'
before await postJson('/session/start') — a second session.created
event during the await could rebind activeSessionId and cache the
context against the wrong key. The cache write + observe call now use
the snapshotted id.

src/cli.ts: clearWorkerPidfile() now runs in every stop branch:
  - Docker engine-not-running early return
  - Docker stopDockerEngine path
  - native engine-not-running 'Nothing to stop'
  - native happy path (was already there)
The worker pid is now read up front so the engine-down branch can also
reap an orphaned worker process (previously fell through to 'preserve
for manual cleanup'). A new dedicated branch reaps the worker and
exits cleanly when only the worker is lingering.

src/viewer/index.html: wakeGraphSim() shared helper consolidates the
quietTicks reset + raf restart pattern. Wheel handler, zoomGraph(),
recenterGraph(), and mousedown all now wake the parked simulation so
zoom/pan/click feedback is immediate after the layout has settled.
graphSim object initializes quietTicks: 0 alongside tickCount: 0.

src/functions/observe.ts: dedupe new Date().toISOString() into a
single 'ts' local for the implicit-create path so startedAt and
updatedAt stay consistent.

test/opencode-auto-context.test.ts: regex updated to assert the
snapshot-then-cache pattern instead of the previous direct
activeSessionId reference.

1119/1119 vitest pass.
2026-05-25 19:45:58 +01:00

347 lines
14 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;
});
});
// ─────────────────────────────────────────────────────────────
// 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-M2.7", 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-2.5-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-4o-mini", 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-4o-mini", 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-4o-mini", 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-4o-mini", 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-4o-mini", 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-4o-mini", 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-4o-mini", 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-4o-mini", 1024);
const out = await provider.compress("system", "user");
expect(out).toBe("real content");
});
});