@parall/parel-channel 1.38.0 → 1.40.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.
@@ -1,5 +1,15 @@
1
1
  /**
2
- * Prompt framing for external IM channel messages (channel_message
2
+ * Prompt framing for inbound messages.
3
+ *
4
+ * Chat messages (buildChatPrompt): under `main` channel routing every Parall
5
+ * chat shares the agent's ONE parel session, so consecutive turns can come
6
+ * from different conversations — each message is framed with its originating
7
+ * chat so the model registers the context switch instead of answering chat B
8
+ * with chat A's thread of thought. No reply hint: the turn-end reply is
9
+ * auto-delivered to the right chat by replyRoute (deliver()), and sandbox CLI
10
+ * calls get the right PRLL_CHAT_ID via per-turn invocation context.
11
+ *
12
+ * External IM channel messages (buildChannelPrompt, channel_message
3
13
  * dispatches). Mirrors agent-core's event-format.ts channel branch, adapted
4
14
  * to the parel runtime's tool surface:
5
15
  *
@@ -15,6 +25,23 @@
15
25
  * form ({"message_id": …}) so threaded conversations aren't nudged toward
16
26
  * new top-level messages.
17
27
  */
28
+ export interface ChatPromptArgs {
29
+ chatId: string;
30
+ /** Display name of the chat; undefined when unresolved (best-effort). */
31
+ chatName?: string;
32
+ /** Sender user id (usr_); display-name enrichment is a follow-up. */
33
+ senderId?: string;
34
+ threadRootId?: string;
35
+ /** The inbound message text (raw, un-framed). */
36
+ text: string;
37
+ }
38
+ /**
39
+ * Frame a Parall chat message with its conversation of origin. Kept to a
40
+ * minimal metadata header: the text itself is the user message, and heavier
41
+ * context (unread counts, membership) is the standard runtimes' enrichment —
42
+ * not re-created here.
43
+ */
44
+ export declare function buildChatPrompt(args: ChatPromptArgs): string;
18
45
  export interface ChannelPromptArgs {
19
46
  /** Provider name (= clip alias, e.g. "feishu"); undefined when unresolved. */
20
47
  provider?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AASH,MAAM,WAAW,iBAAiB;IAChC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkBlE"}
1
+ {"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAS5D;AAED,MAAM,WAAW,iBAAiB;IAChC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkBlE"}
@@ -1,5 +1,15 @@
1
1
  /**
2
- * Prompt framing for external IM channel messages (channel_message
2
+ * Prompt framing for inbound messages.
3
+ *
4
+ * Chat messages (buildChatPrompt): under `main` channel routing every Parall
5
+ * chat shares the agent's ONE parel session, so consecutive turns can come
6
+ * from different conversations — each message is framed with its originating
7
+ * chat so the model registers the context switch instead of answering chat B
8
+ * with chat A's thread of thought. No reply hint: the turn-end reply is
9
+ * auto-delivered to the right chat by replyRoute (deliver()), and sandbox CLI
10
+ * calls get the right PRLL_CHAT_ID via per-turn invocation context.
11
+ *
12
+ * External IM channel messages (buildChannelPrompt, channel_message
3
13
  * dispatches). Mirrors agent-core's event-format.ts channel branch, adapted
4
14
  * to the parel runtime's tool surface:
5
15
  *
@@ -21,6 +31,24 @@ function sanitizeMeta(value) {
21
31
  .replace(/[[\]|]/g, ' ')
22
32
  .trim();
23
33
  }
34
+ /**
35
+ * Frame a Parall chat message with its conversation of origin. Kept to a
36
+ * minimal metadata header: the text itself is the user message, and heavier
37
+ * context (unread counts, membership) is the standard runtimes' enrichment —
38
+ * not re-created here.
39
+ */
40
+ export function buildChatPrompt(args) {
41
+ const chatLabel = args.chatName
42
+ ? `${sanitizeMeta(args.chatId)} — ${sanitizeMeta(args.chatName)}`
43
+ : sanitizeMeta(args.chatId);
44
+ const lines = [`[Chat: ${chatLabel}]`];
45
+ if (args.senderId)
46
+ lines.push(`[From: ${sanitizeMeta(args.senderId)}]`);
47
+ if (args.threadRootId)
48
+ lines.push(`[Thread: ${sanitizeMeta(args.threadRootId)}]`);
49
+ lines.push('', args.text);
50
+ return lines.join('\n');
51
+ }
24
52
  export function buildChannelPrompt(args) {
25
53
  const providerLabel = sanitizeMeta(args.provider ?? 'external IM');
26
54
  const convLabel = args.externalConversationId
package/dist/delivery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
2
- import { ensureSession, invalidateSession, patchSession, REQUEST_TIMEOUT_MS, turnEnvelopeKey, } from './session.js';
2
+ import { ensureSession, invalidateSession, patchSession, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, } from './session.js';
3
3
  /**
4
4
  * Post the agent's reply back to the Parall chat. The chat id rides the
5
5
  * replyRoute.data set in inbound.ts.
@@ -34,9 +34,9 @@ export async function buildParallDelivery(delivery, ctx) {
34
34
  // not the reply). Only run the redundant idle so a binding without
35
35
  // `observe: [turn]` still closes the session.
36
36
  if (route.conversationId) {
37
- const sessionId = await ensureSession(ctx, route.conversationId, { timeoutMs: 3_000 });
37
+ const sessionId = await ensureSession(ctx, { timeoutMs: 3_000 });
38
38
  if (sessionId) {
39
- await idleUnlessSuperseded(ctx, route.conversationId, route.envelopeId, sessionId);
39
+ await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
40
40
  }
41
41
  }
42
42
  return [];
@@ -46,9 +46,9 @@ export async function buildParallDelivery(delivery, ctx) {
46
46
  if (!text)
47
47
  return [];
48
48
  // Short timeout: this sits in front of the user-visible reply POST. Cache
49
- // hit (every message after a chat's first) is a store read; on a slow miss
50
- // we degrade to a reply without the session link rather than delay it.
51
- const sessionId = await ensureSession(ctx, chatId, { timeoutMs: 3_000 });
49
+ // hit (every dispatch after the agent's first) is a store read; on a slow
50
+ // miss we degrade to a reply without the session link rather than delay it.
51
+ const sessionId = await ensureSession(ctx, { timeoutMs: 3_000 });
52
52
  const request = {
53
53
  url: `${apiUrl}/api/v1/orgs/${orgId}/chats/${chatId}/messages`,
54
54
  method: 'POST',
@@ -101,20 +101,21 @@ export async function buildParallDelivery(delivery, ctx) {
101
101
  // run the same turnEnvelope staleness guard and idle is idempotent, so the
102
102
  // overlap is harmless.
103
103
  if (sessionId) {
104
- await idleUnlessSuperseded(ctx, chatId, route.envelopeId, sessionId);
104
+ await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
105
105
  }
106
106
  return [];
107
107
  }
108
108
  /**
109
- * Idle the subject's session unless a newer envelope owns the turn (the same
110
- * turnEnvelope staleness guard onAgentEvent runs). Fail-open when the marker
111
- * is unavailable a stuck ring is worse than a blink.
109
+ * Idle the agent's main session unless a newer envelope owns the turn (the
110
+ * same agent-global turnEnvelope staleness guard onAgentEvent runs any
111
+ * chat's later message supersedes this turn's idle). Fail-open when the
112
+ * marker is unavailable — a stuck ring is worse than a blink.
112
113
  */
113
- async function idleUnlessSuperseded(ctx, subject, envelopeId, sessionId) {
114
- const current = await ctx.store?.get(turnEnvelopeKey(subject)).catch(() => null);
114
+ async function idleUnlessSuperseded(ctx, envelopeId, sessionId) {
115
+ const current = await ctx.store?.get(TURN_ENVELOPE_KEY).catch(() => null);
115
116
  if (!(typeof current === 'string' && current) || !envelopeId || current === envelopeId) {
116
117
  const result = await patchSession(ctx, sessionId, { status: 'idle' });
117
118
  if (result === 'stale')
118
- await invalidateSession(ctx, subject);
119
+ await invalidateSession(ctx);
119
120
  }
120
121
  }
@@ -1 +1 @@
1
- {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;AA4C3B,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAiB5B;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAiFrF;AA+XD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA2C7B"}
1
+ {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;AAiD3B,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAiB5B;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAuDrF;AAkdD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAyD7B"}
package/dist/inbound.js CHANGED
@@ -1,6 +1,6 @@
1
- import { buildChannelPrompt } from './channel-prompt.js';
1
+ import { buildChannelPrompt, buildChatPrompt } from './channel-prompt.js';
2
2
  import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
3
- import { createChannelInputStep, createInputStep, createTraceStep, ensureSession, invalidateSession, markDispatchReceived, patchSession, REQUEST_TIMEOUT_MS, turnEnvelopeKey, } from './session.js';
3
+ import { createChannelInputStep, createErrorStep, createInputStep, createTraceStep, ensureSession, invalidateSession, markDispatchReceived, patchSession, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, } from './session.js';
4
4
  export async function handleParallFrame(frame, ctx) {
5
5
  let event;
6
6
  try {
@@ -52,48 +52,21 @@ export async function catchUpParall(ctx) {
52
52
  if (!res.ok)
53
53
  break;
54
54
  const body = (await res.json().catch(() => null));
55
- // Same-chat dispatches run SEQUENTIALLY (session reporting PATCHes one
56
- // shared session per chat concurrent replay would let an older
57
- // dispatch overwrite trigger_message_id after a newer one and reorder
58
- // input steps); different chats still run concurrently. Page order is
59
- // FIFO, so per-chat envelope order is preserved. A failed dispatch
60
- // resolves to [] and stays pending for the next sweep.
61
- //
62
- // channel_message dispatches carry no chat_id and their conversation
63
- // (the session key) is only known after fetching the message — so they
64
- // all share ONE sequential chain: per-conversation order is preserved
65
- // at the cost of serializing across conversations, which is fine for
66
- // the small external-IM backlogs a reconnect sweep sees.
67
- const byChat = new Map();
68
- const unkeyed = [];
55
+ // ALL dispatches replay SEQUENTIALLY, in page order (FIFO): every
56
+ // envelope feeds the agent's one main session, so cross-chat replay
57
+ // concurrency would scramble the session's input order AND race the
58
+ // agent-global turnEnvelope marker (a slower old dispatch could
59
+ // overwrite a newer one's marker and mis-arm the done-signal guard).
60
+ // Reconnect backlogs are small; serialization is the correctness
61
+ // posture, not a throughput concern. A failed dispatch resolves to []
62
+ // and stays pending for the next sweep.
69
63
  for (const d of body?.data ?? []) {
70
- const cid = String(d.source_type ?? '') === 'channel_message'
71
- ? CHANNEL_SWEEP_CHAIN
72
- : d.chat_id
73
- ? String(d.chat_id)
74
- : '';
75
- if (!cid) {
76
- unkeyed.push(d);
77
- continue;
78
- }
79
- const group = byChat.get(cid);
80
- if (group)
81
- group.push(d);
82
- else
83
- byChat.set(cid, [d]);
64
+ const out = await effectsForDispatch(d, ctx).catch((err) => {
65
+ console.error('[parel-channel] catch-up dispatch failed', err);
66
+ return [];
67
+ });
68
+ effects.push(...out);
84
69
  }
85
- const runOne = (d) => effectsForDispatch(d, ctx).catch((err) => {
86
- console.error('[parel-channel] catch-up dispatch failed', err);
87
- return [];
88
- });
89
- const chains = [...byChat.values()].map(async (group) => {
90
- const out = [];
91
- for (const d of group)
92
- out.push(...(await runOne(d)));
93
- return out;
94
- });
95
- const pageEffects = await Promise.all([...chains, ...unkeyed.map(runOne)]);
96
- effects.push(...pageEffects.flat());
97
70
  if (!body?.next_cursor)
98
71
  break;
99
72
  cursor = body.next_cursor;
@@ -109,11 +82,6 @@ export async function catchUpParall(ctx) {
109
82
  }
110
83
  return effects;
111
84
  }
112
- /**
113
- * Synthetic sweep group key for channel_message dispatches (no chat_id).
114
- * Chat ids are cht_-prefixed nanoids, so this cannot collide with one.
115
- */
116
- const CHANNEL_SWEEP_CHAIN = 'sweep:channel_message';
117
85
  async function effectsForDispatch(data, ctx) {
118
86
  const sourceType = String(data.source_type ?? '');
119
87
  const sourceId = data.source_id ? String(data.source_id) : undefined;
@@ -171,16 +139,18 @@ async function effectsForDispatch(data, ctx) {
171
139
  // pending ring (a later successful turn's idle clears agent-level pendings).
172
140
  const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
173
141
  const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
174
- let sessionId = await ensureSession(ctx, chatId);
142
+ let sessionId = await ensureSession(ctx);
175
143
  if (sessionId) {
176
144
  // The guard marker goes down BEFORE any session write: a stale
177
145
  // turn_completed for the PREVIOUS turn (outbox retry, late event) checks
178
146
  // turnEnvelope to decide whether its idle is still safe — if the active
179
147
  // patch below could land first, that event would still see the old
180
- // envelope id and idle the session out from under this message.
148
+ // envelope id and idle the session out from under this message. The
149
+ // marker is agent-global: chats share the one main session, so a chat-A
150
+ // done signal must not idle it while this (later) message is pending.
181
151
  // (Residual read-then-patch overlap is zero when the host runs a
182
152
  // connection's hooks serially, per the C3 contract / C3b confirm item.)
183
- await ctx.store?.set(turnEnvelopeKey(chatId), envelopeId).catch(() => { });
153
+ await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => { });
184
154
  const report = (sid) => Promise.all([
185
155
  patchSession(ctx, sid, { status: 'active', trigger_message_id: sourceId }),
186
156
  createInputStep(ctx, sid, {
@@ -195,8 +165,8 @@ async function effectsForDispatch(data, ctx) {
195
165
  // The cached session was closed under us (e.g. New Session). The
196
166
  // get-or-create falls through terminal rows, so one rebuild + retry
197
167
  // repairs reporting for this and every later dispatch.
198
- await invalidateSession(ctx, chatId);
199
- const fresh = await ensureSession(ctx, chatId);
168
+ await invalidateSession(ctx);
169
+ const fresh = await ensureSession(ctx);
200
170
  if (fresh && fresh !== sessionId) {
201
171
  await report(fresh);
202
172
  sessionId = fresh;
@@ -212,6 +182,22 @@ async function effectsForDispatch(data, ctx) {
212
182
  PRLL_TRIGGER_MESSAGE_ID: sourceId,
213
183
  ...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
214
184
  };
185
+ // Frame the message with its chat of origin: all chats share the agent's
186
+ // one main session, so the model must see WHERE each message comes from or
187
+ // it answers chat B inside chat A's train of thought. The name is cosmetic
188
+ // garnish and must never block the emit/ack hot path (the catch-up sweep is
189
+ // fully serialized — one slow lookup would stall the whole backlog): only
190
+ // the cached name is used, and a miss kicks off a fire-and-forget prefetch
191
+ // so the NEXT message from this chat gets the name. The id alone already
192
+ // marks the context switch.
193
+ const chatName = await cachedChatName(ctx, chatId);
194
+ const framedText = buildChatPrompt({
195
+ chatId,
196
+ chatName,
197
+ senderId: msg?.sender_id ? String(msg.sender_id) : undefined,
198
+ threadRootId,
199
+ text,
200
+ });
215
201
  const effects = [
216
202
  {
217
203
  type: 'emitEvent',
@@ -221,10 +207,12 @@ async function effectsForDispatch(data, ctx) {
221
207
  connectionId: ctx.connectionId,
222
208
  source: 'parall',
223
209
  type: 'message',
224
- // per_subject routing keys the parel session by subject one parel
225
- // session per Parall chat (mirrors Parall's chat-scoped conversations).
210
+ // The binding's `main` routing sends every envelope to the agent's
211
+ // one session; subject still carries the chat so parel's turn input
212
+ // grouping (F2) never mixes conversations in one turn and agent
213
+ // events mirror it back for per-chat correlation.
226
214
  subject: chatId,
227
- data: { text, chatId, messageId: sourceId, threadRootId },
215
+ data: { text: framedText, chatId, messageId: sourceId, threadRootId },
228
216
  // chat id + thread anchor ride replyRoute.data so deliver() knows
229
217
  // where to post back (same shape channel-slack-socket uses for its
230
218
  // channel id).
@@ -308,9 +296,9 @@ async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
308
296
  // returned; the turnEnvelope marker goes down before any session write.
309
297
  const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
310
298
  const envelopeId = dispatchId || `channel_message:${sourceId}`;
311
- let sessionId = await ensureSession(ctx, conversationId);
299
+ let sessionId = await ensureSession(ctx);
312
300
  if (sessionId) {
313
- await ctx.store?.set(turnEnvelopeKey(conversationId), envelopeId).catch(() => { });
301
+ await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => { });
314
302
  const report = (sid) => Promise.all([
315
303
  patchSession(ctx, sid, { status: 'active', trigger_message_id: sourceId }),
316
304
  createChannelInputStep(ctx, sid, {
@@ -326,8 +314,8 @@ async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
326
314
  ]);
327
315
  const results = await report(sessionId);
328
316
  if (results.includes('stale')) {
329
- await invalidateSession(ctx, conversationId);
330
- const fresh = await ensureSession(ctx, conversationId);
317
+ await invalidateSession(ctx);
318
+ const fresh = await ensureSession(ctx);
331
319
  if (fresh && fresh !== sessionId) {
332
320
  await report(fresh);
333
321
  sessionId = fresh;
@@ -354,9 +342,9 @@ async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
354
342
  // message — the external-audience framing rides inside it (parel has
355
343
  // no separate prompt channel), so 'message' is the right type here.
356
344
  type: 'message',
357
- // per_subject routing keys the parel session by subject → one parel
358
- // session per external conversation (mirrors chv_ session 1:1 in
359
- // the design).
345
+ // main routing sends this to the agent's one session too; subject
346
+ // still carries the external conversation (chv_) for parel's turn
347
+ // input grouping (F2) and per-conversation event correlation.
360
348
  subject: conversationId,
361
349
  data: {
362
350
  text: buildChannelPrompt({
@@ -399,6 +387,64 @@ async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
399
387
  * degrades to neutral rather than inventing a clip alias.
400
388
  */
401
389
  const PROVIDER_LOOKUP_TIMEOUT_MS = 3_000;
390
+ /**
391
+ * How long a cached chat name is served without revalidation. Chat renames
392
+ * are rare and the frame is the model's cross-chat context anchor, so serve
393
+ * stale-while-revalidate: past the TTL the cached name is still returned
394
+ * immediately (never block the hot path) and a background refresh updates
395
+ * the row for later messages.
396
+ */
397
+ const CHAT_NAME_TTL_MS = 60 * 60 * 1000;
398
+ /**
399
+ * chat id → display name for prompt framing, NON-BLOCKING: returns only what
400
+ * the durable store already holds; a miss fires a background prefetch (tight
401
+ * timeout, any failure ignored) so later messages get the name. The first
402
+ * message from a chat is framed id-only — acceptable, the id is the
403
+ * correctness anchor and the name is garnish; a blocking lookup here would
404
+ * sit on the emit/ack hot path and stall the fully-serialized catch-up sweep
405
+ * one dispatch at a time. DMs have no name — the empty name is cached as a
406
+ * "resolved, nameless" sentinel so they don't re-prefetch on every message.
407
+ * Rows carry the fetch timestamp; past CHAT_NAME_TTL_MS the stale name is
408
+ * served AND revalidated in the background, so a renamed chat converges
409
+ * within one TTL window. (Pre-TTL rows were plain strings — served once and
410
+ * upgraded by the same background refresh.)
411
+ */
412
+ async function cachedChatName(ctx, chatId) {
413
+ const cacheKey = `chatName:${chatId}`;
414
+ const cached = await ctx.store?.get(cacheKey).catch(() => null);
415
+ if (cached &&
416
+ typeof cached === 'object' &&
417
+ typeof cached.name === 'string') {
418
+ const { name, at } = cached;
419
+ if (typeof at !== 'number' || ctx.now() - at > CHAT_NAME_TTL_MS) {
420
+ void prefetchChatName(ctx, chatId, cacheKey).catch(() => { });
421
+ }
422
+ return name || undefined;
423
+ }
424
+ if (typeof cached === 'string') {
425
+ // Legacy plain-string row: serve it, upgrade to the timestamped shape.
426
+ void prefetchChatName(ctx, chatId, cacheKey).catch(() => { });
427
+ return cached || undefined;
428
+ }
429
+ void prefetchChatName(ctx, chatId, cacheKey).catch(() => { });
430
+ return undefined;
431
+ }
432
+ async function prefetchChatName(ctx, chatId, cacheKey) {
433
+ try {
434
+ const res = await fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/chats/${chatId}`, {
435
+ headers: { Authorization: `Bearer ${requireAgk(ctx)}` },
436
+ signal: AbortSignal.timeout(PROVIDER_LOOKUP_TIMEOUT_MS),
437
+ });
438
+ if (!res.ok)
439
+ return;
440
+ const body = (await res.json().catch(() => null));
441
+ const name = typeof body?.name === 'string' ? body.name : '';
442
+ await ctx.store?.set(cacheKey, { name, at: ctx.now() }).catch(() => { });
443
+ }
444
+ catch {
445
+ // Cosmetic prefetch — the next message from this chat retries.
446
+ }
447
+ }
402
448
  async function resolveChannelProvider(ctx, connectionId, get) {
403
449
  const cacheKey = `channelProvider:${connectionId}`;
404
450
  const cached = await ctx.store?.get(cacheKey).catch(() => null);
@@ -467,34 +513,48 @@ export async function handleParallAgentEvent(event, ctx) {
467
513
  return [];
468
514
  try {
469
515
  if (event.type === 'turn_completed' || event.type === 'turn_failed') {
470
- // Staleness guard: only idle if this event's turn is still the chat's
471
- // latest emitted envelope a late/retried completed event for a
472
- // superseded turn must not clear the newer turn's indicator. Fail-open
516
+ // Staleness guard: only idle if this event's turn covers the AGENT'S
517
+ // latest emitted envelope (the marker is agent-global all chats share
518
+ // the one main session, so a done signal for chat A's turn must not
519
+ // clear the indicator while a later chat-B message is queued/running).
520
+ // A late/retried event for a superseded turn is dropped. Fail-open
473
521
  // when the marker is unavailable (a stuck ring is worse than a blink).
474
- const current = await ctx.store?.get(turnEnvelopeKey(chatId)).catch(() => null);
522
+ const current = await ctx.store?.get(TURN_ENVELOPE_KEY).catch(() => null);
475
523
  if (typeof current === 'string' && current && !event.envelopeIds.includes(current)) {
476
524
  return [];
477
525
  }
478
- const sessionId = await ensureSession(ctx, chatId);
526
+ const sessionId = await ensureSession(ctx);
479
527
  if (sessionId) {
528
+ // Error step BEFORE the idle patch so the panel orders failure →
529
+ // done; the step is what makes a failed turn visible at all (the
530
+ // reply never arrives — see createErrorStep).
531
+ let stale = false;
532
+ if (event.type === 'turn_failed') {
533
+ const stepResult = await createErrorStep(ctx, sessionId, {
534
+ subject: chatId,
535
+ turnId: event.turnId,
536
+ error: event.error,
537
+ });
538
+ stale = stepResult === 'stale';
539
+ }
480
540
  const result = await patchSession(ctx, sessionId, { status: 'idle' });
481
541
  // A closed session is already terminal (frontend shows done) — just
482
542
  // drop the cache so the next dispatch resolves a fresh one.
483
- if (result === 'stale')
484
- await invalidateSession(ctx, chatId);
543
+ if (stale || result === 'stale')
544
+ await invalidateSession(ctx);
485
545
  }
486
546
  return [];
487
547
  }
488
548
  if (event.type === 'model_reasoning' ||
489
549
  event.type === 'tool_call' ||
490
550
  event.type === 'tool_result') {
491
- const sessionId = await ensureSession(ctx, chatId);
551
+ const sessionId = await ensureSession(ctx);
492
552
  if (sessionId) {
493
553
  const result = await createTraceStep(ctx, sessionId, chatId, event);
494
554
  // Dropped step is fine (display feed); uncache so the NEXT event
495
555
  // resolves a live session instead of failing forever.
496
556
  if (result === 'stale')
497
- await invalidateSession(ctx, chatId);
557
+ await invalidateSession(ctx);
498
558
  }
499
559
  return [];
500
560
  }
package/dist/session.d.ts CHANGED
@@ -7,23 +7,32 @@ import type { ConnectorContext } from '@parel/plugin-sdk';
7
7
  * Every function catches its own failures and returns a degraded value: the
8
8
  * message pipeline (emit / reply) must never block on indicator reporting.
9
9
  *
10
- * Session identity is deterministic — (runtime_type='parel',
11
- * runtime_session_id=subject), where the subject is the envelope routing key:
12
- * a chat id (cht_) for Parall chats, or a channel conversation id (chv_) for
13
- * external IM conversations. per_subject routing already keys one parel
14
- * session per subject, so the parall-side session mirrors it 1:1 without the
15
- * connector ever knowing parel's internal session id.
10
+ * Session identity is deterministic and agent-global — (runtime_type='parel',
11
+ * runtime_session_id='main'). The binding's `main` routing keys EVERY
12
+ * envelope to one parel session per agent (the single-orchestrator model,
13
+ * aligned with the standard runtimes; parel-session-alignment-plan.md), so
14
+ * the parall-side AgentSession mirrors it 1:1: one ase_ per agent, without
15
+ * the connector ever knowing parel's internal session id. Envelopes still
16
+ * carry subject=chatId/chv_ — parel groups a turn's inputs by it (F2) and
17
+ * mirrors it on agent events, so steps and turn signals stay chat-addressed.
16
18
  */
17
19
  export declare const REQUEST_TIMEOUT_MS = 10000;
20
+ /** The agent-global runtime session identity under `main` channel routing. */
21
+ export declare const MAIN_SESSION_KEY = "main";
18
22
  /** Reporting needs the agent id in the endpoint path; old configs lack it. */
19
23
  export declare function reportingEnabled(ctx: ConnectorContext): boolean;
20
24
  /**
21
- * The subject's most recently emitted envelope id — the staleness guard for
25
+ * The agent's most recently emitted envelope id — the staleness guard for
22
26
  * turn-level done signals: a turn_completed/failed whose envelopeIds don't
23
27
  * include the current value belongs to a superseded turn and must not idle
24
- * the session out from under the newer one.
28
+ * the session out from under the newer one. Agent-global (NOT per-subject):
29
+ * all chats share the one main session, so a completed turn for chat A must
30
+ * not idle the session while a later-emitted chat-B message is queued or
31
+ * running — only the turn covering the LAST emitted envelope may idle.
32
+ * (Pre-main-routing store keys were `turnEnvelope:{subject}`; those rows are
33
+ * simply never read again.)
25
34
  */
26
- export declare const turnEnvelopeKey: (subject: string) => string;
35
+ export declare const TURN_ENVELOPE_KEY = "turnEnvelope:main";
27
36
  /**
28
37
  * 'stale' means the session id no longer accepts writes (closed/superseded —
29
38
  * e.g. the New Session control ended it): callers invalidate the cache and
@@ -32,15 +41,16 @@ export declare const turnEnvelopeKey: (subject: string) => string;
32
41
  */
33
42
  export type ReportResult = 'ok' | 'stale' | 'failed';
34
43
  /** Drop the cached session id so the next ensureSession re-resolves it. */
35
- export declare function invalidateSession(ctx: ConnectorContext, subject: string): Promise<void>;
44
+ export declare function invalidateSession(ctx: ConnectorContext): Promise<void>;
36
45
  /**
37
- * Get-or-create the subject's parall AgentSession (ase_), cached in the
46
+ * Get-or-create the agent's parall AgentSession (ase_), cached in the
38
47
  * durable per-connection store. Returns null when reporting is disabled or
39
48
  * the call fails — callers skip their report and move on. Callers on a
40
49
  * latency-sensitive path (deliver blocks the user-visible reply on this) pass
41
50
  * a shorter timeoutMs; the cache-hit fast path is a store read either way.
51
+ * (Pre-main-routing store keys were `session:{subject}`; never read again.)
42
52
  */
43
- export declare function ensureSession(ctx: ConnectorContext, subject: string, opts?: {
53
+ export declare function ensureSession(ctx: ConnectorContext, opts?: {
44
54
  timeoutMs?: number;
45
55
  }): Promise<string | null>;
46
56
  /** PATCH the session (status / trigger_message_id). Never throws. */
@@ -77,24 +87,62 @@ export declare function createChannelInputStep(ctx: ConnectorContext, sessionId:
77
87
  text: string;
78
88
  sentAt?: string;
79
89
  }): Promise<ReportResult>;
90
+ /**
91
+ * Credential redaction for runtime error strings that become user-visible
92
+ * step content. Two layers: every secret VALUE the connector actually holds
93
+ * (`ctx.secrets`) is replaced exactly — the strongest guarantee for the
94
+ * secrets in this connector's scope — then credential-shaped patterns are
95
+ * masked (parall key prefixes, common vendor prefixes, AWS access key ids,
96
+ * bearer tokens, and any long unbroken token as a fallback: credentials are
97
+ * long random strings, while prose and paths break on spaces and slashes
98
+ * first). Pattern matching can't catch every conceivable secret (e.g. a
99
+ * short password echoed by a foreign exception), which is the accepted
100
+ * trade-off for keeping the error text diagnosable — the audience is the
101
+ * org-internal session panel.
102
+ */
103
+ export declare function redactSecrets(s: string, knownValues?: string[]): string;
104
+ /**
105
+ * turn_failed → an error step in the session panel, following the
106
+ * cross-runtime error-step contract (text step + content.status:'error',
107
+ * same shape agent-core's dispatch-failure step uses — pinned by
108
+ * ts/protocol-vectors/agent-steps.json). Without this the turn's failure is
109
+ * invisible on the parall side: the 2026-07-05 parel regression was fully
110
+ * silent because this connector dropped event.error.
111
+ *
112
+ * The error string comes from parel's runtime (exception messages) — length
113
+ * is unbounded and it can echo credential-shaped values, so it is redacted
114
+ * (see redactSecrets) and truncated before leaving the connector.
115
+ */
116
+ export declare function createErrorStep(ctx: ConnectorContext, sessionId: string, args: {
117
+ subject: string;
118
+ turnId: string;
119
+ error: string;
120
+ }): Promise<ReportResult>;
80
121
  /**
81
122
  * E2 step-trace events → parall AgentSteps, shaped exactly like agent-core's
82
123
  * createRuntimeStep so the session panel renders them identically to CC's.
83
124
  * tool_call/tool_result carry a session-scoped idempotency key (events are
84
- * at-most-once today, but a callId key is free insurance); reasoning has no
85
- * natural key and parel already aggregates it host-side. The E2 tool_result
86
- * event has no tool name, so tool_call parks it in the store by callId.
125
+ * at-most-once today, but the key is free insurance). Provider call ids are
126
+ * only unique WITHIN a turn, and the shared main session makes step
127
+ * idempotency agent-wide so the key is turnId-scoped (`tc:{turnId}:{callId}`),
128
+ * or two turns that both emit `call_1` would swallow the later step as a
129
+ * duplicate. Reasoning has no natural key and parel already aggregates it
130
+ * host-side. The E2 tool_result event has no tool name, so tool_call parks
131
+ * it in the store by callId.
87
132
  */
88
133
  export declare function createTraceStep(ctx: ConnectorContext, sessionId: string, subject: string, event: {
89
134
  type: 'model_reasoning';
135
+ turnId?: string;
90
136
  text: string;
91
137
  } | {
92
138
  type: 'tool_call';
139
+ turnId?: string;
93
140
  callId: string;
94
141
  name: string;
95
142
  input: unknown;
96
143
  } | {
97
144
  type: 'tool_result';
145
+ turnId?: string;
98
146
  callId: string;
99
147
  status: 'ok' | 'error';
100
148
  durationMs?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAG1D;;;;;;;;;;;;;;GAcG;AAEH,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAEzC,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAE/D;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,WAA8B,CAAC;AAG9E;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMrD,2EAA2E;AAC3E,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7F;AASD;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyBxB;AAED,qEAAqE;AACrE,wBAAsB,YAAY,CAChC,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IAAE,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IAAC,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/D,OAAO,CAAC,YAAY,CAAC,CAgBvB;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC3E,OAAO,CAAC,YAAY,CAAC,CA8BvB;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IACJ,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACA,OAAO,CAAC,YAAY,CAAC,CAsCvB;AAOD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,KAAK,EACD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACnE;IACE,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACJ,OAAO,CAAC,YAAY,CAAC,CAyDvB;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,gBAAgB,EACrB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAcf"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}