From df784ac8e58292e9a39bb1c13a10bce989a57fec Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Wed, 14 Oct 2026 15:06:11 -0400 Subject: [PATCH 1/2] Let live characters add people to Cc. Co-authored-by: Cursor --- README.md | 2 +- .../prompts/character-reply.md | 6 +- agents/cosmo-mail-character/prompts/system.md | 14 +- agents/cosmo-mail-character/protocol.yaml | 6 + lib/character-replies.js | 130 ++++++++++++- public/app.js | 9 +- server.js | 20 +- tests/api.test.js | 28 +++ tests/character-replies.test.js | 172 ++++++++++++++++++ 9 files changed, 368 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 943079c..e9ea8e1 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ shape, relative to the project root. | `audience` | `learner` \| `candidate` | Default `learner`. `candidate` is for assessments: unless you set them, the assistant model is pinned and the temperature is lowered to `0.2`, and `assistant.allowCustomInstructions` is forced off. Cosmo behaves the same for both audiences: a neutral assistant that does not coach. | | `scenarioType` | `compose_new` \| `reply` \| `reply_chain` | Drives composer prefill. | | `learner` | `{ displayName, email, avatar }` | Who the learner is in the thread. Optional `avatar` is `0`–`12`; **`0` is the empty face and the default for You**. | -| `characters` | object[] | People the learner may put on To / Cc. `{ id, name, email }` required; optional `avatar` (`0`–`12`). Optional `persona` (free-form object or string) and/or `prompt` make the character **live** — they reply via `cosmo-mail-character`. Live characters reply as ordinary people (busy, self-interested, incomplete) unless the persona explicitly says they coach, mentor, or manage the learner's development. `responds: true/false` overrides live vs directory-only. Directory-only people (no persona, `responds` omitted) can be emailed but never write back. | +| `characters` | object[] | People the learner may put on To / Cc. `{ id, name, email }` required; optional `avatar` (`0`–`12`). Optional `persona` (free-form object or string) and/or `prompt` make the character **live** — they reply via `cosmo-mail-character`. Live characters reply as ordinary people (busy, self-interested, incomplete) unless the persona explicitly says they coach, mentor, or manage the learner's development. They can also add directory people to Cc (for example, "CC Victoria and introduce her") without the learner putting them on the thread first; newly looped-in live people then get a chance to write back. `responds: true/false` overrides live vs directory-only. Directory-only people (no persona, `responds` omitted) can be emailed but never write back. | | `world` | string \| `{ summary }` | Shared in-world facts every live character already knows. Cosmo does **not** see this. | | `seed.inbox` | object \| string | Inline `{ threads }` by default. Optionally a path to a JSON file of the same shape for large inboxes. Threads may set `"mailbox": "spam"` to land in Spam; otherwise they start in Inbox. Sent is filled automatically when the learner sends. | | `seed.activeThreadId` | string | Which mailbox to open on load (the folder that contains this thread). | diff --git a/agents/cosmo-mail-character/prompts/character-reply.md b/agents/cosmo-mail-character/prompts/character-reply.md index d446830..99771b1 100644 --- a/agents/cosmo-mail-character/prompts/character-reply.md +++ b/agents/cosmo-mail-character/prompts/character-reply.md @@ -21,5 +21,7 @@ unless that is who you are. This email arrived as text only. You did not receive any attached file. Do not ask them to send a document; if you need more, ask them to put it in the email. -Write the email body only (no subject, no headers, no commentary). Then on the -last line write [[continue]] or [[done]]. +Write the email body only (no subject, no headers, no commentary). If you are +adding someone new to Cc, write [[cc: name-or-email]] on its own line after +the body (one person per line). Then on the last line write [[continue]] or +[[done]]. diff --git a/agents/cosmo-mail-character/prompts/system.md b/agents/cosmo-mail-character/prompts/system.md index 0119f7b..fa5d958 100644 --- a/agents/cosmo-mail-character/prompts/system.md +++ b/agents/cosmo-mail-character/prompts/system.md @@ -77,7 +77,16 @@ How you write - Keep length and tone realistic for email, following your style of communication when one is given. Sign off as {{CHARACTER_NAME}} when a sign-off is natural. - Reply-all is in effect: other people on the thread may see this. Write - accordingly. + accordingly. Reply-all already keeps everyone currently on the thread — + do not re-list them. +- You may add someone new to Cc only when your character card says to + introduce or loop them in, or when you would naturally bring them into + this thread. After the body, write one [[cc: name-or-email]] line per + person, using only people from the list below. Do not invent addresses. + Do not mention these tags in the email body. + +People you can add to Cc: +{{PEOPLE}} ═══════════════════════════════════════════════════════════════════════ When you are finished @@ -91,7 +100,8 @@ still send a short email in your voice — never go silent. A closing note such "I have nothing left to say" (in your own voice) is correct. Do not reopen a settled point, and do not keep the thread alive to teach. -After the email body, on its own last line, write exactly one of: +After the email body, you may write one [[cc: name-or-email]] line per +person you are newly adding to Cc. Then on the last line write exactly one of: [[continue]] — you would keep this correspondence going [[done]] — your goal is met, or you have nothing left to say diff --git a/agents/cosmo-mail-character/protocol.yaml b/agents/cosmo-mail-character/protocol.yaml index aca6f12..68e356b 100644 --- a/agents/cosmo-mail-character/protocol.yaml +++ b/agents/cosmo-mail-character/protocol.yaml @@ -49,6 +49,11 @@ input: optional: true default: "You" description: The name of the person this character is emailing with. + PEOPLE: + type: string + optional: true + default: "" + description: Directory of people this character may add to Cc (name ). triggers: character-reply: @@ -76,6 +81,7 @@ agent: CHARACTER_CARD: CHARACTER_CARD WORLD: WORLD LEARNER_NAME: LEARNER_NAME + PEOPLE: PEOPLE temperature: TEMPERATURE thinking: THINKING webSearch: false diff --git a/lib/character-replies.js b/lib/character-replies.js index d42249a..45c8f30 100644 --- a/lib/character-replies.js +++ b/lib/character-replies.js @@ -7,7 +7,7 @@ * omitted or false) can be emailed but never generate a reply. */ -import { emailsFromAddresses, uniqueEmails } from './characters.js'; +import { characterByEmail, emailsFromAddresses, uniqueEmails } from './characters.js'; import { replySubject } from './mailboxes.js'; const PERSONA_LABELS = { @@ -24,6 +24,11 @@ const PERSONA_LABELS = { const DONE_TAG = /\[\[\s*done\s*\]\]/i; const CONTINUE_TAG = /\[\[\s*continue\s*\]\]/i; +const CC_LINE = /^\[\[\s*cc\s*:\s*([^\]]+?)\s*\]\]$/i; +const CC_TAG_LINE = /^[ \t]*\[\[\s*cc\s*:\s*([^\]]+?)\s*\]\][ \t]*\n?/gim; +const CC_TAG = /\[\[\s*cc\s*:\s*([^\]]+?)\s*\]\]/gi; +const EMAIL_IN_ANGLE = /<([^<>@\s]+@[^<>@\s]+)>/; +const LOOKS_LIKE_EMAIL = /^[^\s@]+@[^\s@]+$/; const NOTHING_LEFT_RE = /nothing left to say|don'?t have anything (else|more) to (say|add)|nothing more to add|i have nothing else/i; @@ -183,6 +188,7 @@ export function selectResponders(email, characters, { doneIds = [], random = Mat export function parseCharacterReply(raw) { let text = String(raw || '').replace(/\r\n/g, '\n').trim(); let done = false; + const cc = []; const lines = text.split('\n'); while (lines.length) { const last = lines[lines.length - 1].trim(); @@ -199,9 +205,29 @@ export function parseCharacterReply(raw) { lines.pop(); continue; } + const ccMatch = last.match(CC_LINE); + if (ccMatch) { + cc.unshift(ccMatch[1].trim()); + lines.pop(); + continue; + } break; } text = lines.join('\n').trim(); + CC_TAG_LINE.lastIndex = 0; + CC_TAG.lastIndex = 0; + text = text + .replace(CC_TAG_LINE, (_, value) => { + cc.push(String(value || '').trim()); + return ''; + }) + .replace(CC_TAG, (_, value) => { + cc.push(String(value || '').trim()); + return ''; + }) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); if (DONE_TAG.test(text)) { done = true; text = text.replace(DONE_TAG, '').trim(); @@ -209,7 +235,68 @@ export function parseCharacterReply(raw) { if (!done && text.length > 0 && text.length < 400 && NOTHING_LEFT_RE.test(text)) { done = true; } - return { body: text, done }; + return { body: text, done, cc: cc.filter(Boolean) }; +} + +function matchDirectoryPerson(raw, characters) { + const text = String(raw || '').trim(); + if (!text) return null; + const angled = text.match(EMAIL_IN_ANGLE); + const email = (angled ? angled[1] : LOOKS_LIKE_EMAIL.test(text) ? text : '').trim(); + if (email) return characterByEmail(characters, email); + + const lower = text.toLowerCase(); + const exact = (characters || []).filter( + (character) => String(character.name || '').trim().toLowerCase() === lower, + ); + if (exact.length === 1) return exact[0]; + + const firstNameHits = (characters || []).filter((character) => { + const first = String(character.name || '').trim().split(/\s+/).find((part) => part.length >= 2); + return first && first.length >= 3 && first.toLowerCase() === lower; + }); + return firstNameHits.length === 1 ? firstNameHits[0] : null; +} + +/** + * Map agent [[cc: …]] values onto directory emails. Unknown names, the + * sender, the learner, and anyone already on To are dropped. + */ +export function resolveCcAdds(rawAdds, characters, { excludeEmails = [] } = {}) { + const skip = new Set( + (excludeEmails || []).map((email) => String(email || '').trim().toLowerCase()).filter(Boolean), + ); + const resolved = []; + for (const raw of rawAdds || []) { + const character = matchDirectoryPerson(raw, characters); + if (!character) continue; + const key = character.email.toLowerCase(); + if (skip.has(key)) continue; + skip.add(key); + resolved.push(character.email); + } + return resolved; +} + +export function formatPeopleDirectory(characters, { excludeEmails = [], learner } = {}) { + const skip = new Set( + (excludeEmails || []).map((email) => String(email || '').trim().toLowerCase()).filter(Boolean), + ); + const lines = []; + const learnerEmail = String(learner?.email || '').trim(); + if (learnerEmail && !skip.has(learnerEmail.toLowerCase())) { + const name = String(learner.displayName || learner.name || 'You').trim() || 'You'; + lines.push(`- ${name} <${learnerEmail}>`); + skip.add(learnerEmail.toLowerCase()); + } + for (const character of characters || []) { + const email = String(character.email || '').trim(); + if (!email || skip.has(email.toLowerCase())) continue; + const role = String(character.role || '').trim(); + lines.push(`- ${character.name} <${email}>${role ? ` — ${role}` : ''}`); + skip.add(email.toLowerCase()); + } + return lines.join('\n'); } function addressEmail(addr) { @@ -219,9 +306,14 @@ function addressEmail(addr) { /** * Reply-all headers for a character answering the learner's outbound email: - * From the character, To the learner plus remaining To, Cc everyone else. + * From the character, To the learner plus remaining To, Cc everyone else + * plus any directory people the character asked to add. */ -export function buildCharacterReplyHeaders(sentEmail, character, { learnerEmail = '', subjectFallback = '' } = {}) { +export function buildCharacterReplyHeaders( + sentEmail, + character, + { learnerEmail = '', subjectFallback = '', ccAdds = [] } = {}, +) { const self = String(character?.email || '').toLowerCase(); const learner = String(learnerEmail || '').toLowerCase(); const originalTo = uniqueEmails(emailsFromAddresses(sentEmail?.to)); @@ -236,15 +328,37 @@ export function buildCharacterReplyHeaders(sentEmail, character, { learnerEmail ...originalTo.filter(notSelfOrLearner), ].filter(Boolean)); - const cc = uniqueEmails(originalCc.filter((email) => { - const key = email.toLowerCase(); - return notSelfOrLearner(email) && !to.some((item) => item.toLowerCase() === key); - })); + const keepOnCc = (email) => + notSelfOrLearner(email) && !to.some((item) => item.toLowerCase() === email.toLowerCase()); + + const cc = uniqueEmails([...originalCc, ...ccAdds].filter(keepOnCc)); const subject = replySubject(sentEmail?.subject || subjectFallback); return { to, cc, subject }; } +/** + * Live people this reply newly put on To/Cc — they were just looped in and + * should get a chance to write back. + */ +export function selectIntroducedResponders(replyEmail, priorEmail, characters) { + const prior = new Set( + uniqueEmails([ + ...emailsFromAddresses(priorEmail?.to), + ...emailsFromAddresses(priorEmail?.cc), + addressEmail(priorEmail?.from), + ]).map((email) => email.toLowerCase()), + ); + const introduced = uniqueEmails([ + ...emailsFromAddresses(replyEmail?.to), + ...emailsFromAddresses(replyEmail?.cc), + ]).filter((email) => !prior.has(email.toLowerCase())); + if (!introduced.length) return []; + return (characters || []).filter( + (character) => characterIsLive(character) && onList(character, introduced), + ); +} + export function formatRecipientsContext(email) { const from = addressEmail(email?.from); const to = uniqueEmails(emailsFromAddresses(email?.to)).join(', ') || '(none)'; diff --git a/public/app.js b/public/app.js index a375f23..c5cb8a0 100644 --- a/public/app.js +++ b/public/app.js @@ -2910,9 +2910,9 @@ async function finalizeCharacterReply(characterId, threadId, replyText, inReplyT }), }); if (!res.ok) return null; - const { thread, email } = await res.json(); + const { thread, email, responders } = await res.json(); refreshMailAfterInbound(thread, email); - return email; + return { email, responders }; } async function ensureCharacterSession(characterId) { @@ -2970,7 +2970,10 @@ async function runCharacterReply(character, sentEmail, threadId) { const replyText = assistantTextFromMessages(chat.messages); if (!replyText.trim()) return; - await finalizeCharacterReply(character.id, threadId, replyText, sentEmail?.id); + const recorded = await finalizeCharacterReply(character.id, threadId, replyText, sentEmail?.id); + if (recorded?.responders?.length) { + await collectCharacterReplies(recorded.responders, recorded.email, threadId); + } } // ── Assistant (Cosmo) ───────────────────────────────────────── diff --git a/server.js b/server.js index b6a29ef..0c4cc31 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,9 @@ import { compileCharacterCard, parseCharacterReply, buildCharacterReplyHeaders, + resolveCcAdds, + selectIntroducedResponders, + formatPeopleDirectory, } from './lib/character-replies.js'; import { buildCapabilityInstructions, @@ -359,6 +362,10 @@ function buildCharacterSessionInput(config, character) { input.CHARACTER_CARD = compileCharacterCard(character); if (config.world) input.WORLD = config.world; input.LEARNER_NAME = config.learner?.displayName || 'You'; + input.PEOPLE = formatPeopleDirectory(config.characters, { + excludeEmails: [character?.email], + learner: config.learner, + }); return input; } @@ -771,7 +778,8 @@ app.post('/api/character/trigger', async (req, res) => { }); // POST /api/character/complete — append a finished character reply as inbound -// mail (reply-all) and record whether that character is done. +// mail (reply-all, plus any [[cc:]] adds), record whether that character is +// done, and list live people they just looped in so the client can ask them. app.post('/api/character/complete', async (req, res) => { const { sessionId, threadId, characterId, reply, inReplyToId } = req.body; if (!sessionId || !characterId) { @@ -786,6 +794,7 @@ app.post('/api/character/complete', async (req, res) => { let email; let thread; let done; + let sent; const record = await updateSessionById(sessionId, (fresh) => { thread = fresh.threads.find((t) => t.id === threadId) ?? fresh.threads[0]; if (!thread) { @@ -794,15 +803,19 @@ app.post('/api/character/complete', async (req, res) => { throw err; } - const sent = + sent = thread.emails.find((entry) => entry.id === inReplyToId) ?? [...thread.emails].reverse().find((entry) => entry.outbound); const parsed = parseCharacterReply(reply); done = parsed.done; const learnerEmail = config.learner?.email || 'you@example.com'; + const ccAdds = resolveCcAdds(parsed.cc, characters, { + excludeEmails: [character.email, learnerEmail], + }); const headers = buildCharacterReplyHeaders(sent, character, { learnerEmail, subjectFallback: thread.subject, + ccAdds, }); email = { @@ -826,7 +839,8 @@ app.post('/api/character/complete', async (req, res) => { }); if (!record) return res.status(404).json({ error: 'Session not found' }); - res.json({ email, thread, done }); + const responders = selectIntroducedResponders(email, sent, characters).map(publicResponder); + res.json({ email, thread, done, responders }); } catch (err) { if (err?.status === 404) return res.status(404).json({ error: err.message }); console.error('[character/complete] Error:', err); diff --git a/tests/api.test.js b/tests/api.test.js index a7379a5..0e783d6 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -319,6 +319,34 @@ describe('session lifecycle', () => { expect(reload.body.events[2].toolCallId).toBe('call-save-1'); }); + it('POST /api/character/complete records a reply-all and strips protocol tags', async () => { + const sent = await request(app).post('/api/email/send').send({ + sessionId, + threadId: 'thread-1', + to: ['dana@acme-vendor.com'], + cc: [], + subject: 'Re: Proposal for annual license', + body: 'Can you do 24 months?', + }); + expect(sent.status).toBe(200); + + const res = await request(app).post('/api/character/complete').send({ + sessionId, + threadId: 'thread-1', + characterId: 'dana', + inReplyToId: sent.body.email.id, + reply: 'I can take 24 months to my team.\n\nDana\n\n[[cc: nobody@example.com]]\n[[done]]', + }); + expect(res.status).toBe(200); + expect(res.body.email.body).toBe('I can take 24 months to my team.\n\nDana'); + expect(res.body.email.cc).toEqual([]); + expect(res.body.email.to).toEqual([ + expect.objectContaining({ email: expect.stringMatching(/@/) }), + ]); + expect(res.body.done).toBe(true); + expect(res.body.responders).toEqual([]); + }); + it('POST /api/assistant/clear wipes the assistant transcript', async () => { const clear = await request(app).post('/api/assistant/clear').send({ sessionId }); expect(clear.status).toBe(200); diff --git a/tests/character-replies.test.js b/tests/character-replies.test.js index 1b950ef..640fa10 100644 --- a/tests/character-replies.test.js +++ b/tests/character-replies.test.js @@ -8,6 +8,9 @@ import { buildCharacterReplyHeaders, characterMentionedIn, normalizeWorld, + resolveCcAdds, + formatPeopleDirectory, + selectIntroducedResponders, } from '../lib/character-replies.js'; import { normalizeCharacters } from '../lib/characters.js'; @@ -155,16 +158,94 @@ describe('parseCharacterReply', () => { expect(parseCharacterReply('Thanks, that works.\n\n[[done]]')).toEqual({ body: 'Thanks, that works.', done: true, + cc: [], }); expect(parseCharacterReply('Could you send a redline?\n[[continue]]')).toEqual({ body: 'Could you send a redline?', done: false, + cc: [], }); }); it('treats a short "nothing left to say" body as done', () => { expect(parseCharacterReply('I have nothing left to say.')).toMatchObject({ done: true }); }); + + it('collects trailing [[cc:]] tags and keeps them out of the body', () => { + expect( + parseCharacterReply( + 'Looping in Victoria Vance (cc\'d).\n\nRyan\n\n[[cc: Victoria Vance]]\n[[cc: alex.rivera@bestcrm.com]]\n[[done]]', + ), + ).toEqual({ + body: 'Looping in Victoria Vance (cc\'d).\n\nRyan', + done: true, + cc: ['Victoria Vance', 'alex.rivera@bestcrm.com'], + }); + }); + + it('strips a [[cc:]] tag buried in the body', () => { + expect(parseCharacterReply('Thanks.\n[[cc: Victoria]]\nSee you Friday.')).toEqual({ + body: 'Thanks.\nSee you Friday.', + done: false, + cc: ['Victoria'], + }); + }); +}); + +describe('resolveCcAdds / formatPeopleDirectory', () => { + const victoria = { + id: 'victoria', + name: 'Victoria Vance', + email: 'victoria.vance@globalsolutionsltd.com', + role: 'Procurement Lead', + persona: { goal: 'Review commercials' }, + }; + const people = normalizeCharacters([dana, marcus, victoria, priyaSilent]); + + it('resolves email, angled address, full name, and unique first name', () => { + expect( + resolveCcAdds( + [ + 'victoria.vance@globalsolutionsltd.com', + 'Dana Reyes ', + 'Marcus Bell', + 'Priya', + ], + people, + ), + ).toEqual([ + victoria.email, + dana.email, + marcus.email, + priyaSilent.email, + ]); + }); + + it('drops unknown people, duplicates, and excluded addresses', () => { + expect( + resolveCcAdds( + ['Nobody Important', 'Dana', dana.email, 'not-an-email'], + people, + { excludeEmails: [dana.email] }, + ), + ).toEqual([]); + }); + + it('lists everyone except the writing character', () => { + expect( + formatPeopleDirectory(people, { + excludeEmails: [dana.email], + learner: { displayName: 'You', email: 'you@bestcrm.com' }, + }), + ).toBe( + [ + '- You ', + '- Marcus Bell — Customer', + '- Victoria Vance — Procurement Lead', + '- Priya Nair — Partner', + ].join('\n'), + ); + }); }); describe('buildCharacterReplyHeaders', () => { @@ -186,4 +267,95 @@ describe('buildCharacterReplyHeaders', () => { subject: 'Re: Proposal for annual license', }); }); + + it('adds directory people the character asked to Cc', () => { + expect( + buildCharacterReplyHeaders( + { + from: { name: 'You', email: 'you@company.com' }, + to: [dana.email], + cc: [marcus.email], + subject: 'Proposal for annual license', + }, + dana, + { + learnerEmail: 'you@company.com', + ccAdds: [morgan.email, dana.email, 'you@company.com'], + }, + ), + ).toEqual({ + to: ['you@company.com'], + cc: [marcus.email, morgan.email], + subject: 'Re: Proposal for annual license', + }); + }); +}); + +describe('selectIntroducedResponders', () => { + const victoria = { + id: 'victoria', + name: 'Victoria Vance', + email: 'victoria.vance@globalsolutionsltd.com', + persona: { goal: 'Review commercials' }, + }; + const people = normalizeCharacters([dana, marcus, victoria, morgan, priyaSilent]); + + it('returns live people newly added to Cc, not anyone already on the thread', () => { + const prior = { + from: { name: 'You', email: 'you@company.com' }, + to: [dana.email], + cc: [marcus.email], + }; + const reply = { + from: dana, + to: ['you@company.com'], + cc: [marcus.email, victoria.email, morgan.email, priyaSilent.email], + }; + expect(selectIntroducedResponders(reply, prior, people).map((c) => c.id)).toEqual([ + 'victoria', + ]); + }); + + it('returns nobody when the reply only reply-alls existing recipients', () => { + const prior = { from: { email: 'you@company.com' }, to: [dana.email], cc: [marcus.email] }; + const reply = { from: dana, to: ['you@company.com'], cc: [marcus.email] }; + expect(selectIntroducedResponders(reply, prior, people)).toEqual([]); + }); +}); + +describe('Ryan introducing Victoria', () => { + const ryan = { + id: 'ryan', + name: 'Ryan Smith', + email: 'ryan.smith@globalsolutionsltd.com', + persona: { goal: 'Introduce Victoria when persuaded' }, + }; + const victoria = { + id: 'victoria', + name: 'Victoria Vance', + email: 'victoria.vance@globalsolutionsltd.com', + persona: { goal: 'Review commercials once introduced' }, + }; + const people = normalizeCharacters([ryan, victoria]); + const learnerEmail = 'you@bestcrm.com'; + const sent = { + from: { name: 'You', email: learnerEmail }, + to: [ryan.email], + cc: [], + subject: 'Re: Following up on our session / pricing details', + }; + + it('puts Victoria on Cc and flags her as the next responder', () => { + const parsed = parseCharacterReply( + "Looping in Victoria Vance (cc'd).\n\nRyan\n\n[[cc: Victoria Vance]]\n[[done]]", + ); + const ccAdds = resolveCcAdds(parsed.cc, people, { + excludeEmails: [ryan.email, learnerEmail], + }); + const headers = buildCharacterReplyHeaders(sent, ryan, { learnerEmail, ccAdds }); + const reply = { from: ryan, to: headers.to, cc: headers.cc }; + expect(parsed.body).toBe("Looping in Victoria Vance (cc'd).\n\nRyan"); + expect(headers.cc).toEqual([victoria.email]); + expect(selectIntroducedResponders(reply, sent, people).map((c) => c.id)).toEqual(['victoria']); + }); }); From 5e27f6c64aa738241bf0163abad0019dbb407b81 Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Wed, 14 Oct 2026 15:27:44 -0400 Subject: [PATCH 2/2] Keep the prior sender on To when a character replies. Co-authored-by: Cursor --- lib/character-replies.js | 9 +++++---- scenario.example.json | 10 ++++++++++ tests/api.test.js | 27 +++++++++++++++++++++++++++ tests/character-replies.test.js | 12 ++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/lib/character-replies.js b/lib/character-replies.js index 45c8f30..71c51b2 100644 --- a/lib/character-replies.js +++ b/lib/character-replies.js @@ -305,9 +305,9 @@ function addressEmail(addr) { } /** - * Reply-all headers for a character answering the learner's outbound email: - * From the character, To the learner plus remaining To, Cc everyone else - * plus any directory people the character asked to add. + * Reply-all headers for a character answering an email: + * From the character, To the learner plus the prior sender and remaining To, + * Cc everyone else plus any directory people the character asked to add. */ export function buildCharacterReplyHeaders( sentEmail, @@ -316,6 +316,7 @@ export function buildCharacterReplyHeaders( ) { const self = String(character?.email || '').toLowerCase(); const learner = String(learnerEmail || '').toLowerCase(); + const originalFrom = addressEmail(sentEmail?.from); const originalTo = uniqueEmails(emailsFromAddresses(sentEmail?.to)); const originalCc = uniqueEmails(emailsFromAddresses(sentEmail?.cc)); const notSelfOrLearner = (email) => { @@ -325,7 +326,7 @@ export function buildCharacterReplyHeaders( const to = uniqueEmails([ learnerEmail, - ...originalTo.filter(notSelfOrLearner), + ...[originalFrom, ...originalTo].filter(notSelfOrLearner), ].filter(Boolean)); const keepOnCc = (email) => diff --git a/scenario.example.json b/scenario.example.json index 7f271fa..dd6b5f8 100644 --- a/scenario.example.json +++ b/scenario.example.json @@ -30,6 +30,16 @@ "Do not invent discounts, legal terms, or dates that are not in knowledge" ] } + }, + { + "id": "lee", + "name": "Lee Park", + "email": "lee.park@acme-vendor.com", + "role": "Deal desk", + "persona": { + "goal": "Review commercial exceptions when Dana loops you in.", + "constraints": ["Do not engage until Dana introduces you."] + } } ], diff --git a/tests/api.test.js b/tests/api.test.js index 0e783d6..178d5f6 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -347,6 +347,33 @@ describe('session lifecycle', () => { expect(res.body.responders).toEqual([]); }); + it('POST /api/character/complete Ccs a directory person and returns them as a responder', async () => { + const sent = await request(app).post('/api/email/send').send({ + sessionId, + threadId: 'thread-1', + to: ['dana@acme-vendor.com'], + cc: [], + subject: 'Re: Proposal for annual license', + body: 'Please involve deal desk on the 24-month option.', + }); + expect(sent.status).toBe(200); + + const res = await request(app).post('/api/character/complete').send({ + sessionId, + threadId: 'thread-1', + characterId: 'dana', + inReplyToId: sent.body.email.id, + reply: 'Looping in Lee from deal desk.\n\nDana\n\n[[cc: Lee Park]]\n[[continue]]', + }); + expect(res.status).toBe(200); + expect(res.body.email.cc).toEqual([ + expect.objectContaining({ name: 'Lee Park', email: 'lee.park@acme-vendor.com' }), + ]); + expect(res.body.responders).toEqual([ + expect.objectContaining({ id: 'lee', email: 'lee.park@acme-vendor.com' }), + ]); + }); + it('POST /api/assistant/clear wipes the assistant transcript', async () => { const clear = await request(app).post('/api/assistant/clear').send({ sessionId }); expect(clear.status).toBe(200); diff --git a/tests/character-replies.test.js b/tests/character-replies.test.js index 640fa10..4bf22eb 100644 --- a/tests/character-replies.test.js +++ b/tests/character-replies.test.js @@ -358,4 +358,16 @@ describe('Ryan introducing Victoria', () => { expect(headers.cc).toEqual([victoria.email]); expect(selectIntroducedResponders(reply, sent, people).map((c) => c.id)).toEqual(['victoria']); }); + + it('keeps Ryan on To when Victoria replies to his introduction', () => { + const ryanReply = { + from: ryan, + to: [learnerEmail], + cc: [victoria.email], + subject: 'Re: Following up on our session / pricing details', + }; + const headers = buildCharacterReplyHeaders(ryanReply, victoria, { learnerEmail }); + expect(headers.to).toEqual([learnerEmail, ryan.email]); + expect(headers.cc).toEqual([]); + }); });