5809ad3adb
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>
106 lines
3.5 KiB
JavaScript
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, /<script>cats<\/script>/);
|
|
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</);
|
|
});
|