merge origin/staging into frontend dead code (issue #63)
Independent Staging Quality Gate / validate (push) Failing after 7m39s
Independent Staging Quality Gate / publish (push) Skipped

Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
Claude
2026-10-14 11:20:59 +08:00
24 changed files with 37342 additions and 18192 deletions
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
- **本机 Postgres**:没有量 `getUser`、注销检查、读会话、按版本读模型各 30 次的中位数。因此不能判断首帧是否真的快了两成,也不能判断模型准备是否快了一半。两个相关开关保持关闭(并行关、版本缓存 0 毫秒)。
- **多领域并发**:没有拿带星盘网络库的镜像重跑。任务书里的起草试算大约只快一成,不到两成五的开启线。`CONSULT_DOMAIN_CONCURRENCY` 保持 1。
- **staging 前后对比**:要等这支分支部署之后才能看真实首行时间。本轮不改默认值。
## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12)
- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。
+36072 -18020
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
# PROGRESS · 发问准备并行(2026-10-14)
- 分支:`codex/consult-prep-parallel-20261013`
- 基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 的后代;计划评论里的 `2994588ad` 更早,不因此停工)
- 个人长报告写作开关仍是关闭,本轮没有改报告章节
- 编号:本分支上的 `docs/BUG_HISTORY.md` 最大号是 BUG-1322。BUG-1323~1336 被其它未合并任务书占用。本单用 BUG-1337~1339
## 开关
三个都保持代码里的默认,没有单独提交去打开:
| 开关 | 默认 | 原因 |
| --- | --- | --- |
| `CONSULT_PREP_PARALLEL` | 关 | 没有本机 Postgres 中位数,不满足首帧快两成 |
| `MODEL_VERSION_CACHE_TTL_MS` | 0 | 没有量到「准备模型」快一半 |
| `CONSULT_DOMAIN_CONCURRENCY` | 1 | 没有带星盘网络库的镜像重测;起草试算预计达不到快两成五 |
因此没有写 `CHANGELOG.md`,也没有改 `frontend/DESIGN.md`。
## 做了什么
- T1:开流前的登录、注销检查、请求体、会话、模型收进 `loadConsultTurnHead`。并行时注销检查和后面三条同时开始,失败仍按旧的先后返回。423 仍在任何扣点之前。
- T2:并行打开时,审核在开流前启动、在扣点前等待。拦住时不扣点,并且比资料错误优先。关掉时仍是开流前等待审核。
- T3:按模型和版本缓存成功的解析,最长 15 秒;失败和空结果不缓存;管理端清目录缓存时一并清掉。地址检查函数本身没改。已发布版本的供应商代码、类型和地址不可变,但启用开关和密钥仍可变,所以不能超过目录缓存的 15 秒。
- T4:问题保存成功后立刻读标题,和分类同时进行,用到标题时再等。分类仍在扣点和保存之后。
- T5:领域并发开关。为 1 时仍是原来的逐个循环,含时间预算。为 2 时一起算,结果按计划顺序组装,不再逐个套时间预算,领域个数上限不变。
- T6:准备阶段记一条 `consultation-prep-v1`,只有阶段名、毫秒和状态,没有问题原文。
- T7:见 `docs/testing/consult-prep-parallel-ab-20261013.md`。假时钟并行 160、串行 210。这不是开启依据。
- 测试注入:模型目录增加 `setModelCatalogIoForTests`,只给测试替换查库、解密和地址检查。生产路径仍调用原来的函数。
## 改过的既有断言
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `content-moderation.test.ts` | 源码含 `await moderate(visibleQuestion, "input"` 且早于 `reserve_consultation_usage` | `await settleInputModeration()` 早于扣点;准备服务里 `beforeReserve` 早于 `input.reserve()`;拦住时扣点次数是 0 | 审核挪到流内、扣点前。关掉开关时开流前的等待还在 |
| `model-catalog.test.ts` | `return await readVersion(modelId, configVersion)` | `const model = await readVersion(...)`,只有非空才写入短缓存 | 绑版本的解析可以缓存,失败和空结果不写 |
| `model-catalog.test.ts` | 咨询路由里 `if (!sessionModel)` 后面是 503 | 准备函数里 `if (!sessionModel)` 返回 `modelUnavailable()`,状态 503 | 模型不可用仍是开流前的 503,文案随准备步骤搬走 |
| `consult-first-frame-and-pacing-20260928.test.tsx` | 401、请求体、会话不存在、会话类型、模型已变这些字面量在路由的 `runTurn` 之前 | 字面量在 `turn-prep.ts`;路由在 `runTurn` 之前调用 `loadConsultTurnHead` 并返回失败 | 开流前的判断收进同一个函数,状态码仍在开流前 |
| `rectification-session-composer-guard.test.ts` | 咨询路由里会话缺失返回 404,类型不对返回 `session_not_consultation` | 同样两句在 `turn-prep.ts` | 会话判断搬走,文案和状态码没变 |
`consultation-agentic-runtime.test.ts` 的领域循环帮助函数现在也接受异步的假计算,以便断言「后完成的领域仍排在计划后面」。原有断言没有改松。
## 并发为 2 时的时间预算
领域数仍先被原来的上限截断(现在是 2)。开关为 2 时,这两个领域一起开始,不再逐个问「剩下的时间还够不够下一个」。开关为 1 时,原来的逐个判断还在。
## 自测
test:db 未跑:本轮没有改查询语句,任务书也不要求。前端全量和 Python 快速门留给验收。`npm run lint` 0 error,原有 warning 没动。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 0 错 |
| 规范 | `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
| 准备与审核 | `tsx --test` 下列文件:`consult-turn-prep-20261013`、`consult-prep-gate-20261013`、`content-moderation`、`model-catalog`、`consult-first-frame-and-pacing-20260928`、`rectification-session-composer-guard` | 准备函数 3 通过;门禁文件 5 通过;其余在同一次 41 例里通过(当时缓存测试还在改,后来单文件 5 通过、失败 0、取消 0) |
| 计费、注销、标题、寒暄、领域 | 同上命令:`application-billing-contract`、`account-deletion`、`session-title-persist-contract`、`consultation-smalltalk`、`consultation-agentic-runtime` | 136 通过,失败 0,取消 0 |
| 假时钟 | `consult-prep-parallel-ab.mts` | 并行 160,串行 210,同时步数 2 对 1 |
| 领域镜像 | `consult-domain-concurrency-ab.py` | 退出 0,写明没有跑镜像 |
## 第 1 次打回
合并最新 `origin/staging`。业务代码没改,三个开关默认值没改。
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-session-authority.test.ts` | 在 `route.ts` 里找 `if (!chatSession)` 后的 404 | 在 `turn-prep.ts` 里锁 `sessionMissing()` 的 404,和分开的 `notConsultation()` 409 | 判定搬走了。仍不许并成一个判断 |
| `consultation-workflow-contract.test.ts` | `beforeReserve: ({ consultationMode }) => createConsultationPlan({`,以及 `reserve: () => reserveConsultationModel(` | `beforeReserve` 的 async 函数体里调用 `createConsultationPlan({`,并且早于 `reserve`;`reserve` 的 async 函数体里调用 `reserveConsultationModel(` | 改成了 async。没有放宽成「文件里出现过函数名」 |
| `consult-prep-parallel-ab.mts` | `frontend/scripts/research/`,从 `frontend/tests` 导入 | `frontend/tests/consult-prep-parallel-ab.mts` | 镜像会复制 scripts、不复制 tests。另一个新增的领域脚本是 Python,不导入测试目录 |
这次自测:上述合同测试加出生时间模式、镜像导入检查,40 通过,失败 0,取消 0。假时钟脚本挪走后仍是并行 160、串行 210。`tsc --noEmit` 0 错。`npm run lint` 0 error,125 条原有 warning。全量 `npm test` 留给验收。`git diff a0e223e2` 到本修复会带上合并进来的测试环境前端文件;相对那次合并的 `origin/staging`,`frontend/src` 没有新增改动。
+2 -2
View File
@@ -475,8 +475,8 @@
| `TASK-api-disk-cache-retention-20261013.md` | `PROGRESS-api-disk-cache-retention-20261013.md` | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待验收 | `codex/api-disk-cache-retention-20261013` |
| `TASK-report-reference-perf-20261013.md` | `PROGRESS-report-reference-perf-20261013.md` | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-report-reference-perf-fix-20261014.md` | `PROGRESS-report-reference-perf-20261013.md` | **#61 第 1 次打回**:`69c739fb` 本单全过;与清理规则合并后,长报告缓存目录补进自行清理名单 | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | — | **#62 第 1 次打回**:`a0e223e2` 业务逻辑通过;两条源码文本断言未跟改(§7.8)、A/B 脚本从 `frontend/tests` 导入(BUG-1140) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | `PROGRESS-consult-prep-parallel-20261013.md` | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
@@ -0,0 +1,36 @@
# 发问准备并行:本轮量到的和没量到的(2026-10-14)
开关都还关着。用户发问后的第一行,这轮不会变快。
## 进程内假时钟
命令:`node node_modules/tsx/dist/cli.mjs tests/consult-prep-parallel-ab.mts`(在 `frontend` 目录)。
假延迟:注销检查 50 毫秒,读会话 80 毫秒,解析模型 80 毫秒。这不是数据库实测。
| | 逻辑耗时 | 同时进行的步数 |
| --- | --- | --- |
| 串行 | 210 | 1 |
| 并行 | 160 | 2 |
并行比这三条假延迟的相加更短。它不能代替「首帧中位数下降两成」的开启条件,因为本机没有量 Postgres。
审核密钥不在这台机器上。测试里把审核挂起 1500 毫秒,第一小段在 400 毫秒内已经写出,然后才收到拦截。这是假等待,不是供应商的真实时间。
## 版本缓存
行为测试:15 秒内第二次解析不再查库、不再做地址检查;清缓存后会再查;缓存关掉后每次都查;查库失败和空结果不会被记住。没有量到真实「准备模型」那一段快了多少,所以缓存时间保持 0。
## 两个领域一起算
命令:`.venv\Scripts\python.exe frontend\scripts\research\consult-domain-concurrency-ab.py`
本轮没有启动计算服务,也没有带星盘网络库的镜像。任务书起草时的试算大约只快一成,热缓存时反而更慢,达不到快两成五。默认仍是一个一个算。
## 仍然关闭的原因
| 开关 | 现在 | 要打开还缺什么 |
| --- | --- | --- |
| 发问准备并行 | 关 | 本机 Postgres 上鉴权、注销检查、读会话、读模型各 30 次的中位数,并且首帧中位数至少快两成 |
| 模型版本缓存 | 0 毫秒 | 同上,准备模型那一段至少快一半 |
| 两个领域一起算 | 1 | 带星盘网络库的镜像上墙钟至少快两成五,争用时 429 不增加,响应除约定字段外一致 |
@@ -0,0 +1,24 @@
"""Two-domain concurrency bench.
The vedastro image and the local API were not started in this round.
The task book's draft on one public chart was about 10 percent faster when
cold and slower when the chart was already cached. The switch opens only
when the vedastro image is at least 25 percent faster, contention 429s do
not rise, and the byte diff matches. Default stays 1.
"""
from __future__ import annotations
def main() -> int:
print("CONSULT_DOMAIN_CONCURRENCY default stays 1")
print("vedastro image bench: not run")
print("local API bench: not run")
print("contention 429 count: not run")
print("byte diff: not run")
print("reason: this round has no vedastro measurement; the published draft missed the 25 percent gate")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+158 -88
View File
@@ -10,7 +10,15 @@ import {
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { consultDomainConcurrency, consultPrepParallelEnabled, modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import {
ConsultationInputBlockedError,
consultPrepObservabilityEvent,
type ConsultPrepPhase,
} from "@/lib/consult/prep-log";
import { loadConsultTurnHead } from "@/lib/consult/turn-prep";
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
import type { ModerationResult } from "@/lib/moderation";
import { recordModerationEvent } from "@/lib/moderation/log";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
@@ -304,78 +312,43 @@ export async function POST(request: Request) {
return jsonForSupabaseSetupFailure(error, "POST /api/consult");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
const prepParallel = consultPrepParallelEnabled();
const prepPhases: ConsultPrepPhase[] = [];
const head = await loadConsultTurnHead({
parallel: prepParallel,
getUser: () => supabase.auth.getUser(),
readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId),
readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)),
readSession: async (userId, parsed) => await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.sessionId)
.eq("user_id", userId)
.maybeSingle(),
resolveModel: async (chatSession) => {
if (!chatSession.model_id) return null;
return await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
},
});
if (!head.ok) {
// A pending account deletion freezes the account: nothing new is charged.
const frozen = head.frozen ? head.response : null;
if (frozen) return frozen;
return head.response;
}
// A pending account deletion freezes the account: nothing new is charged.
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
const { user, parsed, chatSession, sessionModel } = head;
prepPhases.push(
{ phase: "prep.auth", durationMs: head.timings.authMs, status: "completed" },
{ phase: "prep.deletion", durationMs: head.timings.deletionMs, status: "completed" },
{ phase: "prep.session", durationMs: head.timings.sessionMs, status: "completed" },
{ phase: "prep.model", durationMs: head.timings.modelMs, status: "completed" },
{ phase: prepParallel ? "prep.flag.parallel-on" : "prep.flag.parallel-off", durationMs: 0, status: "completed" },
{ phase: modelVersionCacheTtlMs() > 0 ? "prep.flag.cache-on" : "prep.flag.cache-off", durationMs: 0, status: "completed" },
{ phase: consultDomainConcurrency() === 2 ? "prep.flag.domains-2" : "prep.flag.domains-1", durationMs: 0, status: "completed" },
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession) {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (chatSession.session_type !== "consultation") {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!sessionModel) {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.entrypoint === "birth_time_rectification") {
@@ -421,14 +394,58 @@ export async function POST(request: Request) {
// Content moderation, input side (2026-09-30): before any reservation, so a
// blocked question is never sent to a model and never charged.
const moderationContext = { route: "consult", userId: user.id, requestId: parsed.data.requestId, modelId: chatSession.model_id };
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
let inputModerationPending: Promise<ModerationResult> | null = null;
let inputModerationRecorded = false;
let moderationTimed = false;
let prepLogged = false;
const moderationStartedAt = Date.now();
function logPrep() {
if (prepLogged) return;
prepLogged = true;
logAgentObservability(consultPrepObservabilityEvent({
requestId,
sessionId,
phases: prepPhases,
}));
}
async function settleInputModeration(): Promise<ModerationResult | null> {
if (!inputModerationPending) return null;
const result = await inputModerationPending;
if (!moderationTimed) {
moderationTimed = true;
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: result.verdict === "block" ? "failed" : "completed",
});
}
if (result.verdict !== "pass" && !inputModerationRecorded) {
inputModerationRecorded = true;
await recordModerationEvent(accounting, result, "input", moderationContext);
}
return result;
}
function contentBlockedResponse(result: ModerationResult) {
return NextResponse.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
}
if (!prepParallel) {
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: inputModeration.verdict === "block" ? "failed" : "completed",
});
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
logPrep();
return contentBlockedResponse(inputModeration);
}
} else {
inputModerationPending = moderate(visibleQuestion, "input", moderationContext);
}
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
@@ -443,6 +460,7 @@ export async function POST(request: Request) {
// pre-stream order because its text/plain replies cannot ride an NDJSON stream.
const runTurn = async (): Promise<Response> => {
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
const prepareStarted = Date.now();
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
userId,
@@ -475,13 +493,25 @@ export async function POST(request: Request) {
return data;
},
loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }),
beforeReserve: ({ consultationMode }) => createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
}),
reserve: () => reserveConsultationModel(
beforeReserve: async ({ consultationMode }) => {
prepPhases.push({
phase: "prep.subject",
durationMs: Math.max(0, Date.now() - prepareStarted),
status: "completed",
});
const inputModeration = await settleInputModeration();
if (inputModeration?.verdict === "block") throw new ConsultationInputBlockedError(inputModeration);
return createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
});
},
reserve: async () => {
const reserveStarted = Date.now();
try {
return await reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
async (model) => {
@@ -503,9 +533,22 @@ export async function POST(request: Request) {
error_code: reservation.data.reason,
};
},
),
);
} finally {
prepPhases.push({
phase: "prep.reserve",
durationMs: Math.max(0, Date.now() - reserveStarted),
status: "completed",
});
}
},
});
} catch (error) {
const blocked = await settleInputModeration();
if (blocked?.verdict === "block") {
logPrep();
return contentBlockedResponse(blocked);
}
if (error instanceof FeaturePricingError) {
console.error(
`[billing] reservation failed request=${requestId} reason=${error.code}`,
@@ -626,6 +669,7 @@ export async function POST(request: Request) {
}
let appendedQuestion: { success: boolean; error_code?: string | null };
const appendStarted = Date.now();
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
@@ -640,7 +684,18 @@ export async function POST(request: Request) {
}
return parsedAppend.data;
});
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "completed",
});
} catch {
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "failed",
});
logPrep();
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
@@ -670,6 +725,14 @@ export async function POST(request: Request) {
let classificationOutcome: string | undefined;
let classificationDurationMs: number | null = null;
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
const titleStarted = Date.now();
const titleRead = supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const classifyStarted = Date.now();
const turn = parsed.data.entrypoint === undefined
? await classifyConsultationTurn({
model: selectedModel,
@@ -705,8 +768,14 @@ export async function POST(request: Request) {
},
})
: { kind: "consult" as const };
prepPhases.push({
phase: "prep.classify",
durationMs: classificationDurationMs ?? Math.max(0, Date.now() - classifyStarted),
status: "completed",
});
if (turn.kind === "smalltalk") {
logPrep();
return streamSmalltalkResponse({
requestId,
reply: turn.reply,
@@ -738,12 +807,13 @@ export async function POST(request: Request) {
}
const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : "";
const { data: titleRow } = await supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const { data: titleRow } = await titleRead;
prepPhases.push({
phase: "prep.title",
durationMs: Math.max(0, Date.now() - titleStarted),
status: "completed",
});
logPrep();
async function checkpointConsultationContext() {
try {
+62
View File
@@ -0,0 +1,62 @@
/** Call-time switches for consult preparation. Read on each request, not at import. */
const warned = new Set<string>();
function warnOnce(name: string, raw: string): void {
if (warned.has(name)) return;
warned.add(name);
console.warn(`[consult-prep] ${name} 的值无法识别,按默认处理`);
void raw;
}
function readRaw(env: Readonly<Record<string, string | undefined>>, name: string): string | undefined {
const raw = env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
export function consultPrepParallelEnabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const raw = readRaw(env, "CONSULT_PREP_PARALLEL");
if (raw === undefined) return false;
const value = raw.toLowerCase();
if (value === "on") return true;
if (value === "off") return false;
warnOnce("CONSULT_PREP_PARALLEL", raw);
return false;
}
/** 0 closes the cache. Anything outside 0–15000 stays on the default, which is off. */
export function modelVersionCacheTtlMs(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = readRaw(env, "MODEL_VERSION_CACHE_TTL_MS");
if (raw === undefined) return 0;
if (!/^\d+$/.test(raw)) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
const parsed = Number(raw);
if (parsed > 15_000) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
return parsed;
}
/** 1 is today's serial loop. 2 starts the capped domains together. */
export function consultDomainConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): 1 | 2 {
const raw = readRaw(env, "CONSULT_DOMAIN_CONCURRENCY");
if (raw === undefined || raw === "1") return 1;
if (raw === "2") return 2;
warnOnce("CONSULT_DOMAIN_CONCURRENCY", raw);
return 1;
}
export function resetConsultPrepFlagWarningsForTests(): void {
warned.clear();
}
+34
View File
@@ -0,0 +1,34 @@
import type { ModerationResult } from "@/lib/moderation";
export const CONSULT_PREP_AGENT_VERSION = "consultation-prep-v1";
export class ConsultationInputBlockedError extends Error {
readonly result: ModerationResult;
constructor(result: ModerationResult) {
super("content_blocked");
this.name = "ConsultationInputBlockedError";
this.result = result;
}
}
export type ConsultPrepPhase = {
phase: string;
durationMs: number;
status: "completed" | "failed" | "skipped";
};
/** Closed observability event. The question text is not a field. */
export function consultPrepObservabilityEvent(input: {
requestId: string;
sessionId: string;
phases: readonly ConsultPrepPhase[];
}) {
return {
requestId: input.requestId,
sessionId: input.sessionId,
agentVersion: CONSULT_PREP_AGENT_VERSION,
contractPhases: input.phases.map((phase) => ({ ...phase })),
billingSettlementResult: "not_applicable" as const,
};
}
+195
View File
@@ -0,0 +1,195 @@
import { NextResponse } from "next/server";
export type ConsultPrepTimings = {
authMs: number;
deletionMs: number;
sessionMs: number;
modelMs: number;
};
type AuthResult<User> = {
data: { user: User | null };
error: unknown;
};
type BodyResult<Parsed> =
| { success: true; data: Parsed }
| { success: false; error: { flatten: () => unknown } };
type SessionResult<Session> = {
data: Session | null;
error: unknown;
};
type SessionRow = {
model_id: string | null;
session_type: string | null;
model_config_version?: number | null;
messages?: unknown;
title?: unknown;
theme?: unknown;
chart_profile_id?: unknown;
chart_profile_name?: unknown;
chart_profile_role?: unknown;
context_summary?: unknown;
};
type ParsedRequest = {
sessionId: string;
modelId: string;
};
export type ConsultTurnHeadDeps<User extends { id: string }, Parsed extends ParsedRequest, Session extends SessionRow, Model> = {
parallel: boolean;
getUser: () => Promise<AuthResult<User>>;
readPendingDeletion: (userId: string) => Promise<Response | null>;
readBody: () => Promise<BodyResult<Parsed>>;
readSession: (userId: string, parsed: Parsed) => Promise<SessionResult<Session>>;
resolveModel: (chatSession: Session) => Promise<Model | null>;
};
export type ConsultTurnHead<User, Parsed, Session, Model> =
| { ok: false; response: Response; frozen: boolean }
| {
ok: true;
user: User;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
timings: ConsultPrepTimings;
};
function elapsedSince(started: number): number {
return Math.max(0, Date.now() - started);
}
function loginRequired(): Response {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
}
function badBody(error: { flatten: () => unknown }): Response {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: error.flatten() },
{ status: 400 },
);
}
function sessionUnreadable(): Response {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
function sessionMissing(): Response {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
function notConsultation(): Response {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
function modelChanged(): Response {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
function modelUnavailable(): Response {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
type Chain<Parsed, Session, Model> =
| { ok: false; response: Response }
| {
ok: true;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
};
/**
* Auth, then either the old serial checks or deletion in parallel with
* body → session → model. Failures are still reported in the old order.
*/
export async function loadConsultTurnHead<
User extends { id: string },
Parsed extends ParsedRequest,
Session extends SessionRow,
Model,
>(
deps: ConsultTurnHeadDeps<User, Parsed, Session, Model>,
): Promise<ConsultTurnHead<User, Parsed, Session, Model>> {
const timings: ConsultPrepTimings = { authMs: 0, deletionMs: 0, sessionMs: 0, modelMs: 0 };
const authStarted = Date.now();
const auth = await deps.getUser();
timings.authMs = elapsedSince(authStarted);
const user = auth.data.user;
if (auth.error || !user) return { ok: false, response: loginRequired(), frozen: false };
const readChain = async (): Promise<Chain<Parsed, Session, Model>> => {
const body = await deps.readBody();
if (!body.success) return { ok: false, response: badBody(body.error) };
const sessionStarted = Date.now();
const session = await deps.readSession(user.id, body.data);
timings.sessionMs = elapsedSince(sessionStarted);
if (session.error) return { ok: false, response: sessionUnreadable() };
if (!session.data) return { ok: false, response: sessionMissing() };
const chatSession = session.data;
if (chatSession.session_type !== "consultation") return { ok: false, response: notConsultation() };
if (!chatSession.model_id || chatSession.model_id !== body.data.modelId) {
return { ok: false, response: modelChanged() };
}
const modelStarted = Date.now();
const sessionModel = await deps.resolveModel(chatSession);
timings.modelMs = elapsedSince(modelStarted);
if (!sessionModel) return { ok: false, response: modelUnavailable() };
return {
ok: true,
parsed: body,
chatSession: chatSession as Session & { model_id: string },
sessionModel,
};
};
if (!deps.parallel) {
const deletionStarted = Date.now();
const frozen = await deps.readPendingDeletion(user.id);
timings.deletionMs = elapsedSince(deletionStarted);
if (frozen) return { ok: false, response: frozen, frozen: true };
const chain = await readChain();
if (!chain.ok) return { ok: false, response: chain.response, frozen: false };
return { ...chain, user, timings };
}
const deletionStarted = Date.now();
const deletion = deps.readPendingDeletion(user.id).then((frozen) => {
timings.deletionMs = elapsedSince(deletionStarted);
return frozen;
});
const chain = readChain();
const [frozen, settled] = await Promise.all([deletion, chain]);
if (frozen) return { ok: false, response: frozen, frozen: true };
if (!settled.ok) return { ok: false, response: settled.response, frozen: false };
return { ...settled, user, timings };
}
+47 -6
View File
@@ -3,8 +3,22 @@ import "server-only";
import type { MastraModelConfig } from "@mastra/core/llm";
import { queryAdminRows } from "@/lib/admin/database";
import { modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
import { decryptModelProviderApiKey, type ModelProviderType } from "@/lib/model-provider-policy";
/** Tests replace the database, decrypt and DNS steps. Production leaves these as the real functions. */
const catalogIo = {
queryAdminRows,
assertAllowedModelProviderUrl,
decryptModelProviderApiKey,
};
export function setModelCatalogIoForTests(next: Partial<typeof catalogIo> | null): void {
catalogIo.queryAdminRows = next?.queryAdminRows ?? queryAdminRows;
catalogIo.assertAllowedModelProviderUrl = next?.assertAllowedModelProviderUrl ?? assertAllowedModelProviderUrl;
catalogIo.decryptModelProviderApiKey = next?.decryptModelProviderApiKey ?? decryptModelProviderApiKey;
}
import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model";
const cacheTtlMs = 15_000;
@@ -17,7 +31,11 @@ type PublishedModelRow = {
context_window: number | null;
};
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
type VersionHit = { expiresAt: number; model: ResolvedLanguageModel };
const state = globalThis as typeof globalThis & {
jyotishaModelCatalogCache?: Cache;
jyotishaModelVersionCache?: Map<string, VersionHit>;
};
function isSecretSettingKey(key: string) {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -37,7 +55,7 @@ function publicModel(model: ResolvedLanguageModel) {
return { id: model.id, label: model.label, description: model.description, creditCost: model.creditCost, isDefault: model.isDefault };
}
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
const apiKey = decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
const apiKey = catalogIo.decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
if (!apiKey) return null;
let model: MastraModelConfig;
if (row.provider_type === "openai") {
@@ -45,7 +63,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
} else if (row.provider_type === "anthropic") {
model = { providerId: "anthropic", modelId: row.provider_model.replace(/^anthropic\//, ""), apiKey };
} else {
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await catalogIo.assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
}
return {
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
@@ -55,7 +73,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
};
}
async function queryCatalog(where: string, values: readonly unknown[] = []) {
return queryAdminRows<PublishedModelRow>(`
return catalogIo.queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
@@ -86,9 +104,32 @@ async function readVersion(modelId: string, version: number) {
const rows = await queryCatalog("c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled", [modelId, version]);
return rows[0] ? resolveRow(rows[0]) : null;
}
function versionCache(): Map<string, VersionHit> {
return state.jyotishaModelVersionCache ??= new Map();
}
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
if (configVersion !== null && configVersion !== undefined) { try { return await readVersion(modelId, configVersion); } catch { return null; } }
if (configVersion !== null && configVersion !== undefined) {
const ttl = modelVersionCacheTtlMs();
const key = `${modelId}:${configVersion}`;
const cache = versionCache();
if (ttl > 0) {
const hit = cache.get(key);
if (hit && hit.expiresAt > Date.now()) return hit.model;
}
try {
const model = await readVersion(modelId, configVersion);
// Failures and null stay uncached so a disabled provider is not pinned.
if (model && ttl > 0) cache.set(key, { expiresAt: Date.now() + ttl, model });
return model;
} catch {
return null;
}
}
const catalog = await loadLanguageModelCatalog();
return catalog.models.find((model) => model.id === modelId) ?? null;
}
export function invalidateLanguageModelCatalog() { delete state.jyotishaModelCatalogCache; }
export function invalidateLanguageModelCatalog() {
delete state.jyotishaModelCatalogCache;
state.jyotishaModelVersionCache?.clear();
}
+108 -50
View File
@@ -27,6 +27,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
import {
buildEvidenceCard,
citedEvidenceCardFields,
@@ -1227,57 +1228,114 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
const executions: DomainExecution[] = [];
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
if (consultDomainConcurrency() === 1) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
}
} else {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
}
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
if (!domain) return null;
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
return {
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
};
}));
for (const execution of parallelExecutions) {
if (execution) executions.push(execution);
}
}
// Domains the cap refused and domains the clock ran out on are the
// same disclosure to the caller: requested but not calculated.
+12 -7
View File
@@ -7,6 +7,7 @@ const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", impor
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const turnPrep = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
const sql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
@@ -59,14 +60,18 @@ test("session list GET omits messages while detail GET returns them", () => {
});
test("consult POST on a rectification session is 409, a missing id is 404", () => {
// 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」
// 新值:查不到 404;类型不对 409 session_not_consultation
// 原因:BUG-925 前端无法区分不存在与类型不对
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/);
assert.match(consultRoute, /code: "session_not_consultation"/);
assert.match(consultRoute, /status: 409/);
// 原值:consult route 里 /if (!chatSession) {[\s\S]*status: 404/,以及同文件的 session_not_consultation 与 409
// 新值:turn-prep.ts 里 sessionMissing() 为 404,notConsultation() 为 409 session_not_consultation,两处调用分开
// 原因:判定从 route.ts 挪进 turn-prep.ts;仍是查不到 404、类型不对 409,不得并成一个判断
assert.match(turnPrep, /function sessionMissing\(\)[\s\S]*status: 404/);
assert.match(turnPrep, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(turnPrep, /function notConsultation\(\)[\s\S]*code: "session_not_consultation"[\s\S]*status: 409/);
assert.match(
turnPrep,
/if \(chatSession\.session_type !== "consultation"\) return \{ ok: false, response: notConsultation\(\) \}/,
);
assert.doesNotMatch(
consultRoute,
turnPrep,
/if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
);
});
@@ -255,18 +255,23 @@ test("a turn that throws before producing any response ends the stream with a ge
test("the consult route opens the stream before subject reads, reservation, question append and classification", () => {
const route = source("../src/app/api/consult/route.ts");
const head = source("../src/lib/consult/turn-prep.ts");
const openIndex = route.indexOf("const runTurn = async (): Promise<Response> =>");
assert.ok(openIndex > 0);
// Stays in front of the stream: auth, body, session existence and type, the session model.
// 原值: 401、请求体、会话不存在、会话类型、模型已变这些字面量都在 route.ts 的 runTurn 之前。
// 新值: 这些字面量在 turn-prep.ts;route.ts 在 runTurn 之前调用 loadConsultTurnHead 并返回失败响应。提示词拦截仍在 route.ts。
// 原因: 开流前的独立读取收进同一个函数,两种开关的状态码仍在开流前返回。
assert.ok(route.indexOf("loadConsultTurnHead") < openIndex);
assert.ok(route.indexOf("if (!head.ok)") < openIndex);
assert.ok(route.indexOf("if (blocksPromptExtraction(userControlledPrompt))") < openIndex);
for (const marker of [
"status: 401",
'{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() }',
'{ error: "出生资料或问题格式不正确", details: error.flatten() }',
'error: "咨询会话不存在"',
'code: "session_not_consultation"',
'error: "会话模型已经变化"',
"if (blocksPromptExtraction(userControlledPrompt))",
]) {
assert.ok(route.indexOf(marker) < openIndex, `${marker} must answer with an HTTP status`);
assert.ok(head.includes(marker), `${marker} stays an HTTP response before the stream`);
}
// Inside the stream: the slow part, and every rejection it can raise.
for (const marker of [
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { register } from "node:module";
import test from "node:test";
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
import { ConsultationInputBlockedError, consultPrepObservabilityEvent } from "../src/lib/consult/prep-log.ts";
import { inputBlockedCopy, MODERATION_SELF_HARM_COPY } from "../src/lib/moderation/index.ts";
import type { ModerationResult } from "../src/lib/moderation/types.ts";
import { streamFirstResponse } from "../src/lib/stream-first-response.ts";
const blocked: ModerationResult = {
verdict: "block",
categories: ["self_harm"],
ruleIds: ["lexicon"],
providerId: "local",
};
test("a blocked question records once and never reaches reserve", async () => {
let reserves = 0;
let records = 0;
const pending = Promise.resolve(blocked);
async function beforeReserve() {
const result = await pending;
if (result.verdict !== "pass") records += 1;
if (result.verdict === "block") throw new ConsultationInputBlockedError(result);
reserves += 1;
}
await assert.rejects(beforeReserve, (error: unknown) => {
assert.ok(error instanceof ConsultationInputBlockedError);
assert.equal(inputBlockedCopy(error.result), MODERATION_SELF_HARM_COPY);
return true;
});
assert.equal(reserves, 0);
assert.equal(records, 1);
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const blockedAt = route.indexOf('blocked?.verdict === "block"');
const profileAt = route.indexOf("error instanceof ConsultationProfileTruthError");
assert.ok(blockedAt > 0 && blockedAt < profileAt);
});
test("moderation that takes 1.5s does not hold the first frame", async () => {
let release: (result: ModerationResult) => void = () => {};
const pending = new Promise<ModerationResult>((resolve) => {
release = resolve;
});
const started = Date.now();
const response = streamFirstResponse({
requestId: "prep-frame",
run: async () => {
const result = await pending;
return Response.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
},
});
const reader = response.body!.getReader();
const first = await reader.read();
assert.ok(Date.now() - started < 400, "first frame waited for moderation");
assert.ok(first.value && first.value.byteLength > 0);
setTimeout(() => release(blocked), 1_500);
const chunks: string[] = [];
const decoder = new TextDecoder();
while (true) {
const next = await reader.read();
if (next.value) chunks.push(decoder.decode(next.value));
if (next.done) break;
}
const events = chunks.join("").trim().split("\n").map((line) => JSON.parse(line));
const rejected = events.find((event) => event.type === "run.failed");
assert.equal(rejected.message, MODERATION_SELF_HARM_COPY);
assert.equal(rejected.reason, "content_blocked");
assert.equal(rejected.status, 400);
});
test("prep observability accepts the closed schema and drops the question", () => {
const question = "这句问题原文不该出现在日志里";
const event = consultPrepObservabilityEvent({
requestId: "req-prep",
sessionId: "sess-prep",
phases: [
{ phase: "prep.model", durationMs: 3, status: "completed" },
{ phase: "prep.moderation", durationMs: 12, status: "completed" },
],
});
assert.deepEqual(agentObservabilityEventSchema.parse(event), event);
assert.equal(JSON.stringify(event).includes(question), false);
assert.equal(event.agentVersion, "consultation-prep-v1");
});
test("readVersion cache skips a second database and DNS lookup inside the ttl", async () => {
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
let queries = 0;
let dns = 0;
const row = {
model_id: "model-a",
version: 4,
label: "Model A",
description: "fixture",
provider_model: "demo",
credit_cost: 1,
is_default: false,
provider_code: "demo",
provider_type: "openai-compatible",
base_url: "https://example.com/v1",
encrypted_api_key: "enc",
input_cost: 1,
output_cost: 1,
context_window: 8000,
};
const catalog = await import("../src/lib/model-catalog.ts");
const flags = await import("../src/lib/consult/prep-flags.ts");
catalog.setModelCatalogIoForTests({
queryAdminRows: (async () => {
queries += 1;
if (queries === 3) throw new Error("database down");
if (queries >= 4) return [];
return [row];
}) as typeof import("../src/lib/admin/database.ts").queryAdminRows,
assertAllowedModelProviderUrl: (async (value: string) => {
dns += 1;
return { url: new URL(value), addresses: [] };
}) as typeof import("../src/lib/epay/gateway-policy.ts").assertAllowedModelProviderUrl,
decryptModelProviderApiKey: (() => "test-key") as typeof import("../src/lib/model-provider-policy.ts").decryptModelProviderApiKey,
});
process.env.MODEL_VERSION_CACHE_TTL_MS = "15000";
try {
catalog.invalidateLanguageModelCatalog();
const first = await catalog.resolveSessionLanguageModel("model-a", 4);
const second = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(first?.id, "model-a");
assert.equal(second, first);
assert.equal(queries, 1);
assert.equal(dns, 1);
catalog.invalidateLanguageModelCatalog();
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(queries, 2);
process.env.MODEL_VERSION_CACHE_TTL_MS = "0";
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries >= 3);
const failed = await catalog.resolveSessionLanguageModel("model-a", 4);
const empty = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(failed, null);
assert.equal(empty, null);
const queriesAfterMiss = queries;
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries > queriesAfterMiss);
} finally {
flags.resetConsultPrepFlagWarningsForTests();
delete process.env.MODEL_VERSION_CACHE_TTL_MS;
catalog.invalidateLanguageModelCatalog();
catalog.setModelCatalogIoForTests(null);
}
});
test("the title read starts after the question is saved and is awaited at the old use site", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const append = route.indexOf('accounting.rpc("append_consultation_question"');
const title = route.indexOf('.select("title")');
const classify = route.indexOf("await classifyConsultationTurn({");
const awaited = route.indexOf("await titleRead");
assert.ok(append > 0 && title > append && classify > title && awaited > classify);
});
@@ -0,0 +1,79 @@
/**
* In-process overlap check for consult preparation.
*
* This does not measure local Postgres. Those medians were not collected,
* so the switches stay at their code defaults.
*/
import { register } from "node:module";
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
const { loadConsultTurnHead } = await import("../src/lib/consult/turn-prep.ts");
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
let finished = false;
let inFlight = 0;
let maxInFlight = 0;
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
async function occupy(ms: number) {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session).then(() => ({
data: { model_id: "m", model_config_version: 2, session_type: "consultation" },
error: null,
})),
resolveModel: () => occupy(delays.model).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
if (!head.ok) throw new Error("head failed");
return { elapsed: now, maxInFlight };
}
const on = await run(true);
const off = await run(false);
const report = {
kind: "logical-clock",
delays,
parallelElapsed: on.elapsed,
serialElapsed: off.elapsed,
parallelMaxInFlight: on.maxInFlight,
serialMaxInFlight: off.maxInFlight,
postgresMedians: "not-measured",
aliyunKey: "absent",
moderationSimulationMs: [300, 1500],
decision: {
CONSULT_PREP_PARALLEL: "off",
MODEL_VERSION_CACHE_TTL_MS: 0,
reason: "开启条件要求本机 Postgres 中位数。本轮没有量到,默认保持关闭。",
},
};
console.log(JSON.stringify(report));
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadConsultTurnHead } from "../src/lib/consult/turn-prep.ts";
type User = { id: string };
type Parsed = { sessionId: string; modelId: string };
type Session = { model_id: string | null; model_config_version: number | null; session_type: string | null };
type Model = { id: string };
function okSession(): Session {
return { model_id: "m", model_config_version: 2, session_type: "consultation" };
}
function deps(overrides: Record<string, unknown> = {}) {
const calls = { deletion: 0, session: 0, model: 0 };
const base = {
parallel: false,
getUser: async () => ({ data: { user: { id: "user-1" } as User }, error: null }),
readPendingDeletion: async () => {
calls.deletion += 1;
return null;
},
readBody: async () => ({ success: true as const, data: { sessionId: "session-1", modelId: "m" } as Parsed }),
readSession: async () => {
calls.session += 1;
return { data: okSession(), error: null };
},
resolveModel: async () => {
calls.model += 1;
return { id: "m" } as Model;
},
...overrides,
};
return { calls, deps: base };
}
async function snapshot(response: Response) {
return { status: response.status, body: await response.json() };
}
async function both(overrides: Record<string, unknown> = {}) {
const off = deps({ ...overrides, parallel: false });
const on = deps({ ...overrides, parallel: true });
const [left, right] = await Promise.all([
loadConsultTurnHead(off.deps),
loadConsultTurnHead(on.deps),
]);
assert.equal(left.ok, false);
assert.equal(right.ok, false);
if (left.ok || right.ok) return;
assert.deepEqual(await snapshot(left.response), await snapshot(right.response));
}
test("serial and parallel heads return the same status for each gate", async () => {
const anonymous = deps({
getUser: async () => ({ data: { user: null }, error: null }),
});
const missingUser = await loadConsultTurnHead({ ...anonymous.deps, parallel: false });
assert.equal(missingUser.ok, false);
if (!missingUser.ok) assert.equal(missingUser.response.status, 401);
assert.equal(anonymous.calls.deletion, 0);
await both({
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({ readSession: async () => ({ data: null, error: { message: "down" } }) });
await both({ readSession: async () => ({ data: null, error: null }) });
await both({
readSession: async () => ({ data: { ...okSession(), session_type: "rectification" }, error: null }),
});
await both({
readSession: async () => ({ data: { ...okSession(), model_id: "other" }, error: null }),
});
await both({ resolveModel: async () => null });
});
test("a frozen account wins over a bad request body", async () => {
const frozen = deps({
parallel: true,
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({}) } }),
});
const head = await loadConsultTurnHead(frozen.deps);
assert.equal(head.ok, false);
if (!head.ok) {
assert.equal(head.frozen, true);
assert.equal(head.response.status, 423);
}
});
test("parallel head overlaps deletion with session and model", async () => {
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
let deletionStarted = 0;
let chainStarted = 0;
let inFlight = 0;
let maxInFlight = 0;
let finished = false;
async function occupy(ms: number, mark: () => void) {
mark();
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion, () => { deletionStarted = now; }).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session, () => { chainStarted = now; }).then(() => ({ data: okSession(), error: null })),
resolveModel: () => occupy(delays.model, () => {}).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
assert.equal(head.ok, true);
return { elapsed: now, deletionStarted, chainStarted, maxInFlight };
}
const on = await run(true);
const off = await run(false);
assert.ok(on.elapsed < 230, `parallel elapsed ${on.elapsed}`);
assert.equal(on.maxInFlight, 2);
assert.equal(off.maxInFlight, 1);
assert.equal(on.deletionStarted, on.chainStarted);
assert.ok(off.chainStarted >= delays.deletion);
assert.ok(off.elapsed > on.elapsed);
});
@@ -138,7 +138,7 @@ type PlanResult = Record<string, unknown> & {
async function runDomainPlan(
domains: string[],
runWorkflow: (theme: string) => ReturnType<typeof workflow>,
runWorkflow: (theme: string) => ReturnType<typeof workflow> | Promise<ReturnType<typeof workflow>>,
options: { now?: () => number; requestId?: string } = {},
) {
const state = createConsultationRuntimeState();
@@ -623,6 +623,63 @@ test("the single-value domain form stays available to callers without the model
assert.throws(() => canonicalDomainPlan({ theme: "prashna" }, {}), /unsupported_consultation_domain/);
});
test("two domains stay in plan order when concurrency is 2 and the second finishes first", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
const finished: string[] = [];
try {
const { result } = await runDomainPlan(["career", "marriage"], async (theme) => {
await new Promise((resolve) => setTimeout(resolve, theme === "career" ? 40 : 5));
finished.push(theme);
return workflow(theme);
});
assert.deepEqual(finished, ["marriage", "career"]);
assert.deepEqual(result.domains, ["career", "marriage"]);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("a 429 from one parallel domain keeps workflow_rate_limited", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
try {
await assert.rejects(
() => runDomainPlan(["career", "marriage"], async () => {
throw new ConsultationWorkflowError("workflow_rate_limited", "busy");
}),
(error: unknown) => {
assert.equal(consultationToolFailureCode(error), "workflow_rate_limited");
return true;
},
);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("concurrency 1 still runs domains one at a time", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "1";
let active = 0;
let maxActive = 0;
try {
await runDomainPlan(["career", "marriage"], async (theme) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 20));
active -= 1;
return workflow(theme);
});
assert.equal(maxActive, 1);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("daily entrypoint ignores a model domain rewrite instead of executing it", async () => {
const calls: string[] = [];
const state = createConsultationRuntimeState();
@@ -157,8 +157,20 @@ test("consultation plans are server-owned and bounded", () => {
const modelToolInput = tools.slice(tools.indexOf("const consultationToolInputSchema"), tools.indexOf("const MAX_RECORDED_STEPS"));
assert.doesNotMatch(modelToolInput, /birth|latitude|longitude|engine|script|credit/i);
const prepareCall = route.slice(route.indexOf("prepared = await prepareConsultationRoute"), route.indexOf("const modelSelection"));
assert.match(prepareCall, /beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/);
assert.ok(prepareCall.indexOf("beforeReserve:") < prepareCall.indexOf("reserve: () => reserveConsultationModel("));
// 原值:beforeReserve: ({ consultationMode }) => createConsultationPlan({,且早于 reserve: () => reserveConsultationModel(
// 新值:beforeReserve 的 async 函数体里调用 createConsultationPlan({,并且整段早于 reserve;reserve 的 async 函数体里调用 reserveConsultationModel(
// 原因:两处改成 async 函数体。仍锁调用发生在对应回调里,并且 beforeReserve 在 reserve 之前
const beforeReserveAt = prepareCall.indexOf("beforeReserve:");
const reserveAt = prepareCall.indexOf("reserve: async");
assert.ok(beforeReserveAt >= 0 && reserveAt > beforeReserveAt);
assert.match(
prepareCall.slice(beforeReserveAt, reserveAt),
/beforeReserve:\s*async\s*\(\{\s*consultationMode\s*\}\)[\s\S]*createConsultationPlan\(\{/,
);
assert.match(
prepareCall.slice(reserveAt),
/reserve:\s*async\s*\(\)\s*=>[\s\S]*reserveConsultationModel\(/,
);
assert.ok(routeService.indexOf("const preReserveResult = input.beforeReserve") < routeService.indexOf("const reservation = await input.reserve()"));
assert.match(route, /plan: prepared\.preReserveResult,[\s\S]*theme: consultationTheme/);
assert.match(route, /runConsultationWorkflow\(toolInput, \{[\s\S]*plan: prepared\.preReserveResult/);
+9 -2
View File
@@ -239,8 +239,15 @@ test("a blocked answer is replaced in place and onComplete settles the notice",
test("routes check input before any charge and output before settlement", () => {
const consult = read("../src/app/api/consult/route.ts");
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') > 0);
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') < consult.indexOf("reserve_consultation_usage"), "input check precedes the reservation");
const prepare = read("../src/lib/consultation-route-service.ts");
// 原值: 源码含 await moderate(visibleQuestion, "input" 且位置早于 reserve_consultation_usage
// 新值: beforeReserve 里 await settleInputModeration(),而 prepare 里 beforeReserve 早于 input.reserve()
// 原因: 审核从开流前挪到流内、预留前;开关关掉时仍在开流前 await moderate
assert.match(consult, /await settleInputModeration\(\)/);
assert.ok(consult.indexOf("await settleInputModeration()") < consult.indexOf("reserve_consultation_usage"));
const beforeReserve = prepare.indexOf("input.beforeReserve");
const reserveCall = prepare.indexOf("input.reserve()");
assert.ok(beforeReserve > 0 && beforeReserve < reserveCall);
assert.equal((consult.match(/moderateOutput: moderateAnswer/g) ?? []).length, 3, "every agentic answer stream is checked");
assert.match(consult, /if \(!outputModerated\) await moderateAnswer\(reply\.text\);\n\s+if \(outputBlocked\) return await completeModeratedResponse\(usage\);/);
assert.match(consult, /complete_consultation_moderated/);
+11 -2
View File
@@ -45,9 +45,18 @@ test("catalog cache is short-lived and failure stays fail-closed without env fal
test("session model resolution is async and pins published or retired database versions", () => {
assert.match(catalog, /export async function resolveSessionLanguageModel/);
assert.match(catalog, /c\.model_id=\$1 and v\.version=\$2 and v\.status in \('published','retired'\)/);
assert.match(catalog, /return await readVersion\(modelId, configVersion\)/);
// 原值: return await readVersion(modelId, configVersion)
// 新值: const model = await readVersion(modelId, configVersion),只有非空结果写入短缓存
// 原因: 绑版本的解析与目录共用 15 秒上限,失败和空结果不缓存
assert.match(catalog, /const model = await readVersion\(modelId, configVersion\)/);
assert.match(catalog, /if \(model && ttl > 0\) cache\.set\(key, \{ expiresAt: Date\.now\(\) \+ ttl, model \}\)/);
assert.match(routes.consult, /await resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version/);
assert.match(routes.consult, /if \(!sessionModel\)[\s\S]*status: 503/);
// 原值: route.ts 里 if (!sessionModel) 之后是 status 503
// 新值: turn-prep.ts 里 if (!sessionModel) 返回 modelUnavailable(),正文 status 503
// 原因: 模型不可用仍是开流前的 503,文案随准备步骤一起搬走
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
assert.match(head, /if \(!sessionModel\) return \{ ok: false, response: modelUnavailable\(\) \}/);
assert.match(head, /function modelUnavailable\(\)[\s\S]*status: 503/);
assert.doesNotMatch(routes.consult, /languageModelConfigurationMessage/);
});
@@ -18,7 +18,6 @@ import {
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const run = read("../src/hooks/use-consultation-run.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const consultRoute = read("../src/app/api/consult/route.ts");
const pageSource = read("../src/app/(app)/page.tsx");
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
@@ -50,13 +49,16 @@ test("pending consult replay pointing at a rectification session is dropped", ()
});
test("consult POST splits missing session from wrong session type", () => {
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(
consultRoute,
/if \(chatSession\.session_type !== "consultation"\) \{[\s\S]*session_not_consultation[\s\S]*status: 409/,
);
assert.match(consultRoute, /这是生时校正会话/);
assert.match(consultRoute, /请在生时校正里继续。/);
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
// 原值: consult route 里 if (!chatSession) 返回 404,类型不对返回 session_not_consultation。
// 新值: 同样两句在 turn-prep.ts,route 把读会话的结果交给它。
// 原因: 开流前的会话判断收进准备函数,文案和状态码没变。
assert.match(head, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(head, /function sessionMissing\(\)[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(head, /function notConsultation\(\)[\s\S]*session_not_consultation[\s\S]*status: 409/);
assert.match(head, /session_type !== "consultation"/);
assert.match(head, /这是生时校正会话/);
assert.match(head, /请在生时校正里继续。/);
assert.equal(SESSION_NOT_CONSULTATION_CODE, "session_not_consultation");
assert.match(run, /caught\.code === SESSION_NOT_CONSULTATION_CODE/);
});
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,8 @@
const empty = new URL("./server-only-empty.mjs", import.meta.url).href;
export async function resolve(specifier, context, nextResolve) {
if (specifier === "server-only") {
return { url: empty, shortCircuit: true };
}
return nextResolve(specifier, context);
}