Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,40 @@ describe('prepareCampaignMessage', () => {
).toMatchObject({ status: 'ready', subject: 'A practical place to start' });
});

it('closes the sequence on the final step even when evidence copy is selected', () => {
const cited = artifact({
cited_signals: [
{ signal: 'Bounded source fact', source_ids: ['source-1'] },
],
sources: [
{
id: 'source-1',
url: 'https://example.com/about',
retrieved_at: '2026-09-01T12:00:00.000Z',
content_hash: 'a'.repeat(64),
},
],
drafts: [
{ angle_id: 'streaming_foundation', source_id: 'source-1' },
{ angle_id: 'debugging_layers', source_id: 'source-1' },
{ angle_id: 'event_state_boundary', source_id: 'source-1' },
],
});

const message = prepareCampaignMessage({
context: context({ enrichmentArtifact: cited }),
job: job('send_step', { campaign_version: 'v1', step: 3 }),
now: new Date('2026-09-09T12:05:00.000Z'),
unsubscribeUrl: UNSUBSCRIBE,
});

expect(message).toMatchObject({
status: 'ready',
subject: 'One event-state boundary',
});
expect(JSON.stringify(message)).toContain('last automated follow-up');
});

it('falls back per fixed step when an artifact draft violates copy checks', () => {
const invalid = artifact({
drafts: [
Expand Down
4 changes: 3 additions & 1 deletion apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ function draftFor(
source_ids.includes(selection.source_id)
);
if (selection !== null && cited) {
return renderEvidenceCampaignTemplate(selection.angle_id);
return renderEvidenceCampaignTemplate(selection.angle_id, {
finalStep: step === 3,
});
}
}
return renderCampaignTemplate(STEP_NAMES[step]);
Expand Down
15 changes: 15 additions & 0 deletions apps/lifecycle/src/campaign/templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
campaignDraftViolations,
normalizeCampaignDraft,
renderCampaignTemplate,
renderEvidenceCampaignTemplate,
} from './templates.js';

function wordCount(value: string): number {
Expand Down Expand Up @@ -34,6 +35,20 @@ describe('renderCampaignTemplate', () => {
);
});

it('appends the last-follow-up notice to an evidence template only for the final step', () => {
const final = renderEvidenceCampaignTemplate('event_state_boundary', {
finalStep: true,
});
const earlier = renderEvidenceCampaignTemplate('event_state_boundary');

expect(final.body).toContain('last automated follow-up');
expect(final.body.endsWith('This is my last automated follow-up.')).toBe(
true
);
expect(campaignDraftViolations(final)).toEqual([]);
expect(earlier.body).not.toContain('last automated follow-up');
});

it('rejects an unknown campaign step at runtime', () => {
expect(() => renderCampaignTemplate('day-30' as never)).toThrow();
});
Expand Down
12 changes: 10 additions & 2 deletions apps/lifecycle/src/campaign/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,16 @@ export function renderCampaignTemplate(step: CampaignStep): CampaignDraft {
return normalizeCampaignDraft(CAMPAIGN_TEMPLATES[parsedStep]);
}

const FINAL_STEP_NOTICE = 'This is my last automated follow-up.';

export function renderEvidenceCampaignTemplate(
angle: CampaignEvidenceAngle
angle: CampaignEvidenceAngle,
options: { finalStep?: boolean } = {}
): CampaignDraft {
return normalizeCampaignDraft(EVIDENCE_TEMPLATES[angle]);
const template = EVIDENCE_TEMPLATES[angle];
return normalizeCampaignDraft(
options.finalStep
? { ...template, body: `${template.body}\n\n${FINAL_STEP_NOTICE}` }
: template
);
}
Loading
Loading