Skip to content
Open
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
5 changes: 5 additions & 0 deletions changelog.d/draft-notice-wording.changed.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 24 additions & 6 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DraftReason, (many: boolean) => 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
Expand All @@ -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.`
);
}
Expand All @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions test/prose-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down
Loading