diff --git a/changelog.d/draft-notice-wording.changed.md b/changelog.d/draft-notice-wording.changed.md new file mode 100644 index 000000000..d54aa2107 --- /dev/null +++ b/changelog.d/draft-notice-wording.changed.md @@ -0,0 +1,5 @@ +- The held-draft notice now says which message it means and why in a full + sentence: "your plain-speech message starting "…" was not sent and is + instead held as draft d-… because there was an explicit send in the same + round as the plain speech". Several drafts are each named with their + opening words. The drafts tool's listing keeps its wording. diff --git a/src/framework.ts b/src/framework.ts index 2df4807f1..33d63a54c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14395,8 +14395,15 @@ export class AgentFramework { bounced: 'routing prefix bounced', }; + /** Why the held-draft notice's drafts were held, as the clause after "because". */ + private static readonly DRAFT_REASON_BECAUSE: Record string> = { + 'explicit-send': () => 'there was an explicit send in the same round as the plain speech', + 'no-destination': (many) => `${many ? 'they' : 'it'} had no destination — after a routing envelope failed, ` + + 'plain speech waits for a new valid target', + bounced: (many) => `${many ? 'their' : 'its'} routing prefix bounced`, + }; + private draftsHeldNotice(agentName: string, held: Draft[], reason: DraftReason): string { - const ids = held.map((d) => d.id); const many = held.length > 1; const quoted = (ds: Draft[]): string => ds.map((d) => `"${d.id}"`).join(', '); // Words copied from a draft that may already have been posted resend @@ -14411,12 +14418,23 @@ export class AgentFramework { ...risky.map((d) => `${this.riskText(d)}, so resending ${d.id} could post it twice ` + `(to send it anyway: drafts(action: "resend", draftIds: ["${d.id}"], destination: "#channel", confirmDuplicate: true))`), ]; - // Named: residents that share one message slot (#197) share this window. + // Each draft is named with its opening words, so the resident can tell + // which of its messages this is. + const opening = (d: Draft): string => AgentFramework.draftPreview(d.text, 50); + const named = held.map((d) => `${d.id} (starting ${opening(d)})`); + const which = many + ? `your ${held.length} plain-speech messages` + : `your plain-speech message starting ${opening(held[0]!)}`; // "Not sent" only when no copied words may already have been posted. + const status = risky.length > 0 + ? `${many ? 'are' : 'is'} held as` + : `${many ? 'were not sent and are' : 'was not sent and is'} instead held as`; + const heldAs = many + ? `drafts ${named.slice(0, -1).join(', ')} and ${named.at(-1)}` + : `draft ${held[0]!.id}`; + // Named: residents that share one message slot (#197) share this window. return ( - `[drafts] ${agentName}: ${risky.length > 0 ? '' : 'not sent — '}` + - `${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` + - `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${AgentFramework.DRAFT_REASON_TEXT[reason]}. ` + + `[drafts] ${agentName}: ${which} ${status} ${heldAs} because ${AgentFramework.DRAFT_REASON_BECAUSE[reason](many)}. ` + `${resend.join('; ')}; drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.` ); } @@ -14426,7 +14444,7 @@ export class AgentFramework { return formatZonedDateTime(t, this.timeZone).replace(/:\d\d\.\d{3}/, ''); } - /** A draft's opening words, for the drafts tool's listing only. */ + /** A draft's opening words, for the drafts tool's listing and the held-draft notice. */ private static draftPreview(text: string, max = 80): string { const flat = text.replace(/\s+/g, ' ').trim(); return JSON.stringify(flat.length > max ? `${flat.slice(0, max)}…` : flat); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 05dbd0a30..71ea13fbd 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -584,9 +584,11 @@ describe('held prose drafts, end to end', () => { assert.equal(h.publishes().filter((p) => p.text === 'scout words').length, 0); // Residents of one framework share a message slot (#197), so the // notice is visible there too; it names whose drafts it means. - const held = h.texts('scout').find((t) => t.startsWith(`[drafts] scout: not sent — a plain-speech segment held as draft ${mine.id}`)); + const held = h.texts('scout').find((t) => t.startsWith('[drafts] scout: ')); assert.ok(held); - assert.ok(!held!.includes('scout words'), `the held-draft notice quotes none of the draft's words: ${held}`); + assert.ok(held!.startsWith(`[drafts] scout: your plain-speech message starting "scout words" was not sent and is instead ` + + `held as draft ${mine.id} because there was an explicit send in the same round as the plain speech. `), + `the held-draft notice names the message by its opening words, and why it was held: ${held}`); } finally { await h.close(); } @@ -2167,7 +2169,9 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end const notice = h.messages().find((m) => (m.metadata as { kind?: string; draftIds?: string[] } | undefined)?.kind === 'prose-drafts' && (m.metadata as { draftIds?: string[] }).draftIds?.includes(copy.id))!; const words = notice.content.map((b) => (b as { text?: string }).text ?? '').join(''); - assert.ok(words.startsWith(`[drafts] scout: 2 plain-speech segments held as drafts ${free.id}, ${copy.id} — `), `no "not sent" over words that may be out: ${words}`); + assert.ok(words.startsWith(`[drafts] scout: your 2 plain-speech messages are held as drafts ${free.id} (starting "plain words") ` + + `and ${copy.id} (starting "possibly sent and a new line") because there was an explicit send in the same round as the plain speech. `), + `no "not sent" over words that may be out: ${words}`); assert.ok(words.includes(`drafts(action: "resend", draftIds: ["${free.id}"], destination: "#channel") delivers it unchanged`), words); assert.ok(words.includes(`${copy.id} includes the words of ${original.id}, and ${original.id}'s attempt to #room (Guild One) (${ROOM})`), words); assert.ok(words.includes(`so resending ${copy.id} could post it twice (to send it anyway: drafts(action: "resend", draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true))`), words);