fix(consult): 申报时段计算改为服务端预跑并走同请求缓存(BUG-957)

窗口计算挂在 agent context 缓存上,模型开口前预跑并注入 packet;工具再调用命中同请求缓存,成功次数仍为 1。
This commit is contained in:
jesse-ux
2026-09-18 18:45:19 +08:00
parent 5e8b2ac112
commit 53d78137ff
10 changed files with 523 additions and 155 deletions
@@ -21,6 +21,9 @@ import {
createConsultationRuntimeHooks,
createConsultationTools,
createConsultationRuntimeState,
createWindowConsultationAgentContext,
createWindowConsultationTools,
precomputeWindowConsultation,
domainFitsRunBudget,
executableDomainPlan,
publicConsultationRuntimeSteps,
@@ -41,6 +44,7 @@ import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/l
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
collectAgentPublicEvents,
consultationPublicActivityEvent,
CONTRACT_DEGRADED_NOTE,
streamAgentResponse,
} from "../src/lib/stream-agent-response.ts";
@@ -1482,6 +1486,216 @@ test("degraded delivery does not start a compose pass (BUG-961)", async () => {
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
const declaredWindowFixture = {
name: "测试",
toolInput: {
year: 1997,
month: 8,
day: 8,
city: "邯郸",
lat: 36.4,
lon: 114.2,
tz: 8,
ayanamsa: "raman" as const,
rangeStart: "14:00",
rangeEnd: "18:00",
},
truth: {
birthDate: "1997-08-08",
birthTimeSource: "period_only" as const,
birthTimePeriod: "afternoon" as const,
birthTimeStatus: "reported" as const,
wrapsMidnight: false,
placeLabel: "邯郸",
placeCodes: { countryCode: "CN", provinceCode: null, cityCode: null, districtCode: null },
placeId: null,
placeType: null,
placeProvider: null,
timezoneId: null,
timezoneSource: null,
latitude: 36.4,
longitude: 114.2,
timezoneOffset: 8,
},
};
function windowPacket() {
return {
declared_range: { start: "14:00", end: "18:00", wraps_midnight: false },
probe_count: 3,
probes: [
{ clock: "14:00", role: "range_start" as const },
{ clock: "16:00", role: "interior" as const },
{ clock: "18:00", role: "range_end" as const },
],
stable_layers: { planet_signs: { sun: "Cancer" } },
varying_layers: { ascendant_signs: ["Libra", "Scorpio"] },
blocked_layers: ["precise-timing"],
answer_policy: {
can_answer_direction: true,
can_answer_precise_timing: false as const,
birth_time_confidence: "declared_window" as const,
candidate_is_confirmed: false as const,
},
result_hash: "fictional-window",
};
}
function makeWindowCtx(options: { fetchWindowChart?: () => Promise<ReturnType<typeof windowPacket>> } = {}) {
const state = createConsultationRuntimeState();
const ctx = createWindowConsultationAgentContext({
userId: "u",
sessionId: "s",
requestId: "r-window-precompute",
consultationMode: "declared_birth_window",
declaredWindow: declaredWindowFixture,
state,
fetchWindowChart: options.fetchWindowChart ?? (async () => windowPacket()),
runRangeReading: async () => {
throw new Error("range-reading-unused-in-this-test");
},
});
return { ctx, state, tools: createWindowConsultationTools(ctx) };
}
async function writerToSend(send: (event: { type: "activity"; phase: "chart-calculation" | "evidence-validation" | "loading-method" | "answer-composition"; label: string }) => void) {
return {
custom: async (chunk: unknown) => {
if (!chunk || typeof chunk !== "object") return;
const value = chunk as { type?: unknown; data?: unknown };
if (value.type !== "data-jyotish-activity") return;
const event = consultationPublicActivityEvent(value.data);
if (event) send(event);
},
};
}
test("window precompute greens the contract without a model tool call (BUG-957)", async () => {
const { ctx, state } = makeWindowCtx();
async function* chunks() {
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, requireTool: true,
pass4Mode: "declared_birth_window",
warmup: async (send) => {
await precomputeWindowConsultation(ctx, {
question: "未来半年事业如何",
writer: await writerToSend(send),
});
},
stream: chunks(),
toolStatus: () => "degraded",
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.match(answer, /方向上可以推进/);
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), false);
assert.equal(state.consultationToolSuccessCount, 1);
assert.equal(state.consultationToolCompleted, true);
assert.ok(state.steps.some((step) =>
step.kind === "tool" && step.name === "run-jyotish-window-consultation" && step.status === "completed"));
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
assert.ok(events.some((event) => {
const item = event as { type?: string; phase?: string };
return item.type === "activity" && item.phase === "chart-calculation";
}));
});
test("window precompute and a later tool call share one cache (BUG-957)", async () => {
let fetches = 0;
const { ctx, state, tools } = makeWindowCtx({
fetchWindowChart: async () => {
fetches += 1;
return windowPacket();
},
});
await precomputeWindowConsultation(ctx, { question: "事业如何" });
const first = await tools["run-jyotish-window-consultation"].execute!(
{ question: "事业如何" },
toolContext,
);
const reconstructed = createWindowConsultationTools(ctx);
const second = await reconstructed["run-jyotish-window-consultation"].execute!(
{ question: "再问一次" },
toolContext,
);
assert.equal(fetches, 1);
assert.equal(state.consultationToolCallCount, 1);
assert.equal(state.consultationToolSuccessCount, 1);
assert.deepEqual(first, second);
});
test("window precompute failure still degrades when the model writes without a tool (BUG-957)", async () => {
const { ctx, state } = makeWindowCtx({
fetchWindowChart: async () => {
throw new Error("window_unavailable");
},
});
async function* chunks() {
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, requireTool: true,
pass4Mode: "declared_birth_window",
warmup: async () => {
await precomputeWindowConsultation(ctx, { question: "事业如何" }).catch(() => {});
},
stream: chunks(),
toolStatus: () => "blocked",
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.match(answer, /方向上可以推进/);
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true);
assert.equal(state.consultationToolSuccessCount, 0);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
test("warmup runs after skill events and before the model stream (BUG-957)", async () => {
const state = toolOnlyRunState();
const order: string[] = [];
async function* chunks() {
order.push("stream");
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, requireTool: true,
warmup: async (send) => {
order.push("warmup");
send({ type: "activity", phase: "chart-calculation", label: "正在比较声明出生窗口内的稳定层" });
},
stream: async () => {
order.push("open");
return chunks();
},
toolStatus: () => "ready",
receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.deepEqual(order, ["warmup", "open", "stream"]);
const types = events.map((event) => (event as { type?: string }).type);
const skillIdx = types.lastIndexOf("skill.completed");
const activityIdx = types.findIndex((type, index) =>
type === "activity" && index > skillIdx && (events[index] as { phase?: string }).phase === "chart-calculation");
const answerIdx = types.indexOf("answer.delta");
assert.ok(skillIdx >= 0 && activityIdx > skillIdx && answerIdx > activityIdx);
});
test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => {
const bindingState = createConsultationRuntimeState();
let bindingError = "";
@@ -347,3 +347,28 @@ test("agentic consultation is the safe default and legacy is explicit rollback",
assert.match(route, /mode === "legacy"/);
assert.match(route, /mode !== "canary"/);
});
test("window calculation is precomputed on the agent context cache (BUG-957)", () => {
const windowFactory = tools.slice(tools.indexOf("export function createWindowConsultationTools"));
const natalFactory = tools.slice(
tools.indexOf("export function createConsultationTools"),
tools.indexOf("export async function precomputeWindowConsultation"),
);
assert.match(tools, /export async function precomputeWindowConsultation/);
assert.match(tools, /calculationCache/);
assert.doesNotMatch(windowFactory, /let calculation/);
assert.match(natalFactory, /let calculation/);
assert.match(mastra, /tools: createWindowConsultationTools\(context\)/);
assert.match(mastra, /If this turn already includes a server-owned window packet/);
const windowBranch = route.slice(
route.indexOf("if (shouldRunDeclaredWindowWorkflow"),
route.indexOf("if (!prepared.serverChart)"),
);
const natalBranch = route.slice(route.indexOf("if (!prepared.serverChart)"));
assert.match(windowBranch, /precomputeWindowConsultation/);
assert.match(windowBranch, /warmup:/);
assert.match(windowBranch, /windowPrecomputedPacketMessage/);
assert.doesNotMatch(windowBranch, /toolChoice:\s*"required"/);
assert.doesNotMatch(natalBranch, /precomputeWindowConsultation/);
assert.doesNotMatch(natalBranch, /warmup:/);
});