From 6ab9b01d8549bdac715fb56ae9ee43edf16cb1b4 Mon Sep 17 00:00:00 2001 From: CallTelemetry-Jason Date: Sun, 26 Jul 2026 22:04:15 -0500 Subject: [PATCH] fix: route agent session mentions with global regex --- dist/src/infra/shared-profiles.js | 2 ++ dist/src/pipeline/webhook.js | 8 ++++++-- src/infra/shared-profiles.test.ts | 4 ++++ src/infra/shared-profiles.ts | 3 ++- src/pipeline/webhook.test.ts | 7 +++++++ src/pipeline/webhook.ts | 8 ++++++-- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/dist/src/infra/shared-profiles.js b/dist/src/infra/shared-profiles.js index 029f4a6..d69955d 100644 --- a/dist/src/infra/shared-profiles.js +++ b/dist/src/infra/shared-profiles.js @@ -54,6 +54,8 @@ export function buildMentionPattern(profiles) { * belongs to. Returns { agentId, label } or null if no match. */ export function resolveAgentFromAlias(alias, profiles) { + if (typeof alias !== "string" || alias.length === 0) + return null; const lower = alias.toLowerCase(); for (const [agentId, profile] of Object.entries(profiles)) { if (profile.mentionAliases.some(a => a.toLowerCase() === lower)) { diff --git a/dist/src/pipeline/webhook.js b/dist/src/pipeline/webhook.js index b294c5d..63cdd61 100644 --- a/dist/src/pipeline/webhook.js +++ b/dist/src/pipeline/webhook.js @@ -366,7 +366,10 @@ export async function handleLinearWebhook(api, req, res) { mentionPattern.lastIndex = 0; const mentionMatch = text.match(mentionPattern); if (mentionMatch) { - const alias = mentionMatch[1]; + // buildMentionPattern is global, so String#match returns full matches + // rather than capture groups. Use the first full mention consistently + // with both global and non-global patterns. + const alias = mentionMatch[0]?.replace(/^@/, ""); const resolved = resolveAgentFromAlias(alias, profiles); if (resolved) { api.logger.info(`AgentSession routed to ${resolved.agentId} via @${alias} mention in ${text === userMessage ? "comment" : text === sessionPrompt ? "session prompt" : "promptContext"}`); @@ -673,7 +676,8 @@ export async function handleLinearWebhook(api, req, res) { if (promptedMentionPattern && userMessage) { const mentionMatch = userMessage.match(promptedMentionPattern); if (mentionMatch) { - const alias = mentionMatch[1]; + // A global mention regex returns full matches only, not capture groups. + const alias = mentionMatch[0]?.replace(/^@/, ""); const resolved = resolveAgentFromAlias(alias, promptedProfiles); if (resolved) { api.logger.info(`AgentSession prompted: routed to ${resolved.agentId} via @${alias} mention`); diff --git a/src/infra/shared-profiles.test.ts b/src/infra/shared-profiles.test.ts index 3505d69..5d06db1 100644 --- a/src/infra/shared-profiles.test.ts +++ b/src/infra/shared-profiles.test.ts @@ -234,6 +234,10 @@ describe("resolveAgentFromAlias", () => { const result = resolveAgentFromAlias("anything", {}); expect(result).toBeNull(); }); + + it.each([undefined, null, ""])("contains malformed or missing aliases without throwing", (alias) => { + expect(resolveAgentFromAlias(alias, loadAgentProfiles())).toBeNull(); + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/shared-profiles.ts b/src/infra/shared-profiles.ts index 2d790aa..8459546 100644 --- a/src/infra/shared-profiles.ts +++ b/src/infra/shared-profiles.ts @@ -73,9 +73,10 @@ export function buildMentionPattern(profiles: Record): Reg * belongs to. Returns { agentId, label } or null if no match. */ export function resolveAgentFromAlias( - alias: string, + alias: unknown, profiles: Record, ): { agentId: string; label: string } | null { + if (typeof alias !== "string" || alias.length === 0) return null; const lower = alias.toLowerCase(); for (const [agentId, profile] of Object.entries(profiles)) { if (profile.mentionAliases.some(a => a.toLowerCase() === lower)) { diff --git a/src/pipeline/webhook.test.ts b/src/pipeline/webhook.test.ts index f4c7a02..b724f00 100644 --- a/src/pipeline/webhook.test.ts +++ b/src/pipeline/webhook.test.ts @@ -804,6 +804,9 @@ describe("AgentSessionEvent.created full flow", () => { }); it("routes to mentioned agent when @mention is present", async () => { + // Match the production pattern: /g makes String#match return full matches, + // not capture groups. + buildMentionPatternMock.mockReturnValue(/@(mal|mason|kaylee|eureka)/gi); resolveAgentFromAliasMock.mockReturnValue({ agentId: "kaylee", profile: { label: "Kaylee" } }); const result = await postWebhook({ @@ -820,6 +823,7 @@ describe("AgentSessionEvent.created full flow", () => { expect(result.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); + expect(resolveAgentFromAliasMock).toHaveBeenCalledWith("kaylee", expect.any(Object)); const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]); expect(infoCalls.some((msg: string) => msg.includes("routed to kaylee"))).toBe(true); }); @@ -1048,6 +1052,8 @@ describe("AgentSessionEvent.prompted full flow", () => { }); it("routes to mentioned agent in prompted follow-up", async () => { + // AgentSession.prompted uses the same global pattern as production. + buildMentionPatternMock.mockReturnValue(/@(mal|mason|kaylee|eureka)/gi); resolveAgentFromAliasMock.mockReturnValue({ agentId: "kaylee", profile: { label: "Kaylee" } }); const result = await postWebhook({ @@ -1063,6 +1069,7 @@ describe("AgentSessionEvent.prompted full flow", () => { expect(result.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); + expect(resolveAgentFromAliasMock).toHaveBeenCalledWith("kaylee", expect.any(Object)); const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]); expect(infoCalls.some((msg: string) => msg.includes("routed to kaylee"))).toBe(true); }); diff --git a/src/pipeline/webhook.ts b/src/pipeline/webhook.ts index daa99a1..17221e0 100644 --- a/src/pipeline/webhook.ts +++ b/src/pipeline/webhook.ts @@ -423,7 +423,10 @@ export async function handleLinearWebhook( mentionPattern.lastIndex = 0; const mentionMatch = text.match(mentionPattern); if (mentionMatch) { - const alias = mentionMatch[1]; + // buildMentionPattern is global, so String#match returns full matches + // rather than capture groups. Use the first full mention consistently + // with both global and non-global patterns. + const alias = mentionMatch[0]?.replace(/^@/, ""); const resolved = resolveAgentFromAlias(alias, profiles); if (resolved) { api.logger.info(`AgentSession routed to ${resolved.agentId} via @${alias} mention in ${text === userMessage ? "comment" : text === sessionPrompt ? "session prompt" : "promptContext"}`); @@ -764,7 +767,8 @@ export async function handleLinearWebhook( if (promptedMentionPattern && userMessage) { const mentionMatch = userMessage.match(promptedMentionPattern); if (mentionMatch) { - const alias = mentionMatch[1]; + // A global mention regex returns full matches only, not capture groups. + const alias = mentionMatch[0]?.replace(/^@/, ""); const resolved = resolveAgentFromAlias(alias, promptedProfiles); if (resolved) { api.logger.info(`AgentSession prompted: routed to ${resolved.agentId} via @${alias} mention`);