diff --git a/changelog.d/publish-receipt-validation.fixed.md b/changelog.d/publish-receipt-validation.fixed.md new file mode 100644 index 000000000..eda5f2949 --- /dev/null +++ b/changelog.d/publish-receipt-validation.fixed.md @@ -0,0 +1 @@ +- Require an explicit `delivered: true` receipt before reporting final channel speech as delivered. Missing or malformed publish receipts surface a routing failure with uncertain delivery and do not trigger an automatic resend. diff --git a/src/framework.ts b/src/framework.ts index 3bad57676..b2bf31ac5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11775,7 +11775,7 @@ export class AgentFramework { activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName), // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the - // agent sees, on her next turn, that her reply never reached the human. + // agent sees, on her next turn, that delivery of her reply was not confirmed. // addMessage() alone does not request inference, so this never wakes // her (matching the `discord-send-failed-skip` gate intent: context // yes, wake no). @@ -11798,7 +11798,7 @@ export class AgentFramework { 'user', [{ type: 'text', - text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). It was saved to your archive but the human did not receive it.`, + text: `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It was saved to your archive. Check delivery status before resending; a missing receipt does not prove the human did not receive it.`, }], { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason }, ); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 636e032dd..f82fca967 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2794,9 +2794,9 @@ export class ChannelRegistry { ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { // Surface a routing failure: emit a trace AND notify the host (which drops // a `[discord-send-failed]` marker into chronicle) so the agent learns her - // reply never reached the human, instead of it vanishing silently. + // reply delivery was not confirmed, instead of it vanishing silently. const fail = (channelId: string | null, reason: string): null => { - console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`); + console.error(`[routeSpeech] ${conversationId}: ${reason} — speech delivery NOT confirmed (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, @@ -2876,7 +2876,7 @@ export class ChannelRegistry { content: [{ type: 'text', text }], }; const result = await server.sendChannelsPublish(publishParams); - const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true; + const delivered = (result as { delivered?: unknown } | undefined)?.delivered; // Surface the posted message's id (ChannelsPublishResult.messageId) so // trace consumers can act on the just-posted message — e.g. a TTS-relay // tap editing it down to the words actually voiced on interruption. @@ -2890,6 +2890,12 @@ export class ChannelRegistry { return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`); } + // Final publish is a request, so success requires an explicit receipt. + // Missing/malformed responses leave delivery uncertain; do not retry here. + if (delivered !== true) { + return fail(channelId, `server "${entry.serverId}" returned no valid delivery receipt for "${channelId}" (delivery uncertain)`); + } + console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`); this.emitTraceFn({ type: 'mcpl:speech-routed', diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 8468c2d37..2d144a4df 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -526,3 +526,23 @@ test('DM prose targets resolve people-first: @name, prefix-lenient names, and <@ assert.ok('error' in missing && /send_dm/.test(missing.error), 'no-match points at send_dm'); assert.ok('error' in missing && (missing.candidates?.length ?? 0) === 2, 'lists known DMs by name'); }); + +for (const [label, receipt] of [ + ['absent', undefined], + ['missing delivered', {}], + ['null delivered', { delivered: null }], + ['string delivered', { delivered: 'true' }], +] as const) { + test(`routeSpeech does not claim delivery with ${label} receipt`, async () => { + // Malformed responses cross the JSON-RPC boundary despite static types. + const { registry, failures, traces, publishCalls } = makeRegistry(receipt as unknown as { delivered?: boolean } | undefined); + seedRegistered(registry, 'fixture', 'fixture-channel'); + registry.handleIncoming('fixture', incoming('fixture-channel', 'synthetic input')); + const result = await registry.routeSpeech('fixture-agent', 'synthetic reply', 'fixture-channel'); + assert.equal(result, null); + assert.equal(publishCalls.length, 1, 'uncertain delivery must not automatically retry'); + assert.equal(failures.length, 1); + assert.ok(traces.some(t => t.type === 'mcpl:speech-route-failed')); + assert.ok(!traces.some(t => t.type === 'mcpl:speech-routed')); + }); +}