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>
77 lines
3.9 KiB
JavaScript
77 lines
3.9 KiB
JavaScript
const fs = require('node:fs');
|
|
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
const ts = require('typescript');
|
|
require.extensions['.ts'] = (mod, filename) => {
|
|
const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
|
});
|
|
mod._compile(outputText, filename);
|
|
};
|
|
const { conversationService } = require('../src/api/conversationService.ts');
|
|
const { userService } = require('../src/api/userService.ts');
|
|
const run = { id: 'run', conversation_id: 'conv', status: 'running', query: 'hi' };
|
|
function stream(chunks) {
|
|
return new Response(new ReadableStream({ start(controller) {
|
|
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
|
|
controller.close();
|
|
}}));
|
|
}
|
|
|
|
test('SSE parser accepts split CRLF, ids, comments, UTF8, and DONE; ignores duplicate replay', async () => {
|
|
const events = [], cursors = [];
|
|
const response = stream([': ping\r', '\n\r\nid: 1\r\ndata: {"type":"content","delta":"猫"}\r', '\n\r\nid: 1\ndata: {"type":"content","delta":"duplicate"}\n\nid: 2\ndata: [DONE]\n\n']);
|
|
assert.equal(await conversationService._readSSEStream(response, e => events.push(e), id => cursors.push(id)), true);
|
|
assert.deepEqual(events, [{ type: 'content', delta: '猫' }]);
|
|
assert.deepEqual(cursors, [1]);
|
|
});
|
|
|
|
test('premature EOF is recoverable and resume uses last successfully handled cursor', async () => {
|
|
const original = userService.fetchWithAuth;
|
|
const calls = [], events = [], connection = [];
|
|
userService.fetchWithAuth = async (url) => {
|
|
calls.push(url);
|
|
return calls.length === 1 ? stream(['id: 1\ndata: {"type":"content","delta":"first"}\n\n']) :
|
|
stream(['id: 1\ndata: {"type":"content","delta":"duplicate"}\n\nid: 2\ndata: {"type":"response","message":"answer"}\n\nid: 3\ndata: [DONE]\n\n']);
|
|
};
|
|
try {
|
|
await conversationService.resumeRun(run, e => events.push(e), new AbortController().signal, v => connection.push(v));
|
|
assert.match(calls[1], /cursor=1$/);
|
|
assert.deepEqual(events.map(e => e.type), ['content', 'response']);
|
|
assert.deepEqual(connection, [false, true, false]);
|
|
} finally { userService.fetchWithAuth = original; }
|
|
});
|
|
|
|
test('ambiguous creation retry reuses idempotency key; discovery and cancellation are authenticated', async () => {
|
|
const original = userService.fetchWithAuth;
|
|
const calls = [];
|
|
userService.fetchWithAuth = async (url, init) => {
|
|
calls.push([url, init]);
|
|
if (calls.length === 1) throw new TypeError('network failed');
|
|
return new Response(JSON.stringify(url.endsWith('/runs') && init?.method !== 'POST' ? { run } : run));
|
|
};
|
|
try {
|
|
const body = { query: 'hi', conversation_id: 'conv', request_id: 'stable' };
|
|
await conversationService.createRun(body);
|
|
assert.equal(calls[0][1].body, calls[1][1].body);
|
|
assert.equal((await conversationService.discoverRun('conv')).id, 'run');
|
|
await conversationService.cancelRun(run);
|
|
assert.equal(calls.at(-1)[0], '/api/conversation/conv/runs/run/cancel');
|
|
assert.equal(calls.at(-1)[1].method, 'POST');
|
|
} finally { userService.fetchWithAuth = original; }
|
|
});
|
|
|
|
test('detach never issues cancellation and terminal authorization failures do not retry', async () => {
|
|
const original = userService.fetchWithAuth;
|
|
const calls = [];
|
|
userService.fetchWithAuth = async (url) => { calls.push(url); return new Response('', { status: 404 }); };
|
|
try {
|
|
await assert.rejects(conversationService.resumeRun(run, () => {}, new AbortController().signal), /resume/);
|
|
assert.equal(calls.length, 1);
|
|
const controller = new AbortController(); controller.abort();
|
|
await assert.rejects(conversationService.resumeRun(run, () => {}, controller.signal), { name: 'AbortError' });
|
|
assert.equal(calls.length, 1);
|
|
assert.ok(calls.every(url => !url.includes('/cancel')));
|
|
} finally { userService.fetchWithAuth = original; }
|
|
});
|