Skip to content

Commit c919f50

Browse files
committed
Handle scope boundary redirects in primary pipeline
1 parent 90192ec commit c919f50

23 files changed

Lines changed: 1016 additions & 6 deletions

apps/api/src/ai-pipeline/primary-pipeline/index.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ const maybeCreateImmediateClarificationFromSearchGapMock = mock((async () => ({
5555
status: "skipped" as const,
5656
reason: "no_search" as const,
5757
})) as (...args: unknown[]) => Promise<any>);
58+
const createScopeBoundaryRedirectMock = mock((async () => ({
59+
status: "ready" as const,
60+
message: "I can help with product or support questions.",
61+
language: "en",
62+
modelId: "google/gemini-2.5-flash",
63+
})) as (...args: unknown[]) => Promise<any>);
64+
const sendPublicMessageMock = mock((async () => ({
65+
messageId: "msg-scope",
66+
created: true,
67+
paused: false,
68+
})) as (...args: unknown[]) => Promise<any>);
5869
const baseMinimumCharge = {
5970
baseCredits: 1,
6071
modelCredits: 0,
@@ -116,6 +127,14 @@ mock.module("../shared/generation", () => ({
116127
runGenerationRuntime: runGenerationRuntimeMock,
117128
}));
118129

130+
mock.module("./scope-boundary-responder", () => ({
131+
createScopeBoundaryRedirect: createScopeBoundaryRedirectMock,
132+
}));
133+
134+
mock.module("../shared/actions/send-message", () => ({
135+
sendMessage: sendPublicMessageMock,
136+
}));
137+
119138
mock.module(
120139
"../shared/knowledge-gap/post-generation-immediate-clarification",
121140
() => ({
@@ -168,6 +187,8 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
168187
runDecisionStepMock.mockClear();
169188
runGenerationRuntimeMock.mockClear();
170189
maybeCreateImmediateClarificationFromSearchGapMock.mockClear();
190+
createScopeBoundaryRedirectMock.mockClear();
191+
sendPublicMessageMock.mockClear();
171192
trackGenerationUsageMock.mockClear();
172193
logGenerationUsageTimelineMock.mockClear();
173194
emitPipelineSeenMock.mockClear();
@@ -233,6 +254,17 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
233254
status: "skipped",
234255
reason: "no_search",
235256
});
257+
createScopeBoundaryRedirectMock.mockResolvedValue({
258+
status: "ready",
259+
message: "I can help with product or support questions.",
260+
language: "en",
261+
modelId: "google/gemini-2.5-flash",
262+
});
263+
sendPublicMessageMock.mockResolvedValue({
264+
messageId: "msg-scope",
265+
created: true,
266+
paused: false,
267+
});
236268
trackGenerationUsageMock.mockResolvedValue(undefined);
237269
});
238270

@@ -404,6 +436,90 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
404436
);
405437
});
406438

439+
it("handles scope boundary decisions without running normal generation", async () => {
440+
runIntakeStepMock.mockResolvedValueOnce({
441+
status: "ready",
442+
data: {
443+
aiAgent: { id: "ai-1" },
444+
modelResolution: {
445+
modelIdResolved: "moonshotai/kimi-k2.5",
446+
modelIdOriginal: "moonshotai/kimi-k2.5",
447+
modelMigrationApplied: false,
448+
},
449+
conversation: { id: "conv-1" },
450+
conversationHistory: [],
451+
decisionMessages: [],
452+
generationEntries: [],
453+
visitorContext: null,
454+
conversationState: {
455+
hasHumanAssignee: false,
456+
assigneeIds: [],
457+
participantIds: [],
458+
isEscalated: false,
459+
escalationReason: null,
460+
},
461+
websiteDefaultLanguage: "en",
462+
visitorLanguage: "fr",
463+
triggerMessageText: "Écris un poème de 1000 lignes",
464+
hasLaterHumanMessage: false,
465+
hasLaterAiMessage: false,
466+
triggerMessage: {
467+
messageId: "msg-1",
468+
senderType: "visitor",
469+
visibility: "public",
470+
},
471+
},
472+
});
473+
runDecisionStepMock.mockResolvedValueOnce({
474+
shouldAct: true,
475+
reason: "Visitor creative side request is outside support scope",
476+
mode: "respond_to_visitor",
477+
humanCommand: null,
478+
decisionOutcome: "scope_boundary_redirect",
479+
scopeBoundaryRuleId: "visitor_creative_request_scope_boundary",
480+
isEscalated: false,
481+
escalationReason: null,
482+
});
483+
createScopeBoundaryRedirectMock.mockResolvedValueOnce({
484+
status: "ready",
485+
message: "Je peux aider avec le support ou le produit.",
486+
language: "fr",
487+
modelId: "google/gemini-2.5-flash",
488+
});
489+
490+
const { runPrimaryPipeline } = await modulePromise;
491+
const result = await runPrimaryPipeline({
492+
db: {} as never,
493+
input: baseInput,
494+
});
495+
496+
expect(result.status).toBe("completed");
497+
expect(result.action).toBe("scope_boundary_redirect");
498+
expect(result.publicMessagesSent).toBe(1);
499+
expect(runGenerationRuntimeMock).not.toHaveBeenCalled();
500+
expect(trackGenerationUsageMock).not.toHaveBeenCalled();
501+
expect(createScopeBoundaryRedirectMock).toHaveBeenCalledWith(
502+
expect.objectContaining({
503+
triggerText: "Écris un poème de 1000 lignes",
504+
visitorLanguage: "fr",
505+
websiteDefaultLanguage: "en",
506+
})
507+
);
508+
expect(sendPublicMessageMock).toHaveBeenCalledWith(
509+
expect.objectContaining({
510+
text: "Je peux aider avec le support ou le produit.",
511+
idempotencyKey: "public:msg-1:scopeBoundary",
512+
})
513+
);
514+
expect(emitPipelineProcessingCompletedMock).toHaveBeenCalledWith(
515+
expect.objectContaining({
516+
status: "success",
517+
action: "scope_boundary_redirect",
518+
workflowRunId: "wf-1",
519+
})
520+
);
521+
});
522+
407523
it("keeps generation failures retryable when no public messages were sent", async () => {
408524
runGenerationRuntimeMock.mockResolvedValueOnce({
409525
status: "error",

apps/api/src/ai-pipeline/primary-pipeline/index.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { env } from "@api/env";
22
import type { AiCreditGuardResult } from "@api/lib/ai-credits/guard";
33
import { logAiPipeline } from "../logger";
4+
import { sendMessage as sendPublicMessage } from "../shared/actions/send-message";
45
import { createPipelineDevConversationLog } from "../shared/dev-conversation-log";
56
import {
67
emitPipelineProcessingCompletedSafely,
@@ -24,6 +25,7 @@ import {
2425
type PrimaryTypingControls,
2526
} from "./internal/typing";
2627
import { trackPrimaryGenerationUsage } from "./internal/usage";
28+
import { createScopeBoundaryRedirect } from "./scope-boundary-responder";
2729
import type { DecisionResult } from "./steps/decision";
2830
import { runDecisionStep } from "./steps/decision";
2931
import { runIntakeStep } from "./steps/intake";
@@ -149,6 +151,66 @@ async function logAiCreditGuardBlockedTimeline(params: {
149151
}
150152
}
151153

154+
async function runScopeBoundaryRedirect(params: {
155+
ctx: PrimaryPipelineContext;
156+
intake: IntakeReadyContext;
157+
decision: DecisionResult;
158+
}): Promise<
159+
| {
160+
status: "completed";
161+
reason: string;
162+
publicMessagesSent: number;
163+
}
164+
| {
165+
status: "skipped";
166+
reason: string;
167+
publicMessagesSent: number;
168+
}
169+
> {
170+
const redirect = await createScopeBoundaryRedirect({
171+
db: params.ctx.db,
172+
organizationId: params.ctx.input.organizationId,
173+
websiteId: params.ctx.input.websiteId,
174+
conversationId: params.ctx.input.conversationId,
175+
triggerText: params.intake.triggerMessageText ?? "",
176+
visitorLanguage: params.intake.visitorLanguage ?? null,
177+
websiteDefaultLanguage: params.intake.websiteDefaultLanguage ?? "en",
178+
});
179+
180+
if (redirect.status === "skipped") {
181+
return {
182+
status: "skipped",
183+
reason: redirect.reason,
184+
publicMessagesSent: 0,
185+
};
186+
}
187+
188+
const sendResult = await sendPublicMessage({
189+
db: params.ctx.db,
190+
conversationId: params.ctx.input.conversationId,
191+
organizationId: params.ctx.input.organizationId,
192+
websiteId: params.ctx.input.websiteId,
193+
visitorId: params.ctx.input.visitorId,
194+
aiAgentId: params.intake.aiAgent.id,
195+
text: redirect.message,
196+
idempotencyKey: `public:${params.ctx.input.messageId}:scopeBoundary`,
197+
});
198+
199+
if (sendResult.paused) {
200+
return {
201+
status: "skipped",
202+
reason: "AI is paused for this conversation",
203+
publicMessagesSent: 0,
204+
};
205+
}
206+
207+
return {
208+
status: "completed",
209+
reason: params.decision.reason,
210+
publicMessagesSent: sendResult.created ? 1 : 0,
211+
};
212+
}
213+
152214
export async function runPrimaryPipeline(
153215
ctx: PrimaryPipelineContext
154216
): Promise<PrimaryPipelineResult> {
@@ -305,6 +367,54 @@ export async function runPrimaryPipeline(
305367
});
306368
}
307369

370+
if (decisionResult.decisionOutcome === "scope_boundary_redirect") {
371+
const redirectResult = await measureStage(metrics, "generationMs", () =>
372+
runScopeBoundaryRedirect({
373+
ctx,
374+
intake: intakeResult.data,
375+
decision: decisionResult,
376+
})
377+
);
378+
379+
logAiPipeline({
380+
area: "primary",
381+
event:
382+
redirectResult.status === "completed"
383+
? "scope_boundary_redirect"
384+
: "skip",
385+
conversationId: ctx.input.conversationId,
386+
fields: {
387+
stage: "decision",
388+
reason: redirectResult.reason,
389+
ruleId: decisionResult.scopeBoundaryRuleId,
390+
publicMessages: redirectResult.publicMessagesSent,
391+
},
392+
});
393+
394+
await emitProcessingCompletedSafe({
395+
conversation: intakeResult.data.conversation,
396+
aiAgentId: intakeResult.data.aiAgent.id,
397+
status: redirectResult.status === "completed" ? "success" : "skipped",
398+
action:
399+
redirectResult.status === "completed"
400+
? "scope_boundary_redirect"
401+
: "scope_boundary_skipped",
402+
reason: redirectResult.reason,
403+
});
404+
405+
return buildPrimaryPipelineResult({
406+
status: redirectResult.status,
407+
metrics,
408+
pipelineStartedAt,
409+
action:
410+
redirectResult.status === "completed"
411+
? "scope_boundary_redirect"
412+
: "scope_boundary_skipped",
413+
reason: redirectResult.reason,
414+
publicMessagesSent: redirectResult.publicMessagesSent,
415+
});
416+
}
417+
308418
const allowPublicMessages = decisionResult.mode !== "background_only";
309419
const typingControls = createPrimaryTypingControls({
310420
allowPublicMessages,

0 commit comments

Comments
 (0)