@parall/parel-channel 1.37.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,144 @@
1
+ import type { ChannelDelivery, ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
2
+ import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
3
+ import {
4
+ ensureSession,
5
+ invalidateSession,
6
+ patchSession,
7
+ REQUEST_TIMEOUT_MS,
8
+ turnEnvelopeKey,
9
+ } from './session.js';
10
+
11
+ /**
12
+ * Post the agent's reply back to the Parall chat. The chat id rides the
13
+ * replyRoute.data set in inbound.ts.
14
+ *
15
+ * Contract (confirmed against parel #104): provider_http reply routes are
16
+ * auto-delivered at turn end via this hook; `payload` is the plain reply text
17
+ * string and `replyRoute` is the one captured from the originating envelope.
18
+ *
19
+ * The reply POST is awaited (not returned as a host fetch effect) so the
20
+ * session link resolution and failure handling stay in one place; a
21
+ * retryable failure falls back to the host effect (at-least-once, idempotent
22
+ * by delivery id + message idempotency_key). The turn's PRIMARY done signal
23
+ * lives in onAgentEvent (turn_completed); the idle here is a deliberate
24
+ * redundant close for bindings without `observe: [turn]` — see the inline
25
+ * comment at the bottom.
26
+ *
27
+ * External IM turns (channel_message dispatches) route here too but deliver
28
+ * NOTHING: their replyRoute carries a conversationId and no chatId, and the
29
+ * agent replies through the provider clip itself — this hook only runs the
30
+ * same redundant session idle for them.
31
+ */
32
+ export async function buildParallDelivery(
33
+ delivery: ChannelDelivery,
34
+ ctx: ConnectorContext,
35
+ ): Promise<ConnectorEffect[]> {
36
+ const apiUrl = parallApiUrl(ctx);
37
+ const agk = requireAgk(ctx);
38
+ const orgId = parallOrgId(ctx);
39
+ const route = (delivery.replyRoute?.data ?? {}) as {
40
+ chatId?: string;
41
+ messageId?: string;
42
+ threadRootId?: string;
43
+ envelopeId?: string;
44
+ conversationId?: string;
45
+ };
46
+ const chatId = route.chatId;
47
+ if (!chatId) {
48
+ // External IM turn (channel_message): the reply already went out through
49
+ // the provider clip, agent-invoked — the turn-end text is deliberately
50
+ // NOT delivered anywhere (same contract as agent-core: plain output is
51
+ // not the reply). Only run the redundant idle so a binding without
52
+ // `observe: [turn]` still closes the session.
53
+ if (route.conversationId) {
54
+ const sessionId = await ensureSession(ctx, route.conversationId, { timeoutMs: 3_000 });
55
+ if (sessionId) {
56
+ await idleUnlessSuperseded(ctx, route.conversationId, route.envelopeId, sessionId);
57
+ }
58
+ }
59
+ return [];
60
+ }
61
+
62
+ const payload = delivery.payload as { text?: string } | string | undefined;
63
+ const text = typeof payload === 'string' ? payload : (payload?.text ?? '');
64
+ if (!text) return [];
65
+
66
+ // Short timeout: this sits in front of the user-visible reply POST. Cache
67
+ // hit (every message after a chat's first) is a store read; on a slow miss
68
+ // we degrade to a reply without the session link rather than delay it.
69
+ const sessionId = await ensureSession(ctx, chatId, { timeoutMs: 3_000 });
70
+
71
+ const request = {
72
+ url: `${apiUrl}/api/v1/orgs/${orgId}/chats/${chatId}/messages`,
73
+ method: 'POST',
74
+ headers: {
75
+ Authorization: `Bearer ${agk}`,
76
+ 'Content-Type': 'application/json',
77
+ },
78
+ // message_type must be explicit: the server persists it verbatim, and
79
+ // a blank type skips text normalization (prll:// refs, mentions) and
80
+ // renders as an unknown-type fallback on clients. agent_session_id links
81
+ // the reply bubble to its session panel (server validates ownership).
82
+ // idempotency_key dedupes the ambiguous-failure case where the direct
83
+ // POST committed but its response was lost and the host retry re-sends —
84
+ // same body on both paths, so the server collapses them to one message.
85
+ body: JSON.stringify({
86
+ message_type: 'text',
87
+ content: { text },
88
+ idempotency_key: `parel-reply:${delivery.id}`,
89
+ // A reply to a thread message stays in that thread.
90
+ ...(route.threadRootId ? { thread_root_id: route.threadRootId } : {}),
91
+ ...(sessionId ? { agent_session_id: sessionId } : {}),
92
+ }),
93
+ };
94
+
95
+ try {
96
+ const res = await fetch(request.url, {
97
+ method: request.method,
98
+ headers: request.headers,
99
+ body: request.body,
100
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
101
+ });
102
+ if (!res.ok) {
103
+ // 4xx (bar 429) is permanent — retrying an unprocessable reply burns
104
+ // requests forever. Drop it (logged); turn_completed still closes the
105
+ // turn's indicator.
106
+ const permanent = res.status >= 400 && res.status < 500 && res.status !== 429;
107
+ if (!permanent) throw new Error(`reply post failed (${res.status})`);
108
+ console.error(`[parel-channel] reply rejected (${res.status}) — dropping`);
109
+ }
110
+ } catch (err) {
111
+ console.error('[parel-channel] direct reply failed; falling back to host retry', err);
112
+ return [{ type: 'fetch', request, idempotencyKey: `reply:${delivery.id}` }];
113
+ }
114
+
115
+ // Redundant done signal, kept deliberately: turn_completed (onAgentEvent)
116
+ // is the primary close for every turn — including no-reply turns this hook
117
+ // never sees — but a binding deployed without `observe: [turn]` that still
118
+ // resolves this connector (npm ^1 fallback path; the artifact path freezes
119
+ // connector+config atomically) would otherwise never idle. Both signals
120
+ // run the same turnEnvelope staleness guard and idle is idempotent, so the
121
+ // overlap is harmless.
122
+ if (sessionId) {
123
+ await idleUnlessSuperseded(ctx, chatId, route.envelopeId, sessionId);
124
+ }
125
+ return [];
126
+ }
127
+
128
+ /**
129
+ * Idle the subject's session unless a newer envelope owns the turn (the same
130
+ * turnEnvelope staleness guard onAgentEvent runs). Fail-open when the marker
131
+ * is unavailable — a stuck ring is worse than a blink.
132
+ */
133
+ async function idleUnlessSuperseded(
134
+ ctx: ConnectorContext,
135
+ subject: string,
136
+ envelopeId: string | undefined,
137
+ sessionId: string,
138
+ ): Promise<void> {
139
+ const current = await ctx.store?.get(turnEnvelopeKey(subject)).catch(() => null);
140
+ if (!(typeof current === 'string' && current) || !envelopeId || current === envelopeId) {
141
+ const result = await patchSession(ctx, sessionId, { status: 'idle' });
142
+ if (result === 'stale') await invalidateSession(ctx, subject);
143
+ }
144
+ }