diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 76e30fa9..e9ce8001 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2766,6 +2766,23 @@ - 复发自:无(新 Agentic Domain 主链;旧防线缺失的直接原因见下) - 修复版本:本地 staging 候选(未 push / deploy) +### 后续推进(2026-08-13 · stream agent execution + entry routing lane) + +- 状态:resolved(local candidate;真实 PostgreSQL fixture 待远端 runner 执行) +- 最近更新:2026-08-13 +- 现象:旧 `/api/rectification/agent` 仍消费 `textStream`,客户端 history 可覆盖服务端历史,`maxSteps=8` 全局固定,billing identity 是 sessionId;旧工具让模型传 `candidate_range/events[]`,`confirmedGate` 在进程内;UI 的“思考状态”不能证明 Skill/工具真的执行过,候选卡从 Agent 文本或隐藏 sentinel 解析。 +- 根因:第一轮 V9 runtime 落地了 Case/Evidence/Turn/Receipt 表与 open API,但 agent 执行层仍是旧 Direct Agentic 传话筒:没有 durable turn(pending→completed)、没有 fullStream→allowlist NDJSON 映射、没有 Case-ref 工具、没有 Skill 真实加载证据、没有 DB 驱动的 runtime selector。 +- 修复: + 1. 重构 `agentic-rectification.ts`:系统提示词压缩为约 25 行高优先边界(不再复制 gate→scan→score→diagnostics);固定加载 `skills/jyotish-birth-time-rectification`;`maxSteps` 按 action 有界(opening/read-only 6、evidence 8、rescore 12、accept/confirm 6)+ 硬上限 16 + 重复工具调用检测(>3 次相同调用中止)。 + 2. 新增十个 Case-ref 工具(`rectification-v9-tools.ts`):read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case;input 只含 caseId/sourceTurnId/evidenceId/resultId/candidateId/quote/proposedKind 等最小引用,绝不接 userId/出生资料/range/events[]/分数/权限开关;每个工具走 RLS 仅 service_role 的 RPC(evidence ledger、指纹缓存、receipt、幂等);`accepted` 与 `confirmed` 由 RPC 分列,confirm 需要 confirmation gate + 用户原话 consent quote 原文匹配;candidate 相同 evidence/range/engine 指纹复用缓存。 + 3. `/api/rectification/agent` 重写为 Case-ref API:请求仅 caseId/sessionId/requestId/action/message;服务端验证 Case↔Session exact binding 与 `chat_sessions.agentic_rectification_case_id`;客户端 history 不再进入上下文,每轮先从持久化 dossier 读取 turns/evidence;先原子 pending Turn,成功才 completed(半截文本永不成为 settled history);消费 `result.fullStream` 并按 allowlist 输出 NDJSON(run.started/skill.started/skill.loaded/case.loaded/evidence.*/candidates.*/diagnostics.completed/candidate.accepted/birth_time.confirmed/answer.delta/run.completed/run.failed),reasoning/raw/provider metadata/tool args/results/出生资料/评分/DB 错误永不透传;skill 加载证据来自真实框架(`agent.getSkill` 预校验 + fullStream skill tool-call/tool-result),首轮无 skill.started/loaded 允许一次受控重试,仍失败则 Turn failed/retryable 并释放用量;billing identity 绑定 `rectification:case:{caseId}`,opening/read-only 不扣,首次实质运行预留一次,恢复/重试不重复;断线 abort 传播释放。 + 4. 新增向前业务迁移 `20260813010000_agentic_rectification_v9_agent_api.sql`:case dossier / case compute(含 baseline 但不向模型暴露)/ turn finalize / candidate persist(指纹缓存) / case-scoped accept / consent-gated confirm / guarded transition / needs_rebaseline profile guard trigger / `agentic_rectification_run_phases`(持久化 skill.started/loaded 等 phases)/ turn receipt 读取;`rectification_runtime_version` feature flag(published、100%,config version=v9 legacy_mode=readonly),DB 驱动 selector,两个 runtime 不可能同时写 profile/扣费/confirm。 + 5. 前端:`page.tsx` 删除 `hasRectificationSession` 与 `sessions.find(sessionType==='birth_time_rectification')`;新增 `openRectificationFromHomepage/openRectificationSession(sessionId)/startNewRectification`,全部请求服务端 Case open API 并使用返回的 exact sessionId/caseId;首页 CTA 由 entry-summary 驱动(开始/继续上次/再次校正);侧边栏校正 Session 点击走 intent=session + 精确 sessionId(terminal 只读 + “再次校正”);`shouldStartOpening` 只来自服务端;聊天组件改为 caseId/sessionId/readonly/shouldStartOpening 初始化并从持久化 Turns 恢复,候选卡来自 Candidate Snapshot API,活动展示来自真实 NDJSON + 持久化 receipt(可折叠“本轮做了什么”,不显示 reasoning),删除本地 timer 模拟与隐藏 sentinel。 + 6. 新增 `GET cases/[caseId]?sessionId=` 返回持久化 turns/evidence/latest_result/逐轮 receipt 支持刷新恢复;新增 `POST cases/[caseId]/candidates/accept`(UI 候选卡直连,不扣费、幂等、case-scoped)。 +- 验证:新增 `rectification-v9-entry-routing.test.ts`(13)、`rectification-v9-evidence.test.ts`(11)、`rectification-v9-agent.test.ts`(12)、`rectification-v9-stream.test.ts`(9)、`rectification-v9-status-security.test.ts`(11);`rectification-v9-migration.test.ts` 扩 9 项静态合同;`rectification-v9-database.test.ts` 扩 agent API 迁移/flag/consent 测试(本机无 Docker 按环境 skip);重写 `rectification-agentic-entry.test.ts`(旧测试锁死了 `hasRectificationSession + sessions.find` 与 maxSteps=8、textStream、sessions.find 复用等错误行为,全部改为断言 V9 服务端路由);`consultation-entrypoint.test.ts` / `application-billing-contract.test.ts` 的 rectification 段同步为 caseId 绑定契约。本机运行:v9 聚焦 107 通过 / 0 失败 / 6 Docker skip;完整 frontend 套件 1231 通过 / 18 失败(全部为无 Docker/PostgreSQL 的环境类既有失败,基线 20 失败,本轮未新增环境失败);`tsc --noEmit` 仅剩 6 个既有测试文件错误(均未触碰);ESLint 0 error;`git diff --check` 通过。 +- 防复发(补充):任何新 rectification 前端逻辑不得再根据消息数/候选存在/session 排序推断 Case 状态;`/api/rectification/agent` 只能消费 fullStream 并输出 allowlist NDJSON;工具输入 schema 必须 strict 且只含最小引用;首轮必须保留真实 skill 加载证据;billing request identity 只能绑定 caseId;DB 驱动 feature flag 是 runtime selector 的唯一来源。 +- 修复版本:本地 staging 候选(未 push / deploy) + ### 旧防线为何没拦住(hasRectificationSession + sessions.find()) - `hasRectificationSession` 只断言“存在任意校正 Session”,不区分 draft / collecting / candidate_ready / candidate_accepted / confirmed / closed,因此已完成会话始终被当作可继续,且没有服务端 Case 状态可被测试断言。 diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 014dc436..f473ea9d 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -9,11 +9,11 @@ const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root), const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8"); test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => { - assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version"\)/); + assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/); assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/); assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/); - assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:\$\{sessionId\}`/); + assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/); assert.match(rectificationRoute, /from\("usage_reservations"\)[\s\S]*\.eq\("feature_key", "rectification"\)[\s\S]*\.like\("request_id", `\$\{billingRequestPrefix\}%`\)/); assert.match(rectificationRoute, /return reservations.length === 0[\s\S]*`\$\{billingRequestPrefix\}:retry:\$\{reservations.length\}`/); assert.match(rectificationRoute, /authorizeUsage\(accounting, \{[\s\S]*requestId: billingRequestId/); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 4cede283..4b848790 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -78,13 +78,14 @@ test("ordinary product drafts keep the public question and clear hidden routing assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/); }); -test("homepage birth-time card opens the latest Agentic surface instead of ordinary consultation", () => { +test("homepage birth-time card opens the V9 Agentic surface via the server case API", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); - assert.match(source, /function openBirthTimeRectification/); + assert.match(source, /function openRectificationCase/); + assert.match(source, /openRectificationFromHomepage/); assert.match(source, //); + assert.match(component, //); assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/); assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/); assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/); @@ -96,40 +97,38 @@ test("homepage mounts the Agentic surface without invoking retired rectification const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(page, / { +test("homepage opens through the server Case API and merges the returned session", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); - const start = source.indexOf("async function openBirthTimeRectification"); - const end = source.indexOf("function handleRectificationProfileIncomplete", start); + const start = source.indexOf("async function openRectificationCase"); + const end = source.indexOf("async function openRectificationFromHomepage", start); const handler = source.slice(start, end); - const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")'); - const persist = handler.indexOf("await rectificationPersistence.current.enqueue"); - const addToSessionList = handler.indexOf("setSessions((current) => [", persist); - const reveal = handler.indexOf("setActiveSessionId(rectificationSession.id)"); + const open = handler.indexOf('/api/rectification/cases/open'); + const merge = handler.indexOf('setSessions((current) => [merged,'); + const reveal = handler.indexOf('setActiveSessionId(opened.sessionId)'); - assert.ok(create >= 0); - assert.ok(persist > create); - assert.ok(addToSessionList > persist); - assert.ok(reveal > addToSessionList); - assert.match(handler, /rectificationOpenInFlight\.current/); + assert.ok(open >= 0); + assert.ok(merge > open); + assert.ok(reveal > merge); assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/); assert.doesNotMatch(handler, /onNarrativeDelta/); assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/); - assert.match(source, /rectificationSurfaceOpen && \([\s\S]*? { +test("the page never creates the session shell locally; the server owns session creation", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); - const start = page.indexOf("async function openBirthTimeRectification"); - const end = page.indexOf("function handleRectificationProfileIncomplete", start); + const start = page.indexOf("async function openRectificationCase"); + const end = page.indexOf("async function openRectificationFromHomepage", start); const handler = page.slice(start, end); - assert.match(handler, /persistSession\(rectificationSession, "create"\)/); - assert.match(component, / { @@ -141,7 +140,7 @@ test("rectification cards render only inside the active rectification session", assert.doesNotMatch(source, /这个会话保存了生时校正入口|恢复生时校正<\/button>/); }); -test("selecting a rectification session resumes it without an intermediate confirmation", () => { +test("selecting a rectification session resumes it through the exact-session open API", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const selectSession = source.slice( source.indexOf("function selectSession("), @@ -149,36 +148,24 @@ test("selecting a rectification session resumes it without an intermediate confi ); assert.match(selectSession, /nextSession\?\.sessionType === "birth_time_rectification"/); - assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/); + assert.match(selectSession, /void openRectificationSession\(nextSession\.id\)/); assert.match(source, /resumeRectificationSession\.current\(activeSession\)/); assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/); assert.match(source, / { +test("homepage restart and sidebar selection both resolve through the server open API", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); - const start = page.indexOf("async function openBirthTimeRectification"); - const end = page.indexOf("function handleRectificationProfileIncomplete", start); - const handler = page.slice(start, end); - assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/); - assert.match(handler, /existing \?\? createSession/); - assert.match(component, / session\.sessionType === "birth_time_rectification"\)/); + assert.match(component, / { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); - const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); - const start = page.indexOf("async function openBirthTimeRectification"); - const end = page.indexOf("function handleRectificationProfileIncomplete", start); - const handler = page.slice(start, end); - - assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/); - assert.match(component, / { +test("rectify-first suggestions hand the source question to the homepage open flow", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const start = source.indexOf("function chooseConversationSuggestion"); const end = source.indexOf("function draftDailyStarlanguageQuestion", start); @@ -186,7 +173,7 @@ test("rectify-first suggestions hand the source question to a dedicated rectific assert.match(handler, /suggestion !== rectifyBeforeConsultationSuggestion/); assert.match(handler, /find\(\(message\) => message\.role === "user"\)/); - assert.match(handler, /openBirthTimeRectification\(originalQuestion, activeSession\)/); + assert.match(handler, /openRectificationFromHomepage\(originalQuestion \?\? null\)/); assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/); }); @@ -196,7 +183,7 @@ test("rectify-first handoffs stay as Agent context", () => { assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/); assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/); - assert.match(chat, /之后再回到你原来的问题/); + assert.match(chat, /之后会回到你原来的问题/); }); test("ordinary consultation uses current birth data without a rectification notice", () => { diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 834caf79..6cb6bb12 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -49,6 +49,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260806050000_operations_feature_flags\.sql/); assert.match(migration.stdout, /applied 20260811010000_consultation_status_service_role_read\.sql/); assert.match(migration.stdout, /applied 20260812010000_agentic_rectification_v9_runtime\.sql/); + assert.match(migration.stdout, /applied 20260813010000_agentic_rectification_v9_agent_api\.sql/); assert.equal( fixture.psql(` @@ -113,6 +114,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic "agentic_rectification_evidence", "agentic_rectification_open_ledger", "agentic_rectification_results", + "agentic_rectification_run_phases", "agentic_rectification_tool_receipts", "agentic_rectification_turns", "billing_products", diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index f621739c..75841466 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -21,27 +21,35 @@ const agent = readFileSync( ); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); -test("birth-time rectification entry mounts the Agentic chat", () => { - assert.match(component, /return /); +test("birth-time rectification entry mounts the V9 case-ref chat", () => { + assert.match(component, /return /); + assert.match(component, /caseId: string;/); + assert.match(component, /readonly: boolean;/); + assert.match(component, /shouldStartOpening: boolean;/); + assert.match(component, /initialTurns:/); }); -test("opening is a server-owned operation rather than a hidden user prompt", () => { +test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => { + assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/); assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/); - assert.match(chat, /action: "opening"/); - assert.match(route, /z\.literal\("opening"\)/); - assert.match(route, /conversation\.action === "opening"/); + assert.match(chat, /shouldStartOpening/); + assert.match(chat, /if \(readonly \|\| openingStarted\.current \|\| !shouldStartOpening\) return/); + assert.match(chat, /void send\("opening", ""\)/); + assert.match(route, /action: z\.enum\(\["opening", "message", "read_only"\]\)/); + assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/); + assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/); }); -test("incomplete profiles stay in the shared onboarding flow", () => { +test("incomplete profiles stay in the shared onboarding flow before any open request", () => { const opening = page.slice( - page.indexOf("async function openBirthTimeRectification"), - page.indexOf("resumeRectificationSession.current ="), + page.indexOf("async function openRectificationCase"), + page.indexOf("async function openRectificationFromHomepage"), ); assert.match(opening, /const missingStep = missingProfileStep\(profile\)/); assert.match(opening, /setOnboardingStep\(missingStep\)/); assert.ok( opening.indexOf("const missingStep = missingProfileStep(profile)") - < opening.indexOf("setRectificationSessionId(rectificationSession.id)"), + < opening.indexOf("/api/rectification/cases/open"), ); assert.match(chat, /payload\?\.code === "profile_incomplete"/); }); @@ -71,7 +79,6 @@ test("account rehydration normalizes persisted ISO birth dates before completene assert.match(profileReader, /const date = normalizePersistedBirthDate\(/); }); - test("candidate acceptance refreshes the profile result without overwriting an open draft", () => { const refresh = page.slice( page.indexOf("async function refreshAccount"), @@ -82,75 +89,72 @@ test("candidate acceptance refreshes the profile result without overwriting an o assert.doesNotMatch(refresh, /setProfileDraft/); }); -test("agent tool calls leave a final step for visible prose and never end silently", () => { - assert.match(route, /const agenticRectificationMaxSteps = 8/); - assert.match(route, /\{ maxSteps: agenticRectificationMaxSteps \}/); - assert.match(route, /if \(!emitted \|\| !reply\.text\) \{[\s\S]*type: "error"[\s\S]*await settle\(false\)[\s\S]*return;/); - assert.doesNotMatch(route, /send\(\{ type: "done", emitted \}\)/); +test("agent tool calls never end silently; the runner owns completion and failure", () => { + assert.doesNotMatch(route, /agenticRectificationMaxSteps = 8/); + assert.match(agent, /RECTIFICATION_AGENT_STEP_BUDGETS/); + assert.match(agent, /RECTIFICATION_AGENT_HARD_STEP_LIMIT/); + assert.match(route, /send\(\{ type: "done", emitted: true \}\)/); + assert.doesNotMatch(route, /send\(\{ type: "done", emitted: false \}\)/); }); -test("rectification messages survive remounts and suppress duplicate openings", () => { - assert.match(chat, /initialMessages: readonly ChatMessage\[\]/); - assert.match(chat, /if \(initialMessages\.length > 0 \|\| openingStarted\.current\) return/); - assert.match(chat, /sessionId,/); - assert.match(chat, /onMessagesChange\?\.\(/); - assert.match(page, /key=\{rectificationSessionId\}/); - assert.match(page, /initialMessages=\{activeSession\?\.messages \?\? \[\]\}/); +test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => { + assert.match(chat, /initialTurns/); + assert.match(chat, /const openingStarted = useRef\(false\)/); + assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/); + assert.match(page, /initialTurns=\{rectificationTurns\}/); assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/); }); -test("successful Agent turns are persisted by the authenticated rectification route", () => { +test("the agent route verifies the exact Case/Session binding before any turn", () => { + assert.match(route, /caseId: z\.string\(\)\.uuid\(\)/); assert.match(route, /sessionId: z\.string\(\)\.uuid\(\)/); - assert.match(route, /\.from\("chat_sessions"\)[\s\S]*\.eq\("user_id", userId\)/); - assert.match(route, /conversation\.action === "opening" && persistedMessages\.length > 0/); - assert.match(route, /\.update\(\{ messages: nextMessages, updated_at:/); - assert.match(route, /if \(saveError \|\| !savedSession\) throw new Error\("RectificationSessionPersistenceError"\)/); + assert.match(route, /requestId: z\.string\(\)\.uuid\(\)/); + assert.match(route, /boundSessionId !== sessionId/); + assert.match(route, /chatSession\.agentic_rectification_case_id !== caseId/); + assert.doesNotMatch(route, /conversation\.action === "opening" && persistedMessages\.length > 0/); + assert.doesNotMatch(route, /\.update\(\{ messages: nextMessages, updated_at:/); }); +test("candidate results restore through the durable Candidate Snapshot API, never agent text", () => { + assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\?sessionId=/); + assert.doesNotMatch(chat, /savedSentinel|AYANAM_RECTIFICATION_SAVED|hidden block/); + assert.doesNotMatch(chat, /