feat: add response cache and compact output mode
Response cache (api-client.ts):
- In-memory TTL cache (5 min default) avoids duplicate API calls
when LLMs repeat the same tool call within a conversation.
- Cache key = full URL with params; only 200 OK responses are cached.
- Exposed via client.cache for manual clear() if needed.
- Configurable TTL via TorrentClawClient constructor.
Compact mode (search_content tool):
- New compact boolean parameter (default: false).
- When true, magnet links use short form magnet:?xt=urn:btih:{hash}
(~60 chars) instead of full magnets with trackers (~300 chars).
- Short magnets are still valid and clickable (DHT peer discovery).
- Also generates a magnet from info_hash even when API returns null.
- Saves ~10K chars per typical search (5 torrents x 10 results).
Tests: 100 total (15 new), all passing.
This commit is contained in:
parent
e011c0f63e
commit
bf459740fe
5 changed files with 349 additions and 9 deletions
143
tests/cache.test.ts
Normal file
143
tests/cache.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ResponseCache, TorrentClawClient } from "../src/api-client.js";
|
||||
|
||||
describe("ResponseCache", () => {
|
||||
it("returns undefined for missing keys", () => {
|
||||
const cache = new ResponseCache();
|
||||
expect(cache.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores and retrieves values", () => {
|
||||
const cache = new ResponseCache();
|
||||
cache.set("key1", { data: "hello" });
|
||||
expect(cache.get("key1")).toEqual({ data: "hello" });
|
||||
});
|
||||
|
||||
it("expires entries after TTL", () => {
|
||||
const cache = new ResponseCache(100); // 100ms TTL
|
||||
cache.set("key1", "value");
|
||||
|
||||
// Advance time past TTL
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(cache.get("key1")).toBeUndefined();
|
||||
// Expired entry should be removed from store
|
||||
expect(cache.size).toBe(0);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns value before TTL expires", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new ResponseCache(1000); // 1s TTL
|
||||
cache.set("key1", "value");
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(cache.get("key1")).toBe("value");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clears all entries", () => {
|
||||
const cache = new ResponseCache();
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
expect(cache.size).toBe(2);
|
||||
|
||||
cache.clear();
|
||||
expect(cache.size).toBe(0);
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("overwrites existing keys", () => {
|
||||
const cache = new ResponseCache();
|
||||
cache.set("key1", "old");
|
||||
cache.set("key1", "new");
|
||||
expect(cache.get("key1")).toBe("new");
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TorrentClawClient cache integration", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown, status = 200) {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("caches search results on second call", async () => {
|
||||
const responseData = { total: 1, page: 1, pageSize: 10, results: [] };
|
||||
mockFetch(responseData);
|
||||
|
||||
const client = new TorrentClawClient();
|
||||
const result1 = await client.search({ query: "inception" });
|
||||
const result2 = await client.search({ query: "inception" });
|
||||
|
||||
// Only one fetch call — second was served from cache
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(responseData);
|
||||
expect(result2).toEqual(responseData);
|
||||
});
|
||||
|
||||
it("does not cache different queries", async () => {
|
||||
const data1 = { total: 1, page: 1, pageSize: 10, results: [] };
|
||||
const data2 = { total: 2, page: 1, pageSize: 10, results: [] };
|
||||
mockFetch(data1);
|
||||
mockFetch(data2);
|
||||
|
||||
const client = new TorrentClawClient();
|
||||
await client.search({ query: "inception" });
|
||||
await client.search({ query: "matrix" });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache error responses", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(new Response("Server error", { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ total: 0, page: 1, pageSize: 10, results: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new TorrentClawClient();
|
||||
|
||||
// First call fails
|
||||
await expect(client.search({ query: "test" })).rejects.toThrow();
|
||||
|
||||
// Second call should hit the API again (not cached)
|
||||
const result = await client.search({ query: "test" });
|
||||
expect(result.total).toBe(0);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cache can be cleared manually", async () => {
|
||||
const data = { total: 1, page: 1, pageSize: 10, results: [] };
|
||||
mockFetch(data);
|
||||
mockFetch(data);
|
||||
|
||||
const client = new TorrentClawClient();
|
||||
await client.search({ query: "test" });
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
client.cache.clear();
|
||||
await client.search({ query: "test" });
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue