Files
simbarag/raggr-frontend/tests/history-management.test.cjs
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

106 lines
3.5 KiB
JavaScript

const fs = require("node:fs");
const assert = require("node:assert/strict");
const test = require("node:test");
const ts = require("typescript");
const React = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
for (const ext of [".ts", ".tsx"]) {
require.extensions[ext] = (mod, filename) => {
const { outputText } = ts.transpileModule(
fs.readFileSync(filename, "utf8"),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
},
},
);
mod._compile(outputText, filename);
};
}
const { historyService } = require("../src/api/historyService.ts");
const { userService } = require("../src/api/userService.ts");
const { ConversationList } = require("../src/components/ConversationList.tsx");
const { MemoryPanel } = require("../src/components/MemoryPanel.tsx");
test("history requests use authenticated fetch, encode search and send explicit pin boolean", async () => {
const calls = [];
const original = userService.fetchWithAuth;
userService.fetchWithAuth = async (...args) => {
calls.push(args);
return new Response(JSON.stringify([]), { status: 200 });
};
try {
await historyService.list({ search: "cats & dogs", limit: 21, offset: 20 });
const query = new URL(calls[0][0], "https://example.test").searchParams;
assert.equal(query.get("search"), "cats & dogs");
assert.equal(query.get("offset"), "20");
await historyService.pin("id", false);
assert.equal(calls[1][0], "/api/history/conversations/id/pin");
assert.equal(calls[1][1].method, "PATCH");
assert.deepEqual(JSON.parse(calls[1][1].body), { pinned: false });
await historyService.correctMemory("id", "correct fact");
assert.deepEqual(JSON.parse(calls[2][1].body), { content: "correct fact" });
userService.fetchWithAuth = async (...args) => {
calls.push(args);
return new Response(null, { status: 204 });
};
assert.equal(await historyService.forgetMemory("id"), undefined);
assert.equal(calls[3][1].method, "DELETE");
} finally {
userService.fetchWithAuth = original;
}
});
test("failed exports and memory updates are surfaced", async () => {
const original = userService.fetchWithAuth;
userService.fetchWithAuth = async () => new Response(null, { status: 404 });
try {
await assert.rejects(
historyService.export("foreign", "json"),
/Unable to export/,
);
await assert.rejects(
historyService.correctMemory("foreign", "content"),
/Unable to update/,
);
} finally {
userService.fetchWithAuth = original;
}
});
test("conversation results render escaped snippets, pinned status and keyboard reachable actions", () => {
const html = renderToStaticMarkup(
React.createElement(ConversationList, {
conversations: [
{
id: "owned",
title: "Cats",
pinned: true,
snippet: "<script>cats</script>",
},
],
onSelectConversation() {},
onCreateNewConversation() {},
onPinConversation() {},
onExportConversation() {},
variant: "light",
}),
);
assert.match(html, /aria-label="Pinned"/);
assert.match(html, /aria-label="Actions for Cats"/);
assert.match(html, /&lt;script&gt;cats&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script>/);
});
test("memory panel exposes its purpose and loading status", () => {
const html = renderToStaticMarkup(
React.createElement(MemoryPanel, { onClose() {} }),
);
assert.match(html, /aria-label="Saved memories"/);
assert.match(html, /Correct facts Simba remembers/);
assert.match(html, /role="status"/);
assert.match(html, />Close</);
});