Files
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

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; }
});