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
1 change: 1 addition & 0 deletions changelog.d/publish-receipt-validation.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fork failure reaches wrong agent When a conversation fork receives a missing publish receipt, the new failure path calls this handler. The marker says “your previous reply,” but addMessage has no target here and writes it to the primary agent’s context. The fork that authored the reply gets no delivery warning.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 11801

Comment:
**Fork failure reaches wrong agent** When a conversation fork receives a missing publish receipt, the new failure path calls this handler. The marker says “your previous reply,” but `addMessage` has no target here and writes it to the primary agent’s context. The fork that authored the reply gets no delivery warning.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}],
{ system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
);
Expand Down
12 changes: 9 additions & 3 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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)`);
Comment on lines +2895 to +2896

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Uncertain delivery triggers a bounce If a connector posts a hybrid >>> message but returns a missing or malformed receipt, this branch returns null. The hybrid caller then tells the agent the text was not delivered, retains it for {{unsent}}, and requests another turn. The agent may resend a message the human already received.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 2895-2896

Comment:
**Uncertain delivery triggers a bounce** If a connector posts a hybrid `>>>` message but returns a missing or malformed receipt, this branch returns `null`. The hybrid caller then tells the agent the text was not delivered, retains it for `{{unsent}}`, and requests another turn. The agent may resend a message the human already received.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
this.emitTraceFn({
type: 'mcpl:speech-routed',
Expand Down
20 changes: 20 additions & 0 deletions test/channel-registry-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
}
Loading