























11import { describe, expect, it } from "vitest";
22import { createQaBusState } from "./bus-state.js";
3+import { readQaScenarioById } from "./scenario-catalog.js";
34import { runScenarioFlow } from "./scenario-flow-runner.js";
456+type QaFlowStep = {
7+name: string;
8+run: () => Promise<string | void>;
9+};
10+11+function formatTestTranscript(state: ReturnType<typeof createQaBusState>) {
12+return state
13+.getSnapshot()
14+.messages.map((message) => `${message.direction}:${message.conversation.id}:${message.text}`)
15+.join("\n");
16+}
17+18+async function runLoadedScenarioFlow(
19+scenarioId: string,
20+params: {
21+onWaitForOutboundMessage?: (params: {
22+waitCount: number;
23+state: ReturnType<typeof createQaBusState>;
24+}) => void;
25+} = {},
26+) {
27+const scenario = readQaScenarioById(scenarioId);
28+const flow = scenario.execution.flow;
29+if (!flow) {
30+throw new Error(`scenario has no flow: ${scenarioId}`);
31+}
32+33+const state = createQaBusState();
34+let waitCount = 0;
35+const api = {
36+env: {},
37+ state,
38+ scenario,
39+config: scenario.execution.config ?? {},
40+randomUUID: () => "00000000-0000-4000-8000-000000000000",
41+liveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs,
42+waitForGatewayHealthy: async () => undefined,
43+waitForQaChannelReady: async () => undefined,
44+waitForNoOutbound: async () => undefined,
45+sleep: async () => undefined,
46+reset: async () => {
47+state.reset();
48+},
49+resetBus: async () => {
50+state.reset();
51+},
52+runAgentPrompt: async () => undefined,
53+formatTransportTranscript: formatTestTranscript,
54+waitForOutboundMessage: async (
55+stateLocal: ReturnType<typeof createQaBusState>,
56+predicate: (candidate: unknown) => boolean,
57+timeoutMs: number,
58+options?: { sinceIndex?: number },
59+) => {
60+waitCount += 1;
61+params.onWaitForOutboundMessage?.({ waitCount, state: stateLocal });
62+const match = stateLocal
63+.getSnapshot()
64+.messages.slice(options?.sinceIndex ?? 0)
65+.find((candidate) => predicate(candidate));
66+if (match) {
67+return match;
68+}
69+throw new Error(`timed out after ${timeoutMs}ms waiting for outbound marker`);
70+},
71+runScenario: async (_name: string, steps: QaFlowStep[]) => {
72+const stepResults = [];
73+for (const step of steps) {
74+const details = await step.run();
75+stepResults.push({
76+name: step.name,
77+status: "pass" as const,
78+ ...(details !== undefined ? { details } : {}),
79+});
80+}
81+return {
82+name: scenario.title,
83+status: "pass" as const,
84+steps: stepResults,
85+};
86+},
87+};
88+89+return await runScenarioFlow({
90+ api,
91+scenarioTitle: scenario.title,
92+ flow,
93+});
94+}
95+596describe("scenario-flow-runner", () => {
697it("supports qaImport inside flow expressions", async () => {
798const result = await runScenarioFlow({
@@ -221,4 +312,78 @@ describe("scenario-flow-runner", () => {
221312expect(result.status).toBe("pass");
222313expect(result.steps[0]?.details).toBe("QA_CODEX_PLUGIN_TURN_OK");
223314});
315+316+it.each([
317+{
318+scenarioId: "channel-chat-baseline",
319+to: "channel:qa-room",
320+text: "generic shared-channel reply without the required marker",
321+},
322+{
323+scenarioId: "dm-chat-baseline",
324+to: "dm:alice",
325+text: "generic DM reply without the required marker",
326+},
327+])("rejects unmarked outbound replies for $scenarioId", async ({ scenarioId, to, text }) => {
328+await expect(
329+runLoadedScenarioFlow(scenarioId, {
330+onWaitForOutboundMessage: ({ state }) => {
331+state.addOutboundMessage({
332+accountId: "qa-channel",
333+ to,
334+ text,
335+});
336+},
337+}),
338+).rejects.toThrow("waiting for outbound marker");
339+});
340+341+it("rejects reconnect follow-up replies that replay the first marker", async () => {
342+await expect(
343+runLoadedScenarioFlow("qa-channel-reconnect-dedupe", {
344+onWaitForOutboundMessage: ({ waitCount, state }) => {
345+if (waitCount === 1) {
346+state.addOutboundMessage({
347+accountId: "qa-channel",
348+to: "channel:qa-room",
349+text: "RECONNECT-FIRST-OK",
350+});
351+return;
352+}
353+state.addOutboundMessage({
354+accountId: "qa-channel",
355+to: "channel:qa-room",
356+text: "RECONNECT-FIRST-OK",
357+});
358+},
359+}),
360+).rejects.toThrow("waiting for outbound marker");
361+});
362+363+it("rejects reconnect follow-up turns with extra unmarked outbound replies", async () => {
364+await expect(
365+runLoadedScenarioFlow("qa-channel-reconnect-dedupe", {
366+onWaitForOutboundMessage: ({ waitCount, state }) => {
367+if (waitCount === 1) {
368+state.addOutboundMessage({
369+accountId: "qa-channel",
370+to: "channel:qa-room",
371+text: "RECONNECT-FIRST-OK",
372+});
373+return;
374+}
375+state.addOutboundMessage({
376+accountId: "qa-channel",
377+to: "channel:qa-room",
378+text: "RECONNECT-SECOND-OK",
379+});
380+state.addOutboundMessage({
381+accountId: "qa-channel",
382+to: "channel:qa-room",
383+text: "unmarked duplicate delivery",
384+});
385+},
386+}),
387+).rejects.toThrow("exactly one marked post-restart reply");
388+});
224389});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。