Skip to content

Commit 02de90d

Browse files
committed
Correlate hosted SMS delivery failures
1 parent 40303a1 commit 02de90d

8 files changed

Lines changed: 140 additions & 5 deletions

File tree

src/gateway/dispatch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ContactResolver } from "./contacts.js";
77
import { normalizeAddress } from "./contacts.js";
88
import type { NotifyOnce } from "./dedup.js";
99
import { deliveryFailureKey, deliveryFailureRecovery } from "./delivery-policy.js";
10+
import { isSuccessfulHostedSmsMessage } from "./hosted-call-registry.js";
1011
import { downloadMedia, mediaDir } from "./media.js";
1112
import { SILENT } from "./prompts.js";
1213
import type {
@@ -425,6 +426,10 @@ async function handleDeliveryFailure(
425426
const r = resourceOf(event.body, isText ? "text_message" : "message");
426427
if (str(r?.direction)?.toLowerCase() === "inbound") return true;
427428
const messageId = str(r?.id);
429+
if (isText && messageId && isSuccessfulHostedSmsMessage(messageId)) {
430+
deps.logger.info("dispatch.hosted_sms_delivery_failed");
431+
return true;
432+
}
428433
const recipientRows = Array.isArray(r?.recipients) ? r.recipients : [];
429434
const failedRecipient = recipientRows
430435
.map((item) => record(item))

src/gateway/hosted-call-registry.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface HostedSmsAttempt {
1414
phase: "initial" | "correction";
1515
id: string;
1616
messageId?: string;
17+
providerMessageId?: string;
1718
target?: string;
1819
targetMatches: boolean;
1920
state: "pending" | "success" | "failed";
@@ -386,6 +387,7 @@ export function settleHostedSmsAttempt(
386387
guard: HostedSmsGuard,
387388
state: "success" | "failed",
388389
errorKind?: HostedSmsErrorKind,
390+
providerMessageId?: string,
389391
): void {
390392
withRegistryMutation((registry) => {
391393
const entry = registry[hostedCallKey(guard.identityId, guard.callId)];
@@ -395,10 +397,21 @@ export function settleHostedSmsAttempt(
395397
}
396398
attempt.state = state;
397399
attempt.errorKind = errorKind;
400+
if (state === "success") attempt.providerMessageId = bounded(providerMessageId, 256);
398401
entry.updatedAt = Date.now();
399402
});
400403
}
401404

405+
export function isSuccessfulHostedSmsMessage(providerMessageId: string): boolean {
406+
const id = providerMessageId.trim();
407+
if (!id) return false;
408+
return Object.values(read()).some((entry) =>
409+
entry.smsAttempts.some(
410+
(attempt) => attempt.state === "success" && attempt.providerMessageId === id,
411+
),
412+
);
413+
}
414+
402415
export function classifyHostedSmsError(error: unknown): HostedSmsErrorKind {
403416
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
404417
if (

src/tools/send-sms.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,13 @@ export function sendSmsTools(deps: ToolDeps): RegisteredTool[] {
166166
};
167167
const msg = await identity.sendText(payload);
168168
providerAccepted = true;
169-
if (hostedGuard) settleHostedSmsAttempt(hostedGuard, "success");
169+
if (hostedGuard) {
170+
const providerMessageId = String(msg.id ?? "").trim();
171+
if (!providerMessageId) {
172+
throw new Error("SMS provider accepted the message without a message id.");
173+
}
174+
settleHostedSmsAttempt(hostedGuard, "success", undefined, providerMessageId);
175+
}
170176
const target = formatTargetSummary(msg, args);
171177
const status = msg.deliveryStatus ?? "unknown";
172178
return {

tests/gateway/dispatch.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
// Event routing: channel selection, sender filtering (self/control/allowlist),
22
// reactions, deduped delivery-failure captures, sender agent identities,
33
// external events, and media.
4+
import * as fs from "node:fs";
5+
import * as os from "node:os";
6+
import * as path from "node:path";
47
import { beforeEach, describe, expect, it, vi } from "vitest";
58
import type { ResolvedConfig } from "../../src/config.js";
69
import { defaultGatewayConfig } from "../../src/config.js";
710
import { createNotifyOnce } from "../../src/gateway/dedup.js";
811
import type { DispatchDeps } from "../../src/gateway/dispatch.js";
912
import { dispatchEvent } from "../../src/gateway/dispatch.js";
13+
import {
14+
activateHostedSmsCapture,
15+
beginHostedSmsAttempt,
16+
saveHostedCall,
17+
settleHostedSmsAttempt,
18+
} from "../../src/gateway/hosted-call-registry.js";
1019
import { downloadMedia, mediaDir } from "../../src/gateway/media.js";
1120
import { frameInbound } from "../../src/gateway/prompts.js";
1221
import type { VerifiedEvent } from "../../src/gateway/types.js";
@@ -270,6 +279,73 @@ describe("dispatchEvent reactions", () => {
270279
});
271280

272281
describe("dispatchEvent delivery failures", () => {
282+
it("does not start generic recovery for a settled hosted SMS", async () => {
283+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-hosted-delivery-"));
284+
process.env.INKBOX_OPENCODE_HOME = dir;
285+
try {
286+
saveHostedCall({
287+
identityId: "ident-1",
288+
callId: "call-1",
289+
eventId: "event-1",
290+
state: "running",
291+
event: {
292+
id: "event-1",
293+
event_type: "call.ended",
294+
timestamp: "2026-08-01T00:00:00Z",
295+
data: { call: { id: "call-1", mode: "hosted_agent" } },
296+
} as never,
297+
});
298+
activateHostedSmsCapture({
299+
identityId: "ident-1",
300+
callId: "call-1",
301+
sessionID: "session-1",
302+
phase: "initial",
303+
expectedTarget: "+15551112222",
304+
});
305+
const guard = beginHostedSmsAttempt({
306+
sessionID: "session-1",
307+
target: "+15551112222",
308+
hasConversationId: false,
309+
});
310+
if (!guard) throw new Error("expected hosted SMS guard");
311+
settleHostedSmsAttempt(guard, "success", undefined, "hosted-message-1");
312+
saveHostedCall({
313+
identityId: "ident-1",
314+
callId: "call-1",
315+
eventId: "event-1",
316+
state: "completed",
317+
outcome: "success",
318+
retryable: false,
319+
event: {
320+
id: "event-1",
321+
event_type: "call.ended",
322+
timestamp: "2026-08-01T00:00:00Z",
323+
data: { call: { id: "call-1", mode: "hosted_agent" } },
324+
} as never,
325+
});
326+
327+
const deps = makeDeps();
328+
const ok = await dispatchEvent(
329+
deps,
330+
event("text.delivery_failed", {
331+
text_message: {
332+
id: "hosted-message-1",
333+
remote_phone_number: "+15551112222",
334+
error_detail: "handset unreachable",
335+
error_code: "undelivered",
336+
},
337+
}),
338+
);
339+
340+
expect(ok).toBe(true);
341+
expect(deps.sessions.runCapture).not.toHaveBeenCalled();
342+
expect(deps.contacts.resolve).not.toHaveBeenCalled();
343+
} finally {
344+
delete process.env.INKBOX_OPENCODE_HOME;
345+
fs.rmSync(dir, { recursive: true, force: true });
346+
}
347+
});
348+
273349
it("runs a capture on the first failure and dedupes a repeat with the same id", async () => {
274350
const deps = makeDeps();
275351
const failure = event("text.delivery_failed", {

tests/live/voice-proof.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@ export function hasSmsIntent(value: string): boolean {
2626
/\b(?:text|sms) (?:me|the caller|the user|them|him|her)\b/.test(normalized)
2727
);
2828
}
29+
30+
export function wasAcceptedForDelivery(message: {
31+
deliveryStatus?: unknown;
32+
delivery_status?: unknown;
33+
}): boolean {
34+
const status = String(message.deliveryStatus ?? message.delivery_status ?? "").toLowerCase();
35+
return status !== "blocked_spam_filter";
36+
}

tests/live/voice.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ import {
2424
waitDriverLocalSpeech,
2525
waitTwoWayCall,
2626
} from "./helpers.js";
27-
import { containsVoiceMarker, hasAfterCallSmsIntent, hasSmsIntent } from "./voice-proof.js";
27+
import {
28+
containsVoiceMarker,
29+
hasAfterCallSmsIntent,
30+
hasSmsIntent,
31+
wasAcceptedForDelivery,
32+
} from "./voice-proof.js";
2833

2934
const SCENARIO = process.env.VOICE_SCENARIO ?? "";
3035
const STATE_FILE = process.env.VOICE_DRIVER_STATE || "/tmp/voice_driver_state.json";
@@ -490,9 +495,10 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => {
490495
);
491496
},
492497
);
493-
matched = fresh.filter((message: any) =>
498+
const markerRows = fresh.filter((message: any) =>
494499
containsVoiceMarker(String(message.text ?? ""), HOSTED_MARKER),
495500
);
501+
matched = markerRows.filter(wasAcceptedForDelivery);
496502
try {
497503
const registry = JSON.parse(
498504
readFileSync(
@@ -504,13 +510,17 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => {
504510
} catch {
505511
registryEntry = undefined;
506512
}
507-
progress.last = `marker_rows=${matched.length} registry_state=${registryEntry?.state ?? "missing"}`;
513+
progress.last =
514+
`accepted_marker_rows=${matched.length} ` +
515+
`blocked_marker_rows=${markerRows.length - matched.length} ` +
516+
`registry_state=${registryEntry?.state ?? "missing"}`;
508517
if (matched.length === 1 && registryEntry?.state === "completed") {
509518
await new Promise((resolve) => setTimeout(resolve, duplicateGraceMs));
510519
const afterGrace = (await outboundTextsTo(aut, autPhone.id, st.number)).filter(
511520
(message: any) =>
512521
!beforeSmsIds.has(message.id) &&
513522
(recordCreatedAt(message) ?? -1) >= scenarioStartedAt &&
523+
wasAcceptedForDelivery(message) &&
514524
containsVoiceMarker(String(message.text ?? ""), HOSTED_MARKER),
515525
);
516526
expect(afterGrace.length).toBe(1);

tests/unit/hosted-send-sms.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import {
77
activateHostedSmsCapture,
88
getHostedCall,
9+
isSuccessfulHostedSmsMessage,
910
saveHostedCall,
1011
} from "../../src/gateway/hosted-call-registry.js";
1112
import { sendSmsTools } from "../../src/tools/send-sms.js";
@@ -88,7 +89,12 @@ describe("hosted send SMS boundary", () => {
8889
} as any;
8990
await tool.definition.execute({ to: "+14155550123", text: "bravo maple" }, ctx);
9091
expect(sendText).toHaveBeenCalledOnce();
91-
expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0].state).toBe("success");
92+
expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0]).toMatchObject({
93+
state: "success",
94+
providerMessageId: "sms-1",
95+
});
96+
expect(isSuccessfulHostedSmsMessage("sms-1")).toBe(true);
97+
expect(isSuccessfulHostedSmsMessage("sms-other")).toBe(false);
9298
});
9399

94100
it("does not rewrite a provider-accepted SMS as failed when success journaling fails", async () => {

tests/unit/voice-proof.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
hasAfterCallSmsIntent,
55
hasSmsIntent,
66
normalizedVoiceTokens,
7+
wasAcceptedForDelivery,
78
} from "../live/voice-proof.js";
89

910
describe("hosted live voice proof normalization", () => {
@@ -28,4 +29,14 @@ describe("hosted live voice proof normalization", () => {
2829
expect(hasSmsIntent("Send a text message containing the marker after the call.")).toBe(true);
2930
expect(hasSmsIntent("Review the text-message history.")).toBe(false);
3031
});
32+
33+
it("does not count a pre-delivery policy block as an accepted SMS", () => {
34+
expect(wasAcceptedForDelivery({ deliveryStatus: "blocked_spam_filter" })).toBe(false);
35+
expect(wasAcceptedForDelivery({ delivery_status: "blocked_spam_filter" })).toBe(false);
36+
expect(wasAcceptedForDelivery({ deliveryStatus: "queued" })).toBe(true);
37+
expect(wasAcceptedForDelivery({ deliveryStatus: "delivered" })).toBe(true);
38+
expect(wasAcceptedForDelivery({ deliveryStatus: "delivery_failed" })).toBe(true);
39+
expect(wasAcceptedForDelivery({ deliveryStatus: "sending_failed" })).toBe(true);
40+
expect(wasAcceptedForDelivery({})).toBe(true);
41+
});
3142
});

0 commit comments

Comments
 (0)