diff --git a/src/distill-receipt.js b/src/distill-receipt.js new file mode 100644 index 0000000..085ba3b --- /dev/null +++ b/src/distill-receipt.js @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +// Transport success is not a receipt that persist:true stored both artifacts. +export function outcomeOfDistill(result, usage) { + const data = result?.data; + if (!result?.ok) return { status: 'failed', error: String(result?.error), usage }; + const hasId = asset => typeof asset?.asset_id === 'string' && asset.asset_id.trim().length > 0; + if (data?.ok !== true || data.status !== 'stored' || !hasId(data.gene) || !hasId(data.capsule) || data.quality?.ok === false) { + return { status: 'failed', error: String(data?.reason ?? `distill_not_persisted:${data?.status ?? 'unknown'}`), data, usage }; + } + return { status: 'sent', data, usage }; +} diff --git a/src/session-distill.js b/src/session-distill.js index e21395b..1b2b0a9 100644 --- a/src/session-distill.js +++ b/src/session-distill.js @@ -1,3 +1,4 @@ +import { outcomeOfDistill } from './distill-receipt.js'; // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap @@ -230,7 +231,7 @@ export async function distillCandidate({ proxyFetch, llm, candidate, focus, time if (!args) return { status: 'unparsed', usage }; if (cancel?.aborted || !isCurrent()) return { status: 'dropped', usage }; const result = await proxyFetch('POST', '/conversation/distill', distillBody(candidate, args, usage), signal); - return result?.ok ? { status: 'sent', usage, data: result.data } : { status: 'failed', error: String(result?.error) }; + return outcomeOfDistill(result, usage); } catch (error) { return { status: 'failed', error: String(error?.message ?? error) }; } diff --git a/test/distill-receipt.test.js b/test/distill-receipt.test.js new file mode 100644 index 0000000..b86cc32 --- /dev/null +++ b/test/distill-receipt.test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { outcomeOfDistill } from '../src/distill-receipt.js'; + +const stored = { ok: true, status: 'stored', gene: { asset_id: 'gene_x' }, capsule: { asset_id: 'capsule_x' }, quality: { ok: true } }; +const usage = { input_tokens: 10, output_tokens: 20 }; + +test('only a stored receipt with both artifact identities is sent', () => { + const result = outcomeOfDistill({ ok: true, data: stored }, usage); + assert.equal(result.status, 'sent'); + assert.equal(result.data, stored); + assert.equal(result.usage, usage); + // Older stored receipts need not contain the optional quality field. + assert.equal(outcomeOfDistill({ ok: true, data: { ...stored, quality: undefined } }, usage).status, 'sent'); +}); + +test('HTTP success with rejected, draft, malformed or incomplete data is not persisted', () => { + for (const data of [undefined, {}, { id: 'legacy-without-proof' }, { ok: false, reason: 'quality_gate' }, + { ...stored, status: 'draft' }, { ...stored, status: 'rejected' }, { ...stored, quality: { ok: false } }, + { ...stored, gene: null }, { ...stored, capsule: null }, { ...stored, gene: { asset_id: ' ' } }, + { ...stored, capsule: { asset_id: 42 } }]) { + const outcome = outcomeOfDistill({ ok: true, data }, usage); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.data, data); + assert.equal(outcome.usage, usage); + } +}); + +test('transport failure and business rejection preserve usage and the reason', () => { + assert.deepEqual(outcomeOfDistill({ ok: false, error: 'unreachable' }, usage), { status: 'failed', error: 'unreachable', usage }); + assert.equal(outcomeOfDistill({ ok: true, data: { ok: false, reason: 'quality_gate' } }, usage).error, 'quality_gate'); +}); diff --git a/test/session-distill.test.js b/test/session-distill.test.js index d8bfcde..309ef8a 100644 --- a/test/session-distill.test.js +++ b/test/session-distill.test.js @@ -69,7 +69,7 @@ test('a list the model wrote as one numbered paragraph becomes steps', () => { test('a candidate is distilled through the host model and sent to the Proxy with its own cost', async () => { const sent = []; - const proxyFetch = async (method, path, body) => { sent.push({ path, body }); return { ok: true, data: { id: 'gene_x' } }; }; + const proxyFetch = async (method, path, body) => { sent.push({ path, body }); return { ok: true, data: { id: 'gene_x', ok: true, status: 'stored', gene: { asset_id: 'gene_x' }, capsule: { asset_id: 'capsule_x' } } }; }; const candidate = candidateOf({ prompt: 'add a retry', record: record(), model: MODEL, turn: 1 }); const llm = fakeLlm(); const outcome = await distillCandidate({ proxyFetch, llm, candidate, timeoutMs: 1_000 }); @@ -181,7 +181,11 @@ async function harness(t, prefix, answerOf) { return { proxy, listeners, llm: ctx.llm }; } -const proxyAnswer = (assets = []) => (path) => (path === '/asset/fetch' ? { assets } : { recorded: true }); +const proxyAnswer = (assets = []) => (path) => { + if (path === '/asset/fetch') return { assets }; + if (path === '/conversation/distill') return { ok: true, status: 'stored', gene: { asset_id: 'gene_x' }, capsule: { asset_id: 'capsule_x' } }; + return { recorded: true }; +}; async function verifiedTurn(listeners, agent, turn = 1) { await primedBy(listeners, agent, 'add a retry to the uploader', turn); @@ -314,3 +318,18 @@ test("the next session reports a killed session's open strategies and distills i assert.equal(report.outcome, 'failed'); assert.equal(llm.calls.length, 1); }); + +test('an HTTP 200 rejection retains the candidate and its model usage for a retry', async (t) => { + t.after(freshHome('evolver-distill-receipt-')); + const candidate = candidateOf({ prompt: 'add a retry', record: record(), model: MODEL, turn: 1 }); + rememberCandidate('receipt-rejected', candidate); + const outcome = await distillCandidate({ + proxyFetch: async () => ({ ok: true, data: { ok: false, reason: 'quality_gate', status: 'rejected' } }), + llm: fakeLlm(), candidate, timeoutMs: 1_000, + }); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.error, 'quality_gate'); + assert.ok(outcome.usage); + settleCandidate('receipt-rejected', candidate, { sent: outcome.status === 'sent' }); + assert.equal(dueCandidates({ sessionId: 'receipt-rejected' })[0].candidate.attempts, 1); +});