@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.
package/src/session.ts ADDED
@@ -0,0 +1,333 @@
1
+ import type { ConnectorContext } from '@parel/plugin-sdk';
2
+ import { parallAgentId, parallApiUrl, parallOrgId, requireAgk } from './connect.js';
3
+
4
+ /**
5
+ * Best-effort progress reporting: dispatch received, AgentSession lifecycle,
6
+ * and input steps — the signals parall's message-level activity indicator and
7
+ * session panel are built on (see parall-runtime-indicator-plan.md §3).
8
+ *
9
+ * Every function catches its own failures and returns a degraded value: the
10
+ * message pipeline (emit / reply) must never block on indicator reporting.
11
+ *
12
+ * Session identity is deterministic — (runtime_type='parel',
13
+ * runtime_session_id=subject), where the subject is the envelope routing key:
14
+ * a chat id (cht_) for Parall chats, or a channel conversation id (chv_) for
15
+ * external IM conversations. per_subject routing already keys one parel
16
+ * session per subject, so the parall-side session mirrors it 1:1 without the
17
+ * connector ever knowing parel's internal session id.
18
+ */
19
+
20
+ export const REQUEST_TIMEOUT_MS = 10_000;
21
+
22
+ /** Reporting needs the agent id in the endpoint path; old configs lack it. */
23
+ export function reportingEnabled(ctx: ConnectorContext): boolean {
24
+ return parallAgentId(ctx) !== '';
25
+ }
26
+
27
+ /**
28
+ * The subject's most recently emitted envelope id — the staleness guard for
29
+ * turn-level done signals: a turn_completed/failed whose envelopeIds don't
30
+ * include the current value belongs to a superseded turn and must not idle
31
+ * the session out from under the newer one.
32
+ */
33
+ export const turnEnvelopeKey = (subject: string) => `turnEnvelope:${subject}`;
34
+ const sessionKey = (subject: string) => `session:${subject}`;
35
+
36
+ /**
37
+ * 'stale' means the session id no longer accepts writes (closed/superseded —
38
+ * e.g. the New Session control ended it): callers invalidate the cache and
39
+ * may retry once against a fresh get-or-create, which falls through terminal
40
+ * rows and creates a new session server-side.
41
+ */
42
+ export type ReportResult = 'ok' | 'stale' | 'failed';
43
+
44
+ function resultForStatus(status: number): ReportResult {
45
+ return status === 404 || status === 409 || status === 410 ? 'stale' : 'failed';
46
+ }
47
+
48
+ /** Drop the cached session id so the next ensureSession re-resolves it. */
49
+ export async function invalidateSession(ctx: ConnectorContext, subject: string): Promise<void> {
50
+ await ctx.store?.delete(sessionKey(subject)).catch(() => {});
51
+ }
52
+
53
+ function jsonHeaders(ctx: ConnectorContext): Record<string, string> {
54
+ return {
55
+ Authorization: `Bearer ${requireAgk(ctx)}`,
56
+ 'Content-Type': 'application/json',
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Get-or-create the subject's parall AgentSession (ase_), cached in the
62
+ * durable per-connection store. Returns null when reporting is disabled or
63
+ * the call fails — callers skip their report and move on. Callers on a
64
+ * latency-sensitive path (deliver blocks the user-visible reply on this) pass
65
+ * a shorter timeoutMs; the cache-hit fast path is a store read either way.
66
+ */
67
+ export async function ensureSession(
68
+ ctx: ConnectorContext,
69
+ subject: string,
70
+ opts: { timeoutMs?: number } = {},
71
+ ): Promise<string | null> {
72
+ if (!reportingEnabled(ctx)) return null;
73
+ const cached = await ctx.store?.get(sessionKey(subject)).catch(() => null);
74
+ if (typeof cached === 'string' && cached) return cached;
75
+ try {
76
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions`;
77
+ const res = await fetch(url, {
78
+ method: 'POST',
79
+ headers: jsonHeaders(ctx),
80
+ body: JSON.stringify({ runtime_type: 'parel', runtime_session_id: subject }),
81
+ signal: AbortSignal.timeout(opts.timeoutMs ?? REQUEST_TIMEOUT_MS),
82
+ });
83
+ if (!res.ok) {
84
+ console.error(`[parel-channel] session get-or-create failed (${res.status})`);
85
+ return null;
86
+ }
87
+ const body = (await res.json().catch(() => null)) as { id?: string } | null;
88
+ const id = body?.id;
89
+ if (!id) return null;
90
+ await ctx.store?.set(sessionKey(subject), id).catch(() => {});
91
+ return id;
92
+ } catch (err) {
93
+ console.error('[parel-channel] session get-or-create failed', err);
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** PATCH the session (status / trigger_message_id). Never throws. */
99
+ export async function patchSession(
100
+ ctx: ConnectorContext,
101
+ sessionId: string,
102
+ body: { status: 'active' | 'idle'; trigger_message_id?: string },
103
+ ): Promise<ReportResult> {
104
+ try {
105
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}`;
106
+ const res = await fetch(url, {
107
+ method: 'PATCH',
108
+ headers: jsonHeaders(ctx),
109
+ body: JSON.stringify(body),
110
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
111
+ });
112
+ if (res.ok) return 'ok';
113
+ console.error(`[parel-channel] session patch failed (${res.status})`);
114
+ return resultForStatus(res.status);
115
+ } catch (err) {
116
+ console.error('[parel-channel] session patch failed', err);
117
+ return 'failed';
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The input step is what flips the frontend indicator from pending to
123
+ * processing — target_type/target_id route the agent_step.new broadcast to
124
+ * messages:{chatId}, and content.trigger_ref.message_id is the exact match
125
+ * key (both mandatory; the server only publishes when a target is set).
126
+ */
127
+ export async function createInputStep(
128
+ ctx: ConnectorContext,
129
+ sessionId: string,
130
+ args: { chatId: string; messageId: string; senderId?: string; text: string },
131
+ ): Promise<ReportResult> {
132
+ try {
133
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
134
+ const res = await fetch(url, {
135
+ method: 'POST',
136
+ headers: jsonHeaders(ctx),
137
+ body: JSON.stringify({
138
+ step_type: 'input',
139
+ target_type: 'chat',
140
+ target_id: args.chatId,
141
+ // Session-scoped dedup: an ack failure leaves the dispatch replayable
142
+ // by the reconnect catch-up sweep — without this key every replay
143
+ // would insert and broadcast another input step for the same message.
144
+ idempotency_key: `input:${args.messageId}`,
145
+ content: {
146
+ trigger_type: 'mention',
147
+ trigger_ref: { message_id: args.messageId },
148
+ ...(args.senderId ? { sender_id: args.senderId } : {}),
149
+ summary: args.text.slice(0, 200),
150
+ },
151
+ }),
152
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
153
+ });
154
+ if (res.ok) return 'ok';
155
+ console.error(`[parel-channel] input step failed (${res.status})`);
156
+ return resultForStatus(res.status);
157
+ } catch (err) {
158
+ console.error('[parel-channel] input step failed', err);
159
+ return 'failed';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Input step for a channel_message dispatch (external IM). Shaped exactly
165
+ * like agent-core's channel input step so the session panel renders both
166
+ * runtimes identically: target = the ChannelConversation (chv_), trigger_ref
167
+ * carries the channel identifiers, and the sender is the external user
168
+ * (a display name, not a Parall usr_).
169
+ */
170
+ export async function createChannelInputStep(
171
+ ctx: ConnectorContext,
172
+ sessionId: string,
173
+ args: {
174
+ conversationId: string;
175
+ channelMessageId: string;
176
+ provider?: string;
177
+ externalConversationId?: string;
178
+ senderId: string;
179
+ senderName: string;
180
+ text: string;
181
+ sentAt?: string;
182
+ },
183
+ ): Promise<ReportResult> {
184
+ try {
185
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
186
+ const res = await fetch(url, {
187
+ method: 'POST',
188
+ headers: jsonHeaders(ctx),
189
+ body: JSON.stringify({
190
+ step_type: 'input',
191
+ target_type: 'channel_conversation',
192
+ target_id: args.conversationId,
193
+ // Session-scoped dedup, same rationale as the chat input step: a
194
+ // failed ack leaves the dispatch replayable by the catch-up sweep.
195
+ idempotency_key: `input:${args.channelMessageId}`,
196
+ content: {
197
+ trigger_type: 'channel_message',
198
+ trigger_ref: {
199
+ conversation_id: args.conversationId,
200
+ channel_message_id: args.channelMessageId,
201
+ ...(args.provider ? { provider: args.provider } : {}),
202
+ ...(args.externalConversationId
203
+ ? { external_conversation_id: args.externalConversationId }
204
+ : {}),
205
+ },
206
+ sender_id: args.senderId,
207
+ sender_name: args.senderName,
208
+ summary: args.text.slice(0, 200),
209
+ ...(args.sentAt ? { sent_at: args.sentAt } : {}),
210
+ },
211
+ }),
212
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
213
+ });
214
+ if (res.ok) return 'ok';
215
+ console.error(`[parel-channel] channel input step failed (${res.status})`);
216
+ return resultForStatus(res.status);
217
+ } catch (err) {
218
+ console.error('[parel-channel] channel input step failed', err);
219
+ return 'failed';
220
+ }
221
+ }
222
+
223
+ // Subject-scoped: one connector serves many per-subject sessions, and
224
+ // provider call ids are only unique within a turn — never let two subjects'
225
+ // concurrent calls overwrite each other's parked name.
226
+ const toolNameKey = (subject: string, callId: string) => `toolName:${subject}:${callId}`;
227
+
228
+ /**
229
+ * E2 step-trace events → parall AgentSteps, shaped exactly like agent-core's
230
+ * createRuntimeStep so the session panel renders them identically to CC's.
231
+ * tool_call/tool_result carry a session-scoped idempotency key (events are
232
+ * at-most-once today, but a callId key is free insurance); reasoning has no
233
+ * natural key and parel already aggregates it host-side. The E2 tool_result
234
+ * event has no tool name, so tool_call parks it in the store by callId.
235
+ */
236
+ export async function createTraceStep(
237
+ ctx: ConnectorContext,
238
+ sessionId: string,
239
+ subject: string,
240
+ event:
241
+ | { type: 'model_reasoning'; text: string }
242
+ | { type: 'tool_call'; callId: string; name: string; input: unknown }
243
+ | {
244
+ type: 'tool_result';
245
+ callId: string;
246
+ status: 'ok' | 'error';
247
+ durationMs?: number;
248
+ outputPreview?: string;
249
+ },
250
+ ): Promise<ReportResult> {
251
+ let body: Record<string, unknown>;
252
+ if (event.type === 'model_reasoning') {
253
+ body = { step_type: 'thinking', content: { text: event.text } };
254
+ } else if (event.type === 'tool_call') {
255
+ await ctx.store?.set(toolNameKey(subject, event.callId), event.name).catch(() => {});
256
+ body = {
257
+ step_type: 'tool_call',
258
+ runtime_key: event.callId,
259
+ idempotency_key: `tc:${event.callId}`,
260
+ content: {
261
+ call_id: event.callId,
262
+ tool_name: event.name,
263
+ tool_input: event.input,
264
+ status: 'running',
265
+ started_at: new Date(ctx.now()).toISOString(),
266
+ },
267
+ };
268
+ } else {
269
+ const parked = await ctx.store?.get(toolNameKey(subject, event.callId)).catch(() => null);
270
+ await ctx.store?.delete(toolNameKey(subject, event.callId)).catch(() => {});
271
+ body = {
272
+ step_type: 'tool_result',
273
+ idempotency_key: `tr:${event.callId}`,
274
+ content: {
275
+ call_id: event.callId,
276
+ // The server REQUIRES tool_name on tool_result — a missed tool_call
277
+ // event (best-effort feed) must degrade to a labeled unknown, not a
278
+ // 400-dropped step.
279
+ tool_name: typeof parked === 'string' && parked ? parked : 'unknown',
280
+ status: event.status === 'error' ? 'error' : 'success',
281
+ output: event.outputPreview ?? '',
282
+ duration_ms: event.durationMs ?? 0,
283
+ collapsible: true,
284
+ },
285
+ };
286
+ }
287
+ try {
288
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
289
+ // The subject prefix tells chat turns from external IM turns — channel
290
+ // steps must not target 'chat' or the server would broadcast them onto
291
+ // a messages:{chv_} channel no client subscribes to (agent-core's
292
+ // resolveStepTarget draws the same line).
293
+ const targetType = subject.startsWith('chv_') ? 'channel_conversation' : 'chat';
294
+ const res = await fetch(url, {
295
+ method: 'POST',
296
+ headers: jsonHeaders(ctx),
297
+ body: JSON.stringify({ ...body, target_type: targetType, target_id: subject }),
298
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
299
+ });
300
+ if (res.ok) return 'ok';
301
+ console.error(`[parel-channel] trace step failed (${res.status})`);
302
+ return resultForStatus(res.status);
303
+ } catch (err) {
304
+ console.error('[parel-channel] trace step failed', err);
305
+ return 'failed';
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
311
+ * before the ack effect is returned: MarkReceived only transitions rows still
312
+ * in status='pending', and ack moves the same row to 'acked' — losing the race
313
+ * means the ring never appears.
314
+ */
315
+ export async function markDispatchReceived(
316
+ ctx: ConnectorContext,
317
+ sourceType: string,
318
+ sourceId: string,
319
+ ): Promise<void> {
320
+ if (!reportingEnabled(ctx)) return;
321
+ try {
322
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
323
+ const res = await fetch(url, {
324
+ method: 'POST',
325
+ headers: jsonHeaders(ctx),
326
+ body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
327
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
328
+ });
329
+ if (!res.ok) console.error(`[parel-channel] mark received failed (${res.status})`);
330
+ } catch (err) {
331
+ console.error('[parel-channel] mark received failed', err);
332
+ }
333
+ }