feat(api): orchestrator cutover — replace bandit with multi-agent pipeline (ADR-0013 step 6)

POST /recommend now calls ml/serving /recommend with pre-computed agent
snippets + task context instead of /generate + /score/egreedy/v2. Falls
back to a random signal candidate when ml/serving is unavailable.

Removes: remotePolicy, fetchLlmCandidates, sendRewardWithRetry,
candidateCache, pickPromptVersion. Feedback handler keeps inferReward +
tipFeedback writes for observability; reward delivery to the bandit is gone.
tipScores.policy is now 'orchestrator'; promptVersion is 'v4-orchestrator'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 10:37:15 +00:00
parent 7e958a779d
commit c65bedcf68
4 changed files with 117 additions and 402 deletions

View File

@@ -4,6 +4,10 @@
* inside beforeAll (same pattern as admin.test.ts) to avoid TDZ issues.
* Uses http.request (not fetch) as the test client so that globalThis.fetch
* mocking doesn't interfere with the test runner itself.
*
* The orchestrator path (ADR-0013): signals fetched for task context/fallback,
* then ml/serving /recommend called. agent_outputs table is empty in tests so
* the orchestrator always uses the raw-task fallback path.
*/
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import express from 'express';
@@ -48,7 +52,7 @@ describe('POST /recommend integration', () => {
let server: http.Server;
let baseUrl: string;
let savedFetch: typeof globalThis.fetch;
let clearCache: () => void;
let clearSignalCache: () => void;
beforeAll(async () => {
await testDb.insert(users).values({
@@ -58,11 +62,12 @@ describe('POST /recommend integration', () => {
await testDb.insert(integrationTokens).values({
id: 'tok-1', userId: 'user-1', provider: 'todoist',
accessToken: 'fake-token', connectedAt: new Date().toISOString(),
tokenStatus: 'active',
});
const mod = await import('../recommender.js');
const { recommenderRouter } = mod;
clearCache = (mod as any)._clearCandidateCacheForTests;
clearSignalCache = (mod as any)._clearSignalCacheForTests;
const app = express();
app.use(express.json());
app.use('/api', recommenderRouter);
@@ -74,19 +79,22 @@ describe('POST /recommend integration', () => {
afterEach(() => {
globalThis.fetch = savedFetch;
clearCache?.();
clearSignalCache?.();
});
it('returns 204 when Todoist + LLM both return empty', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ results: [] }),
} as any);
it('returns 204 when Todoist is empty and orchestrator fails', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) } as any);
}
// /recommend fails → orchestrator returns null, random fallback also empty → 204
return Promise.resolve({ ok: false, status: 503 } as any);
});
const { status } = await post(`${baseUrl}/api/recommend`);
expect(status).toBe(204);
});
it('serves todoist tip and writes correct tip_scores columns', async () => {
it('serves orchestrator tip and writes correct tip_scores columns', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({
@@ -96,55 +104,16 @@ describe('POST /recommend integration', () => {
}),
} as any);
}
if (String(url).includes('/generate')) {
return Promise.resolve({ ok: false, status: 503, json: async () => ({}) } as any);
}
if (String(url).includes('/score')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({ tip_id: 'todoist:task-1', score: 0.8 }),
} as any);
}
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as any);
});
const { status, body } = await post(`${baseUrl}/api/recommend`);
expect(status).toBe(200);
expect(body.tip.source).toBe('todoist');
expect(body.tip.kind).toBe('task');
const rows = await testDb.select().from(tipScores);
const row = rows[rows.length - 1];
expect(row.tipKind).toBe('task');
expect(row.promptVersion).toBeNull();
expect(row.llmModel).toBeNull();
});
it('writes prompt_version + llm_model when LLM tip is served', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({ results: [] }),
} as any);
}
if (String(url).includes('/generate')) {
if (String(url).includes('/recommend')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({
candidates: [{ id: 'adv-1', content: 'Take a break.', rationale: 'You deserve it.' }],
tip: { id: 'adv-1', content: 'Take a break.', rationale: 'You deserve it.' },
model: 'tip-generator',
prompt_version: 'v1',
}),
} as any);
}
if (String(url).includes('/score')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({ tip_id: 'llm:adv-1', score: 0.9 }),
} as any);
}
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as any);
return Promise.resolve({ ok: false, status: 500 } as any);
});
const { status, body } = await post(`${baseUrl}/api/recommend`);
@@ -155,12 +124,14 @@ describe('POST /recommend integration', () => {
const rows = await testDb.select().from(tipScores);
const row = rows[rows.length - 1];
expect(row.promptVersion).toBe('v1');
expect(row.policy).toBe('orchestrator');
expect(row.promptVersion).toBe('v4-orchestrator');
expect(row.llmModel).toBe('tip-generator');
expect(row.mlScore).toBeNull();
expect(row.tipKind).toBe('advice');
});
it('falls back to todoist tip when /generate returns non-200', async () => {
it('falls back to random signal tip when orchestrator fails', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({
@@ -170,22 +141,18 @@ describe('POST /recommend integration', () => {
}),
} as any);
}
if (String(url).includes('/generate')) {
return Promise.resolve({ ok: false, status: 502, json: async () => ({}) } as any);
}
if (String(url).includes('/score')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({ tip_id: 'todoist:fallback-1', score: 0.5 }),
} as any);
}
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) } as any);
// /recommend fails → falls back to random signal candidate
return Promise.resolve({ ok: false, status: 502 } as any);
});
const { status, body } = await post(`${baseUrl}/api/recommend`);
expect([200, 204]).toContain(status);
if (status === 200) {
expect(body.tip.source).toBe('todoist');
}
expect(status).toBe(200);
expect(body.tip.source).toBe('todoist');
const rows = await testDb.select().from(tipScores);
const row = rows[rows.length - 1];
expect(row.policy).toBe('random');
expect(row.promptVersion).toBeNull();
expect(row.llmModel).toBeNull();
});
});