@telnyx/agent-harness 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Generic discrete-message channel contract.
3
+ *
4
+ * The harness never accepts provider payloads or provider-specific addressing
5
+ * fields. A reference adapter owns those transformations before this boundary.
6
+ */
7
+ export type ChannelContent = Readonly<Record<string, unknown>>;
8
+ export interface NormalizedChannelInbound {
9
+ readonly channel: string;
10
+ readonly externalMessageId: string;
11
+ readonly conversationId: string;
12
+ readonly receivedAt: number;
13
+ readonly content: ChannelContent;
14
+ }
15
+ export interface ChannelAcceptance {
16
+ readonly channel: string;
17
+ readonly conversationId: string;
18
+ readonly externalMessageId: string;
19
+ readonly receivedAt: number;
20
+ readonly content: ChannelContent;
21
+ }
22
+ export interface ChannelSendRequest {
23
+ readonly conversationId: string;
24
+ readonly content: ChannelContent;
25
+ /** Stable retry token supplied for replies that require delivery correlation. */
26
+ readonly idempotencyKey?: string;
27
+ }
28
+ export interface ChannelAdapter {
29
+ readonly channel: string;
30
+ send(request: ChannelSendRequest): Promise<{
31
+ providerRequestId: string;
32
+ }>;
33
+ }
34
+ export interface ChannelLedger {
35
+ /**
36
+ * Atomically claims an inbound provider message and persists its run mapping
37
+ * as one recovery unit. Durable implementations must share this claim across
38
+ * router processes and must not release a claim after `accept` succeeds.
39
+ */
40
+ acceptInbound(channel: string, externalMessageId: string, conversationId: string, accept: () => Promise<string>): Promise<Readonly<{
41
+ runId: string;
42
+ duplicate: boolean;
43
+ }>>;
44
+ findRun(runId: string): Promise<Readonly<{
45
+ channel: string;
46
+ conversationId: string;
47
+ }> | undefined>;
48
+ /**
49
+ * Reserves and completes one correlated outbound attempt. Implementations
50
+ * must retain the stable idempotency key through retries/recovery so a
51
+ * provider never observes an unkeyed resend after persistence uncertainty.
52
+ */
53
+ sendOutbound(channel: string, runId: string, idempotencyKey: string, send: (idempotencyKey: string) => Promise<{
54
+ providerRequestId: string;
55
+ }>): Promise<{
56
+ providerRequestId: string;
57
+ }>;
58
+ /** Reconciles a previous provider success without authorizing or sending again. */
59
+ recoverOutbound(channel: string, runId: string, idempotencyKey: string): Promise<{
60
+ providerRequestId: string;
61
+ } | undefined>;
62
+ findOutbound(channel: string, providerRequestId: string): Promise<string | undefined>;
63
+ }
64
+ export interface OutboundChannelPolicy {
65
+ authorize(request: Readonly<{
66
+ channel: string;
67
+ serviceAccountId: string | undefined;
68
+ }>): Promise<boolean>;
69
+ }
70
+ export declare class ChannelAuthorizationError extends Error {
71
+ constructor();
72
+ }
73
+ export interface ChannelRouterOptions {
74
+ readonly adapters: readonly ChannelAdapter[];
75
+ readonly ledger: ChannelLedger;
76
+ readonly policy: OutboundChannelPolicy;
77
+ readonly accept: (request: ChannelAcceptance) => Promise<{
78
+ runId: string;
79
+ }>;
80
+ }
81
+ export interface ChannelReplyRequest {
82
+ readonly runId: string;
83
+ readonly serviceAccountId?: string;
84
+ /** Caller-supplied stable key for this retryable reply. */
85
+ readonly idempotencyKey: string;
86
+ readonly content: ChannelContent;
87
+ }
88
+ export interface ChannelOutboundRequest {
89
+ readonly channel: string;
90
+ readonly conversationId: string;
91
+ readonly serviceAccountId?: string;
92
+ /** Exact harness run to correlate this out-of-band send with delivery events. */
93
+ readonly runId: string;
94
+ /** Caller-supplied stable key for this retryable out-of-band send. */
95
+ readonly idempotencyKey: string;
96
+ readonly content: ChannelContent;
97
+ }
98
+ export interface ChannelDeliveryEvent {
99
+ readonly channel: string;
100
+ readonly providerRequestId: string;
101
+ readonly status: string;
102
+ readonly occurredAt: number;
103
+ }
104
+ export declare function createChannelRouter(options: ChannelRouterOptions): Readonly<{
105
+ receive(inbound: NormalizedChannelInbound): Promise<{
106
+ runId: string;
107
+ duplicate: boolean;
108
+ }>;
109
+ reply(request: ChannelReplyRequest): Promise<{
110
+ providerRequestId: string;
111
+ }>;
112
+ send(request: ChannelOutboundRequest): Promise<{
113
+ providerRequestId: string;
114
+ }>;
115
+ delivery(event: ChannelDeliveryEvent): Promise<{
116
+ runId: string;
117
+ } | undefined>;
118
+ }>;
119
+ export interface InMemoryChannelLedgerOptions {
120
+ /** Test-only fault injection for recovery-path verification. */
121
+ readonly failAfterInboundAcceptOnce?: boolean;
122
+ /** Test-only fault injection for recovery-path verification. */
123
+ readonly failAfterOutboundSendOnce?: boolean;
124
+ }
125
+ /** Deterministic ledger test double; production wiring must provide durable storage. */
126
+ export declare class InMemoryChannelLedger implements ChannelLedger {
127
+ private readonly inbound;
128
+ private readonly inboundClaims;
129
+ private readonly pendingInbound;
130
+ private readonly runs;
131
+ private readonly outbound;
132
+ private readonly outboundAttempts;
133
+ private readonly pendingOutbound;
134
+ private failAfterInboundAcceptOnce;
135
+ private failAfterOutboundSendOnce;
136
+ constructor(options?: InMemoryChannelLedgerOptions);
137
+ acceptInbound(channel: string, externalMessageId: string, conversationId: string, accept: () => Promise<string>): Promise<Readonly<{
138
+ runId: string;
139
+ duplicate: boolean;
140
+ }>>;
141
+ findRun(runId: string): Promise<Readonly<{
142
+ channel: string;
143
+ conversationId: string;
144
+ }> | undefined>;
145
+ sendOutbound(channel: string, runId: string, idempotencyKey: string, send: (idempotencyKey: string) => Promise<{
146
+ providerRequestId: string;
147
+ }>): Promise<{
148
+ providerRequestId: string;
149
+ }>;
150
+ recoverOutbound(channel: string, runId: string, idempotencyKey: string): Promise<{
151
+ providerRequestId: string;
152
+ } | undefined>;
153
+ findOutbound(channel: string, providerRequestId: string): Promise<string | undefined>;
154
+ }
155
+ export interface SmsReferenceAdapterOptions {
156
+ readonly send: (request: ChannelSendRequest) => Promise<{
157
+ providerRequestId: string;
158
+ }>;
159
+ }
160
+ /**
161
+ * Hermetic reference adapter for SMS-shaped webhooks. It does not call a live
162
+ * provider; transport wiring is deliberately injected by the caller.
163
+ */
164
+ export declare class SmsReferenceAdapter implements ChannelAdapter {
165
+ private readonly options;
166
+ readonly channel = "sms";
167
+ constructor(options: SmsReferenceAdapterOptions);
168
+ normalizeWebhook(webhook: Readonly<{
169
+ messageId: string;
170
+ conversationId: string;
171
+ text: string;
172
+ receivedAt: number;
173
+ }>): NormalizedChannelInbound;
174
+ send(request: ChannelSendRequest): Promise<{
175
+ providerRequestId: string;
176
+ }>;
177
+ }
178
+ //# sourceMappingURL=channel.d.ts.map
@@ -0,0 +1,184 @@
1
+ export class ChannelAuthorizationError extends Error {
2
+ constructor() {
3
+ super("outbound channel send requires an authorized Service Account");
4
+ this.name = "ChannelAuthorizationError";
5
+ }
6
+ }
7
+ function adapterByChannel(adapters, channel) {
8
+ const adapter = adapters.get(channel);
9
+ if (!adapter)
10
+ throw new Error(`unsupported channel: ${channel}`);
11
+ return adapter;
12
+ }
13
+ function ledgerKey(...values) {
14
+ return JSON.stringify(values);
15
+ }
16
+ function requireIdempotencyKey(idempotencyKey) {
17
+ if (!idempotencyKey.trim())
18
+ throw new Error("idempotency key must not be blank");
19
+ }
20
+ export function createChannelRouter(options) {
21
+ const adapters = new Map(options.adapters.map((adapter) => [adapter.channel, adapter]));
22
+ const send = async (request, runId, idempotencyKey) => {
23
+ requireIdempotencyKey(idempotencyKey);
24
+ const recovered = await options.ledger.recoverOutbound(request.channel, runId, idempotencyKey);
25
+ if (recovered)
26
+ return recovered;
27
+ if (!request.serviceAccountId?.trim()) {
28
+ throw new ChannelAuthorizationError();
29
+ }
30
+ if (!(await options.policy.authorize({
31
+ channel: request.channel,
32
+ serviceAccountId: request.serviceAccountId,
33
+ }))) {
34
+ throw new ChannelAuthorizationError();
35
+ }
36
+ const adapter = adapterByChannel(adapters, request.channel);
37
+ return options.ledger.sendOutbound(request.channel, runId, idempotencyKey, (stableKey) => adapter.send({
38
+ conversationId: request.conversationId,
39
+ content: request.content,
40
+ idempotencyKey: stableKey,
41
+ }));
42
+ };
43
+ return Object.freeze({
44
+ receive: async (inbound) => {
45
+ adapterByChannel(adapters, inbound.channel);
46
+ return options.ledger.acceptInbound(inbound.channel, inbound.externalMessageId, inbound.conversationId, async () => {
47
+ const result = await options.accept({
48
+ channel: inbound.channel,
49
+ conversationId: inbound.conversationId,
50
+ externalMessageId: inbound.externalMessageId,
51
+ receivedAt: inbound.receivedAt,
52
+ content: inbound.content,
53
+ });
54
+ return result.runId;
55
+ });
56
+ },
57
+ reply: async (request) => {
58
+ const run = await options.ledger.findRun(request.runId);
59
+ if (!run)
60
+ throw new Error(`unknown channel run: ${request.runId}`);
61
+ return send({ ...run, serviceAccountId: request.serviceAccountId, runId: request.runId, idempotencyKey: request.idempotencyKey, content: request.content }, request.runId, request.idempotencyKey);
62
+ },
63
+ send: async (request) => send(request, request.runId, request.idempotencyKey),
64
+ delivery: async (event) => {
65
+ adapterByChannel(adapters, event.channel);
66
+ const runId = await options.ledger.findOutbound(event.channel, event.providerRequestId);
67
+ return runId === undefined ? undefined : { runId };
68
+ },
69
+ });
70
+ }
71
+ /** Deterministic ledger test double; production wiring must provide durable storage. */
72
+ export class InMemoryChannelLedger {
73
+ inbound = new Map();
74
+ inboundClaims = new Map();
75
+ pendingInbound = new Map();
76
+ runs = new Map();
77
+ outbound = new Map();
78
+ outboundAttempts = new Map();
79
+ pendingOutbound = new Map();
80
+ failAfterInboundAcceptOnce;
81
+ failAfterOutboundSendOnce;
82
+ constructor(options = {}) {
83
+ this.failAfterInboundAcceptOnce = options.failAfterInboundAcceptOnce ?? false;
84
+ this.failAfterOutboundSendOnce = options.failAfterOutboundSendOnce ?? false;
85
+ }
86
+ async acceptInbound(channel, externalMessageId, conversationId, accept) {
87
+ const key = ledgerKey(channel, conversationId, externalMessageId);
88
+ const existing = this.inbound.get(key);
89
+ if (existing)
90
+ return { runId: existing, duplicate: true };
91
+ const pending = this.pendingInbound.get(key);
92
+ if (pending) {
93
+ this.pendingInbound.delete(key);
94
+ this.inbound.set(key, pending.runId);
95
+ this.runs.set(pending.runId, { channel: pending.channel, conversationId: pending.conversationId });
96
+ return { runId: pending.runId, duplicate: true };
97
+ }
98
+ const inFlight = this.inboundClaims.get(key);
99
+ if (inFlight)
100
+ return { runId: await inFlight, duplicate: true };
101
+ const claimed = (async () => {
102
+ const runId = await accept();
103
+ if (this.failAfterInboundAcceptOnce) {
104
+ this.failAfterInboundAcceptOnce = false;
105
+ this.pendingInbound.set(key, { runId, channel, conversationId });
106
+ throw new Error("inbound run mapping failed");
107
+ }
108
+ this.inbound.set(key, runId);
109
+ this.runs.set(runId, { channel, conversationId });
110
+ return runId;
111
+ })();
112
+ this.inboundClaims.set(key, claimed);
113
+ try {
114
+ return { runId: await claimed, duplicate: false };
115
+ }
116
+ finally {
117
+ this.inboundClaims.delete(key);
118
+ }
119
+ }
120
+ async findRun(runId) {
121
+ return this.runs.get(runId);
122
+ }
123
+ async sendOutbound(channel, runId, idempotencyKey, send) {
124
+ const attemptKey = ledgerKey(channel, runId, idempotencyKey);
125
+ const existing = this.outboundAttempts.get(attemptKey);
126
+ if (existing)
127
+ return existing;
128
+ const attempt = (async () => {
129
+ const result = await send(attemptKey);
130
+ if (this.failAfterOutboundSendOnce) {
131
+ this.failAfterOutboundSendOnce = false;
132
+ this.pendingOutbound.set(attemptKey, { channel, runId, result });
133
+ throw new Error("outbound persistence failed");
134
+ }
135
+ this.outbound.set(ledgerKey(channel, result.providerRequestId), runId);
136
+ return result;
137
+ })();
138
+ this.outboundAttempts.set(attemptKey, attempt);
139
+ try {
140
+ return await attempt;
141
+ }
142
+ catch (error) {
143
+ this.outboundAttempts.delete(attemptKey);
144
+ throw error;
145
+ }
146
+ }
147
+ async recoverOutbound(channel, runId, idempotencyKey) {
148
+ const attemptKey = ledgerKey(channel, runId, idempotencyKey);
149
+ const pending = this.pendingOutbound.get(attemptKey);
150
+ if (!pending)
151
+ return undefined;
152
+ this.pendingOutbound.delete(attemptKey);
153
+ this.outbound.set(ledgerKey(channel, pending.result.providerRequestId), runId);
154
+ this.outboundAttempts.set(attemptKey, Promise.resolve(pending.result));
155
+ return pending.result;
156
+ }
157
+ async findOutbound(channel, providerRequestId) {
158
+ return this.outbound.get(ledgerKey(channel, providerRequestId));
159
+ }
160
+ }
161
+ /**
162
+ * Hermetic reference adapter for SMS-shaped webhooks. It does not call a live
163
+ * provider; transport wiring is deliberately injected by the caller.
164
+ */
165
+ export class SmsReferenceAdapter {
166
+ options;
167
+ channel = "sms";
168
+ constructor(options) {
169
+ this.options = options;
170
+ }
171
+ normalizeWebhook(webhook) {
172
+ return {
173
+ channel: this.channel,
174
+ externalMessageId: webhook.messageId,
175
+ conversationId: webhook.conversationId,
176
+ receivedAt: webhook.receivedAt,
177
+ content: { text: webhook.text },
178
+ };
179
+ }
180
+ send(request) {
181
+ return this.options.send(request);
182
+ }
183
+ }
184
+ //# sourceMappingURL=channel.js.map
@@ -0,0 +1,50 @@
1
+ import { type AgentHarnessPorts } from "./ports.js";
2
+ export { AGENT_HARNESS_PORTS_VERSION } from "./ports.js";
3
+ /** A fresh adapter fixture consumed by the shared ports contract suite. */
4
+ export interface AgentHarnessPortsContractFixture {
5
+ /** Adapter under test. */
6
+ ports: AgentHarnessPorts<TestState>;
7
+ /** Test-host control for exercising current due-task delivery and retry. */
8
+ delivery: {
9
+ failNext(count?: number): void;
10
+ advanceBy(milliseconds: number): void;
11
+ drainDue(): Promise<number>;
12
+ /** Exercise a task handler that cancels its own recurring id, then throws. */
13
+ selfCancelAndThrow(): Promise<number>;
14
+ /** Exercise a task handler that re-arms its own id, then throws. */
15
+ selfReplaceAndThrow(): Promise<number>;
16
+ calls(): readonly string[];
17
+ };
18
+ /** Test-host control for exercising post-commit state-hook failures. */
19
+ stateHook: {
20
+ failNext(count?: number): void;
21
+ calls(): readonly Readonly<{
22
+ next: TestState;
23
+ prev: TestState;
24
+ }>[];
25
+ };
26
+ /** Reconstruct an adapter over the same durable backing. */
27
+ reopen(): Promise<AgentHarnessPorts<TestState>> | AgentHarnessPorts<TestState>;
28
+ /** Construct a distinct wrapper for the current live activation. */
29
+ rewrapLive(): AgentHarnessPorts<TestState>;
30
+ /** Release fixture-owned resources. */
31
+ close(): Promise<void> | void;
32
+ }
33
+ /** Factory used by {@link defineAgentHarnessPortsContract}. */
34
+ export type AgentHarnessPortsContractFactory = () => Promise<AgentHarnessPortsContractFixture> | AgentHarnessPortsContractFixture;
35
+ interface TestState extends Record<string, unknown> {
36
+ nested: {
37
+ keep?: boolean;
38
+ added?: string;
39
+ };
40
+ remove?: string;
41
+ replacement?: boolean;
42
+ }
43
+ /**
44
+ * Register the executable v2 ports contract against an adapter factory.
45
+ *
46
+ * Runtime and plain-Node adapters call this same function; adapter-specific
47
+ * tests may add stricter checks, but may not weaken these shared semantics.
48
+ */
49
+ export declare function defineAgentHarnessPortsContract(label: string, factory: AgentHarnessPortsContractFactory): void;
50
+ //# sourceMappingURL=contract.d.ts.map