fix: preserve subagent thread routing (#1241)
Thanks @gnarco. Co-authored-by: gnarco <gnarco@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,10 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
|
||||
agentTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
agentThreadId?: string | number;
|
||||
agentDir?: string;
|
||||
sandboxRoot?: string;
|
||||
workspaceDir?: string;
|
||||
@@ -108,6 +112,8 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentChannel: options?.agentChannel,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentTo: options?.agentTo,
|
||||
agentThreadId: options?.agentThreadId,
|
||||
sandboxed: options?.sandboxed,
|
||||
}),
|
||||
createSessionStatusTool({
|
||||
|
||||
@@ -208,6 +208,8 @@ export async function runEmbeddedPiAgent(
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
|
||||
@@ -148,6 +148,8 @@ export async function runEmbeddedAttempt(
|
||||
sandbox,
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
|
||||
@@ -23,6 +23,10 @@ export type RunEmbeddedPiAgentParams = {
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
|
||||
messageTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
messageThreadId?: string | number;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
|
||||
@@ -21,6 +21,8 @@ export type EmbeddedRunAttemptParams = {
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
messageTo?: string;
|
||||
messageThreadId?: string | number;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
|
||||
@@ -102,6 +102,8 @@ export function createClawdbotCodingTools(options?: {
|
||||
exec?: ExecToolDefaults & ProcessToolDefaults;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
messageTo?: string;
|
||||
messageThreadId?: string | number;
|
||||
sandbox?: SandboxContext | null;
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
@@ -265,6 +267,8 @@ export function createClawdbotCodingTools(options?: {
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentTo: options?.messageTo,
|
||||
agentThreadId: options?.messageThreadId,
|
||||
agentDir: options?.agentDir,
|
||||
sandboxRoot,
|
||||
workspaceDir: options?.workspaceDir,
|
||||
|
||||
@@ -98,6 +98,8 @@ function resolveAnnounceOrigin(
|
||||
|
||||
async function sendAnnounce(item: AnnounceQueueItem) {
|
||||
const origin = item.origin;
|
||||
const threadId =
|
||||
origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined;
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
@@ -106,6 +108,7 @@ async function sendAnnounce(item: AnnounceQueueItem) {
|
||||
channel: origin?.channel,
|
||||
accountId: origin?.accountId,
|
||||
to: origin?.to,
|
||||
threadId,
|
||||
deliver: true,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
@@ -424,6 +427,11 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
deliver: true,
|
||||
channel: directOrigin?.channel,
|
||||
accountId: directOrigin?.accountId,
|
||||
to: directOrigin?.to,
|
||||
threadId:
|
||||
directOrigin?.threadId != null && directOrigin.threadId !== ""
|
||||
? String(directOrigin.threadId)
|
||||
: undefined,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
expectFinal: true,
|
||||
|
||||
@@ -61,6 +61,8 @@ export function createSessionsSpawnTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string | number;
|
||||
sandboxed?: boolean;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
@@ -83,6 +85,8 @@ export function createSessionsSpawnTool(opts?: {
|
||||
const requesterOrigin = normalizeDeliveryContext({
|
||||
channel: opts?.agentChannel,
|
||||
accountId: opts?.agentAccountId,
|
||||
to: opts?.agentTo,
|
||||
threadId: opts?.agentThreadId,
|
||||
});
|
||||
const runTimeoutSeconds = (() => {
|
||||
const explicit =
|
||||
|
||||
@@ -210,6 +210,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
|
||||
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
|
||||
@@ -106,6 +106,8 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
|
||||
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
|
||||
@@ -145,6 +145,8 @@ export function createFollowupRunner(params: {
|
||||
sessionKey: queued.run.sessionKey,
|
||||
messageProvider: queued.run.messageProvider,
|
||||
agentAccountId: queued.run.agentAccountId,
|
||||
messageTo: queued.originatingTo,
|
||||
messageThreadId: queued.originatingThreadId,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
|
||||
@@ -168,6 +168,8 @@ export async function handleInlineActions(params: {
|
||||
agentSessionKey: sessionKey,
|
||||
agentChannel: channel,
|
||||
agentAccountId: (ctx as { AccountId?: string }).AccountId,
|
||||
agentTo: ctx.OriginatingTo ?? ctx.To,
|
||||
agentThreadId: ctx.MessageThreadId ?? undefined,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
@@ -255,6 +255,22 @@ describe("routeReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses threadId as threadTs for Slack when replyToId is missing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
threadId: "1710000000.9999",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ threadTs: "1710000000.9999" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends multiple mediaUrls (caption only on first)", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
|
||||
@@ -100,6 +100,11 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
return { ok: false, error: "Reply routing aborted" };
|
||||
}
|
||||
|
||||
const resolvedReplyToId =
|
||||
replyToId ??
|
||||
(channelId === "slack" && threadId != null && threadId !== "" ? String(threadId) : undefined);
|
||||
const resolvedThreadId = channelId === "slack" ? null : (threadId ?? null);
|
||||
|
||||
try {
|
||||
// Provider docking: this is an execution boundary (we're about to send).
|
||||
// Keep the module cheap to import by loading outbound plumbing lazily.
|
||||
@@ -110,8 +115,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
to,
|
||||
accountId: accountId ?? undefined,
|
||||
payloads: [normalized],
|
||||
replyToId: replyToId ?? null,
|
||||
threadId: threadId ?? null,
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
threadId: resolvedThreadId,
|
||||
abortSignal,
|
||||
mirror: params.sessionKey
|
||||
? {
|
||||
|
||||
@@ -221,16 +221,20 @@ export async function initSessionState(params: {
|
||||
const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
|
||||
const lastToRaw = (ctx.OriginatingTo as string | undefined) || ctx.To || baseEntry?.lastTo;
|
||||
const lastAccountIdRaw = (ctx.AccountId as string | undefined) || baseEntry?.lastAccountId;
|
||||
const lastThreadIdRaw =
|
||||
(ctx.MessageThreadId as string | number | undefined) || baseEntry?.lastThreadId;
|
||||
const deliveryFields = normalizeSessionDeliveryFields({
|
||||
deliveryContext: {
|
||||
channel: lastChannelRaw,
|
||||
to: lastToRaw,
|
||||
accountId: lastAccountIdRaw,
|
||||
threadId: lastThreadIdRaw,
|
||||
},
|
||||
});
|
||||
const lastChannel = deliveryFields.lastChannel ?? lastChannelRaw;
|
||||
const lastTo = deliveryFields.lastTo ?? lastToRaw;
|
||||
const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw;
|
||||
const lastThreadId = deliveryFields.lastThreadId ?? lastThreadIdRaw;
|
||||
sessionEntry = {
|
||||
...baseEntry,
|
||||
sessionId,
|
||||
@@ -261,6 +265,7 @@ export async function initSessionState(params: {
|
||||
lastChannel,
|
||||
lastTo,
|
||||
lastAccountId,
|
||||
lastThreadId,
|
||||
};
|
||||
const metaPatch = deriveSessionMetaPatch({
|
||||
ctx: sessionCtxForState,
|
||||
|
||||
@@ -407,6 +407,8 @@ export async function agentCommand(
|
||||
sessionKey,
|
||||
messageChannel,
|
||||
agentAccountId: runContext.accountId,
|
||||
messageTo: opts.replyTo ?? opts.to,
|
||||
messageThreadId: opts.threadId,
|
||||
currentChannelId: runContext.currentChannelId,
|
||||
currentThreadTs: runContext.currentThreadTs,
|
||||
replyToMode: runContext.replyToMode,
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function deliverAgentCommandResult(params: {
|
||||
sessionEntry,
|
||||
requestedChannel: opts.replyChannel ?? opts.channel,
|
||||
explicitTo: opts.replyTo ?? opts.to,
|
||||
explicitThreadId: opts.threadId,
|
||||
accountId: opts.replyAccountId ?? opts.accountId,
|
||||
wantsDelivery: deliver,
|
||||
});
|
||||
@@ -93,6 +94,10 @@ export async function deliverAgentCommandResult(params: {
|
||||
};
|
||||
const resolvedTarget = resolved.resolvedTarget;
|
||||
const deliveryTarget = resolved.resolvedTo;
|
||||
const resolvedThreadId = deliveryPlan.resolvedThreadId ?? opts.threadId;
|
||||
const resolvedReplyToId =
|
||||
deliveryChannel === "slack" && resolvedThreadId != null ? String(resolvedThreadId) : undefined;
|
||||
const resolvedThreadTarget = deliveryChannel === "slack" ? undefined : resolvedThreadId;
|
||||
|
||||
const logDeliveryError = (err: unknown) => {
|
||||
const message = `Delivery failed (${deliveryChannel}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
|
||||
@@ -153,6 +158,8 @@ export async function deliverAgentCommandResult(params: {
|
||||
to: deliveryTarget,
|
||||
accountId: resolvedAccountId,
|
||||
payloads: deliveryPayloads,
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
threadId: resolvedThreadTarget ?? null,
|
||||
bestEffort: bestEffortDeliver,
|
||||
onError: (err) => logDeliveryError(err),
|
||||
onPayload: logPayload,
|
||||
|
||||
@@ -14,5 +14,14 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext
|
||||
const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId);
|
||||
if (normalizedAccountId) merged.accountId = normalizedAccountId;
|
||||
|
||||
if (
|
||||
merged.currentThreadTs == null &&
|
||||
opts.threadId != null &&
|
||||
opts.threadId !== "" &&
|
||||
opts.threadId !== null
|
||||
) {
|
||||
merged.currentThreadTs = String(opts.threadId);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export type AgentCommandOpts = {
|
||||
replyChannel?: string;
|
||||
/** Override delivery account id (separate from session routing). */
|
||||
replyAccountId?: string;
|
||||
/** Override delivery thread/topic id (separate from session routing). */
|
||||
threadId?: string | number;
|
||||
/** Message channel context (webchat|voicewake|whatsapp|...). */
|
||||
messageChannel?: string;
|
||||
channel?: string; // delivery channel (whatsapp|telegram|...)
|
||||
|
||||
@@ -56,11 +56,13 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
|
||||
const sameDelivery =
|
||||
(entry.deliveryContext?.channel ?? undefined) === nextDelivery?.channel &&
|
||||
(entry.deliveryContext?.to ?? undefined) === nextDelivery?.to &&
|
||||
(entry.deliveryContext?.accountId ?? undefined) === nextDelivery?.accountId;
|
||||
(entry.deliveryContext?.accountId ?? undefined) === nextDelivery?.accountId &&
|
||||
(entry.deliveryContext?.threadId ?? undefined) === nextDelivery?.threadId;
|
||||
const sameLast =
|
||||
entry.lastChannel === normalized.lastChannel &&
|
||||
entry.lastTo === normalized.lastTo &&
|
||||
entry.lastAccountId === normalized.lastAccountId;
|
||||
entry.lastAccountId === normalized.lastAccountId &&
|
||||
entry.lastThreadId === normalized.lastThreadId;
|
||||
if (sameDelivery && sameLast) return entry;
|
||||
return {
|
||||
...entry,
|
||||
@@ -68,6 +70,7 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
|
||||
lastChannel: normalized.lastChannel,
|
||||
lastTo: normalized.lastTo,
|
||||
lastAccountId: normalized.lastAccountId,
|
||||
lastThreadId: normalized.lastThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,11 +382,12 @@ export async function updateLastRoute(params: {
|
||||
channel?: SessionEntry["lastChannel"];
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
deliveryContext?: DeliveryContext;
|
||||
ctx?: MsgContext;
|
||||
groupResolution?: import("./types.js").GroupKeyResolution | null;
|
||||
}) {
|
||||
const { storePath, sessionKey, channel, to, accountId, ctx } = params;
|
||||
const { storePath, sessionKey, channel, to, accountId, threadId, ctx } = params;
|
||||
return await withSessionStoreLock(storePath, async () => {
|
||||
const store = loadSessionStore(storePath);
|
||||
const existing = store[sessionKey];
|
||||
@@ -393,6 +397,7 @@ export async function updateLastRoute(params: {
|
||||
channel,
|
||||
to,
|
||||
accountId,
|
||||
threadId,
|
||||
});
|
||||
const mergedInput = mergeDeliveryContext(explicitContext, inlineContext);
|
||||
const merged = mergeDeliveryContext(mergedInput, deliveryContextFromSession(existing));
|
||||
@@ -401,6 +406,7 @@ export async function updateLastRoute(params: {
|
||||
channel: merged?.channel,
|
||||
to: merged?.to,
|
||||
accountId: merged?.accountId,
|
||||
threadId: merged?.threadId,
|
||||
},
|
||||
});
|
||||
const metaPatch = ctx
|
||||
@@ -417,6 +423,7 @@ export async function updateLastRoute(params: {
|
||||
lastChannel: normalized.lastChannel,
|
||||
lastTo: normalized.lastTo,
|
||||
lastAccountId: normalized.lastAccountId,
|
||||
lastThreadId: normalized.lastThreadId,
|
||||
};
|
||||
const next = mergeSessionEntry(
|
||||
existing,
|
||||
|
||||
@@ -89,6 +89,7 @@ export type SessionEntry = {
|
||||
lastChannel?: SessionChannelId;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
lastThreadId?: string | number;
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
systemPromptReport?: SessionSystemPromptReport;
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export const AgentParamsSchema = Type.Object(
|
||||
replyChannel: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
replyAccountId: Type.Optional(Type.String()),
|
||||
threadId: Type.Optional(Type.String()),
|
||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
|
||||
@@ -71,6 +71,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
replyChannel?: string;
|
||||
accountId?: string;
|
||||
replyAccountId?: string;
|
||||
threadId?: string;
|
||||
lane?: string;
|
||||
extraSystemPrompt?: string;
|
||||
idempotencyKey: string;
|
||||
@@ -257,10 +258,15 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
: typeof request.to === "string" && request.to.trim()
|
||||
? request.to.trim()
|
||||
: undefined;
|
||||
const explicitThreadId =
|
||||
typeof request.threadId === "string" && request.threadId.trim()
|
||||
? request.threadId.trim()
|
||||
: undefined;
|
||||
const deliveryPlan = resolveAgentDeliveryPlan({
|
||||
sessionEntry,
|
||||
requestedChannel: request.replyChannel ?? request.channel,
|
||||
explicitTo,
|
||||
explicitThreadId,
|
||||
accountId: request.replyAccountId ?? request.accountId,
|
||||
wantsDelivery,
|
||||
});
|
||||
@@ -298,6 +304,8 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
respond(true, accepted, undefined, { runId });
|
||||
|
||||
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
|
||||
|
||||
void agentCommand(
|
||||
{
|
||||
message,
|
||||
@@ -310,9 +318,11 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
deliveryTargetMode,
|
||||
channel: resolvedChannel,
|
||||
accountId: resolvedAccountId,
|
||||
threadId: resolvedThreadId,
|
||||
runContext: {
|
||||
messageChannel: resolvedChannel,
|
||||
accountId: resolvedAccountId,
|
||||
currentThreadTs: resolvedThreadId != null ? String(resolvedThreadId) : undefined,
|
||||
},
|
||||
timeout: request.timeout?.toString(),
|
||||
bestEffortDeliver,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type AgentDeliveryPlan = {
|
||||
resolvedChannel: GatewayMessageChannel;
|
||||
resolvedTo?: string;
|
||||
resolvedAccountId?: string;
|
||||
resolvedThreadId?: string | number;
|
||||
deliveryTargetMode?: ChannelOutboundTargetMode;
|
||||
};
|
||||
|
||||
@@ -29,6 +30,7 @@ export function resolveAgentDeliveryPlan(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
requestedChannel?: string;
|
||||
explicitTo?: string;
|
||||
explicitThreadId?: string | number;
|
||||
accountId?: string;
|
||||
wantsDelivery: boolean;
|
||||
}): AgentDeliveryPlan {
|
||||
@@ -46,6 +48,7 @@ export function resolveAgentDeliveryPlan(params: {
|
||||
entry: params.sessionEntry,
|
||||
requestedChannel: requestedChannel === INTERNAL_MESSAGE_CHANNEL ? "last" : requestedChannel,
|
||||
explicitTo,
|
||||
explicitThreadId: params.explicitThreadId,
|
||||
});
|
||||
|
||||
const resolvedChannel = (() => {
|
||||
@@ -89,6 +92,7 @@ export function resolveAgentDeliveryPlan(params: {
|
||||
resolvedChannel,
|
||||
resolvedTo,
|
||||
resolvedAccountId,
|
||||
resolvedThreadId: baseDelivery.threadId,
|
||||
deliveryTargetMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,10 +124,12 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
accountId: "acct-1",
|
||||
threadId: undefined,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "acct-1",
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,10 +148,12 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
channel: "telegram",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,10 +173,12 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
channel: "telegram",
|
||||
to: "+1555",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,10 +198,12 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
channel: "slack",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,16 +35,19 @@ export type SessionDeliveryTarget = {
|
||||
channel?: DeliverableMessageChannel;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
mode: ChannelOutboundTargetMode;
|
||||
lastChannel?: DeliverableMessageChannel;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
lastThreadId?: string | number;
|
||||
};
|
||||
|
||||
export function resolveSessionDeliveryTarget(params: {
|
||||
entry?: SessionEntry;
|
||||
requestedChannel?: GatewayMessageChannel | "last";
|
||||
explicitTo?: string;
|
||||
explicitThreadId?: string | number;
|
||||
fallbackChannel?: DeliverableMessageChannel;
|
||||
allowMismatchedLastTo?: boolean;
|
||||
mode?: ChannelOutboundTargetMode;
|
||||
@@ -54,6 +57,7 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
|
||||
const lastTo = context?.to;
|
||||
const lastAccountId = context?.accountId;
|
||||
const lastThreadId = context?.threadId;
|
||||
|
||||
const rawRequested = params.requestedChannel ?? "last";
|
||||
const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);
|
||||
@@ -68,6 +72,10 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
typeof params.explicitTo === "string" && params.explicitTo.trim()
|
||||
? params.explicitTo.trim()
|
||||
: undefined;
|
||||
const explicitThreadId =
|
||||
params.explicitThreadId != null && params.explicitThreadId !== ""
|
||||
? params.explicitThreadId
|
||||
: undefined;
|
||||
|
||||
let channel = requestedChannel === "last" ? lastChannel : requestedChannel;
|
||||
if (!channel && params.fallbackChannel && isDeliverableMessageChannel(params.fallbackChannel)) {
|
||||
@@ -84,16 +92,19 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
}
|
||||
|
||||
const accountId = channel && channel === lastChannel ? lastAccountId : undefined;
|
||||
const threadId = channel && channel === lastChannel ? lastThreadId : undefined;
|
||||
const mode = params.mode ?? (explicitTo ? "explicit" : "implicit");
|
||||
|
||||
return {
|
||||
channel,
|
||||
to,
|
||||
accountId,
|
||||
threadId: explicitThreadId ?? threadId,
|
||||
mode,
|
||||
lastChannel,
|
||||
lastTo,
|
||||
lastAccountId,
|
||||
lastThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -479,6 +479,7 @@ export async function prepareSlackMessage(params: {
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
MessageThreadId: isThreadReply ? threadTs : undefined,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
|
||||
@@ -39,10 +39,13 @@ describe("delivery context helpers", () => {
|
||||
});
|
||||
|
||||
it("builds stable keys only when channel and to are present", () => {
|
||||
expect(deliveryContextKey({ channel: "whatsapp", to: "+1555" })).toBe("whatsapp|+1555|");
|
||||
expect(deliveryContextKey({ channel: "whatsapp", to: "+1555" })).toBe("whatsapp|+1555||");
|
||||
expect(deliveryContextKey({ channel: "whatsapp" })).toBeUndefined();
|
||||
expect(deliveryContextKey({ channel: "whatsapp", to: "+1555", accountId: "acct-1" })).toBe(
|
||||
"whatsapp|+1555|acct-1",
|
||||
"whatsapp|+1555|acct-1|",
|
||||
);
|
||||
expect(deliveryContextKey({ channel: "slack", to: "channel:C1", threadId: "123.456" })).toBe(
|
||||
"slack|channel:C1||123.456",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,17 +67,24 @@ describe("delivery context helpers", () => {
|
||||
deliveryContextFromSession({
|
||||
channel: "telegram",
|
||||
lastTo: " 123 ",
|
||||
lastThreadId: " 999 ",
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
accountId: undefined,
|
||||
threadId: "999",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes delivery fields and mirrors them on session entries", () => {
|
||||
const normalized = normalizeSessionDeliveryFields({
|
||||
deliveryContext: { channel: " Slack ", to: " channel:1 ", accountId: " acct-2 " },
|
||||
deliveryContext: {
|
||||
channel: " Slack ",
|
||||
to: " channel:1 ",
|
||||
accountId: " acct-2 ",
|
||||
threadId: " 444 ",
|
||||
},
|
||||
lastChannel: " whatsapp ",
|
||||
lastTo: " +1555 ",
|
||||
});
|
||||
@@ -83,9 +93,11 @@ describe("delivery context helpers", () => {
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
accountId: "acct-2",
|
||||
threadId: "444",
|
||||
});
|
||||
expect(normalized.lastChannel).toBe("whatsapp");
|
||||
expect(normalized.lastTo).toBe("+1555");
|
||||
expect(normalized.lastAccountId).toBe("acct-2");
|
||||
expect(normalized.lastThreadId).toBe("444");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export type DeliveryContext = {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
export type DeliveryContextSessionSource = {
|
||||
@@ -12,6 +13,7 @@ export type DeliveryContextSessionSource = {
|
||||
lastChannel?: string;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
lastThreadId?: string | number;
|
||||
deliveryContext?: DeliveryContext;
|
||||
};
|
||||
|
||||
@@ -23,12 +25,22 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
|
||||
: undefined;
|
||||
const to = typeof context.to === "string" ? context.to.trim() : undefined;
|
||||
const accountId = normalizeAccountId(context.accountId);
|
||||
if (!channel && !to && !accountId) return undefined;
|
||||
return {
|
||||
const threadId =
|
||||
typeof context.threadId === "number" && Number.isFinite(context.threadId)
|
||||
? Math.trunc(context.threadId)
|
||||
: typeof context.threadId === "string"
|
||||
? context.threadId.trim()
|
||||
: undefined;
|
||||
const normalizedThreadId =
|
||||
typeof threadId === "string" ? (threadId ? threadId : undefined) : threadId;
|
||||
if (!channel && !to && !accountId && normalizedThreadId == null) return undefined;
|
||||
const normalized: DeliveryContext = {
|
||||
channel: channel || undefined,
|
||||
to: to || undefined,
|
||||
accountId,
|
||||
};
|
||||
if (normalizedThreadId != null) normalized.threadId = normalizedThreadId;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): {
|
||||
@@ -36,6 +48,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
|
||||
lastChannel?: string;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
lastThreadId?: string | number;
|
||||
} {
|
||||
if (!source) {
|
||||
return {
|
||||
@@ -43,6 +56,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
|
||||
lastChannel: undefined,
|
||||
lastTo: undefined,
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +65,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
|
||||
channel: source.lastChannel ?? source.channel,
|
||||
to: source.lastTo,
|
||||
accountId: source.lastAccountId,
|
||||
threadId: source.lastThreadId,
|
||||
}),
|
||||
normalizeDeliveryContext(source.deliveryContext),
|
||||
);
|
||||
@@ -61,6 +76,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
|
||||
lastChannel: undefined,
|
||||
lastTo: undefined,
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +85,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
|
||||
lastChannel: merged.channel,
|
||||
lastTo: merged.to,
|
||||
lastAccountId: merged.accountId,
|
||||
lastThreadId: merged.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,11 +107,14 @@ export function mergeDeliveryContext(
|
||||
channel: normalizedPrimary?.channel ?? normalizedFallback?.channel,
|
||||
to: normalizedPrimary?.to ?? normalizedFallback?.to,
|
||||
accountId: normalizedPrimary?.accountId ?? normalizedFallback?.accountId,
|
||||
threadId: normalizedPrimary?.threadId ?? normalizedFallback?.threadId,
|
||||
});
|
||||
}
|
||||
|
||||
export function deliveryContextKey(context?: DeliveryContext): string | undefined {
|
||||
const normalized = normalizeDeliveryContext(context);
|
||||
if (!normalized?.channel || !normalized?.to) return undefined;
|
||||
return `${normalized.channel}|${normalized.to}|${normalized.accountId ?? ""}`;
|
||||
const threadId =
|
||||
normalized.threadId != null && normalized.threadId !== "" ? String(normalized.threadId) : "";
|
||||
return `${normalized.channel}|${normalized.to}|${normalized.accountId ?? ""}|${threadId}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user