Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4deb5c2378 | |||
| 90199b4d9b | |||
| 746fdc184b | |||
| 4152103726 |
@@ -3005,3 +3005,34 @@
|
||||
- 防复发:服务器确认路径与额外对话轮次必须分开建模;任何 pending 状态不得隐式参与正式候选评分。
|
||||
- 相关记录:BUG-170、BUG-174
|
||||
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
|
||||
|
||||
## BUG-176 | V9 普通 Turn 的 Agent 回复在刷新后消失
|
||||
|
||||
- 状态:resolved(本地候选)
|
||||
- 首次发现:2026-08-12
|
||||
- 最近更新:2026-08-12
|
||||
- 影响面:V9 生时校正历史消息、持久化 Activity 与 Agent 下一轮上下文。
|
||||
- 用户现象:事件提交后当轮可以看到 Agent 回复和 Activity,但刷新或重新进入 Case 后只剩用户事件;opening Agent 消息仍可显示。
|
||||
- 触发条件:一条物理 `agentic_rectification_turns` 记录同时保存非空 `user_message` 与 `assistant_message`。
|
||||
- 根因:`get_agentic_rectification_case_dossier()` 把每条物理 Turn 只投影成一条逻辑消息,并用 `coalesce(user_message, assistant_message)` 优先返回用户文本,导致同一行已持久化的 Agent 回复和 receipt 关联在恢复 API 中丢失。
|
||||
- 修复:新增向前业务迁移,将每条物理 Turn 通过 lateral values 展开为按 user、assistant 排序的最多两条逻辑消息;保留同一真实 Turn ID,使 assistant 消息继续读取对应 Activity receipt。
|
||||
- 验证:迁移契约测试锁定双消息展开、空消息过滤、顺序、迁移唯一性和禁止复制到 identity migration tree;staging 需继续验证 Case GET、页面刷新、下一轮上下文及计费不变量。
|
||||
- 防复发:持久化行与对话消息不是一对一时,恢复投影必须显式展开全部逻辑消息,不得用 `coalesce` 静默舍弃其中一侧。
|
||||
- 相关记录:BUG-173、BUG-174、BUG-175
|
||||
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
|
||||
|
||||
## BUG-177 | 首页生时校正被未完成 Session 强制劫持,无法新建独立校正
|
||||
|
||||
- 状态:resolved(staging 发布与真实环境验收以本次发布记录为准)
|
||||
- 首次发现:2026-08-12
|
||||
- 最近更新:2026-08-12
|
||||
- 影响面:首页生时校正卡片、`POST /api/rectification/cases/open`、V9 Case 并发约束
|
||||
- 用户现象:账户存在任意未完成的生时校正时,从首页点击“生时校正”会直接回到旧 Session;用户无法保留旧记录并另开一段校正。
|
||||
- 触发条件:存在 `draft`、`collecting_evidence`、`candidate_ready`、`candidate_accepted`、`needs_rebaseline` 或 `paused` Case 后点击首页生时校正卡片。
|
||||
- 根因:V9 初始设计把首页 `homepage` intent 定义为 resume-or-create,并用 `agentic_rectification_cases_one_resumable_per_user` 部分唯一索引和 `active_case_conflict` 强制每用户最多一个 resumable Case;首页 UI 又据 entry summary 显示“继续上次校正”。该安全约束错误扩大成产品限制。
|
||||
- 修复:首页入口始终作为显式创建动作;新增向前迁移 `20260813040000_allow_parallel_rectification_cases.sql`,删除每用户单 resumable 唯一索引并重定义 open RPC,使 `homepage`/`new` 创建独立 Case + Session,`session` 仍按精确 Session 恢复;requestId 幂等与同用户 advisory lock 保留。首页有未完成记录时明确提示可从左侧历史继续,但主 CTA 仍是新建。
|
||||
- 验证:入口、Case service、迁移静态合同等聚焦测试共 119 项,113 通过、0 失败;6 项真实 PostgreSQL 测试因本机 Docker 不可用跳过(测试已覆盖不同 requestId 新建多个 resumable Case、同 requestId 幂等和精确 Session 恢复)。目标 ESLint 与 `git diff --check` 通过;全量 TypeScript 检查仅命中仓库既有的 `dayjs` 缺失及无关测试类型错误。
|
||||
- 防复发:首页“新建”和历史“继续”必须使用不同 intent;不得用“存在 resumable Case”改变首页主 CTA 或阻止新 Case;同一 requestId 重试只能返回同一 Case。
|
||||
- 相关记录:BUG-163
|
||||
- 复发自:BUG-163
|
||||
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
|
||||
|
||||
@@ -12,7 +12,7 @@ export const runtime = "nodejs";
|
||||
/**
|
||||
* GET /api/rectification/cases/entry-summary
|
||||
*
|
||||
* Server-truth homepage CTA: "开始生时校正" / "继续上次校正" / "再次校正".
|
||||
* Server-truth context for homepage copy; the primary homepage action always creates.
|
||||
*/
|
||||
export async function GET() {
|
||||
let supabase;
|
||||
|
||||
@@ -28,7 +28,7 @@ function serviceErrorResponse(error: unknown) {
|
||||
*
|
||||
* Browser sends only { intent, requestId } (+ sessionId for intent=session).
|
||||
* The server derives the user from the session, normalizes the profile and
|
||||
* decides resume-or-create. Double-click / multi-tab are idempotent.
|
||||
* creates for homepage/new or restores the exact requested session. Retries are idempotent.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let supabase;
|
||||
|
||||
@@ -3423,8 +3423,8 @@ export default function Home() {
|
||||
<div className="product-entrypoint-copy">
|
||||
<span className="product-entrypoint-kicker">出生资料</span>
|
||||
<h2 id="birth-rectification-title">生时校正</h2>
|
||||
<p>{rectificationCardAction === "resume"
|
||||
? "你有一段未完成的校正记录,可以从上次的位置继续。"
|
||||
<p>{rectificationEntrySummary?.hasResumableCase
|
||||
? "新建一段独立校正;未完成的记录仍可从左侧历史会话继续。"
|
||||
: rectificationCardAction === "restart"
|
||||
? "上一次校正已经完成,可以基于最新资料再次校正。"
|
||||
: "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}</p>
|
||||
|
||||
@@ -20,18 +20,16 @@ export type RectificationEntrySummary = Readonly<{
|
||||
}> | null;
|
||||
}>;
|
||||
|
||||
export type RectificationCardAction = "start" | "resume" | "restart";
|
||||
export type RectificationCardAction = "start" | "restart";
|
||||
|
||||
export const rectificationEntryLabels: Readonly<Record<RectificationCardAction, string>> = {
|
||||
start: "开始生时校正",
|
||||
resume: "继续上次校正",
|
||||
start: "开始新的生时校正",
|
||||
restart: "再次校正",
|
||||
};
|
||||
|
||||
export function resolveRectificationEntryAction(
|
||||
summary: RectificationEntrySummary,
|
||||
): RectificationCardAction {
|
||||
if (summary.hasResumableCase) return "resume";
|
||||
if (summary.hasTerminalCaseWithTime) return "restart";
|
||||
return "start";
|
||||
}
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
-- Preserve both logical messages stored by a completed rectification turn.
|
||||
-- A normal turn stores user_message and assistant_message on the same physical
|
||||
-- row; the dossier must expose both in conversational order. Business schema
|
||||
-- only: do not copy this migration into frontend/db/migrations.
|
||||
|
||||
begin;
|
||||
|
||||
create or replace function public.get_agentic_rectification_case_dossier(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_turns jsonb;
|
||||
v_evidence jsonb;
|
||||
v_result public.agentic_rectification_results%rowtype;
|
||||
v_evidence_count bigint;
|
||||
v_turn_count bigint;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'role', message.role,
|
||||
'text', message.text,
|
||||
'status', t.status,
|
||||
'created_at', t.created_at,
|
||||
'completed_at', t.completed_at
|
||||
) order by t.created_at, message.ordinal
|
||||
), '[]'::jsonb) into v_turns
|
||||
from public.agentic_rectification_turns t
|
||||
cross join lateral (
|
||||
values
|
||||
(1, 'user'::text, t.user_message),
|
||||
(2, 'assistant'::text, t.assistant_message)
|
||||
) as message(ordinal, role, text)
|
||||
where t.case_id = v_case.id
|
||||
and message.text is not null;
|
||||
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', e.id,
|
||||
'source_turn_id', e.source_turn_id,
|
||||
'subject', e.subject,
|
||||
'event_kind', e.event_kind,
|
||||
'domain', e.domain,
|
||||
'occurred_from', e.occurred_from,
|
||||
'occurred_to', e.occurred_to,
|
||||
'date_precision', e.date_precision,
|
||||
'summary', e.summary,
|
||||
'status', e.status,
|
||||
'supersedes_evidence_id', e.supersedes_evidence_id,
|
||||
'created_at', e.created_at
|
||||
) order by e.created_at
|
||||
), '[]'::jsonb) into v_evidence
|
||||
from public.agentic_rectification_evidence e
|
||||
where e.case_id = v_case.id;
|
||||
|
||||
select count(*) into v_evidence_count
|
||||
from public.agentic_rectification_evidence
|
||||
where case_id = v_case.id;
|
||||
select count(*) into v_turn_count
|
||||
from public.agentic_rectification_turns
|
||||
where case_id = v_case.id;
|
||||
|
||||
select * into v_result
|
||||
from public.agentic_rectification_results
|
||||
where case_id = v_case.id
|
||||
and invalidated_at is null
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
|
||||
return jsonb_build_object(
|
||||
'case', jsonb_build_object(
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_case.session_id,
|
||||
'status', v_case.status,
|
||||
'skill_name', v_case.skill_name,
|
||||
'skill_version', v_case.skill_version,
|
||||
'candidate_range', v_case.candidate_range,
|
||||
'accepted_time', v_case.accepted_time,
|
||||
'confirmed_time', v_case.confirmed_time,
|
||||
'completed_at', v_case.completed_at,
|
||||
'closed_reason', v_case.closed_reason,
|
||||
'last_activity_at', v_case.last_activity_at,
|
||||
'evidence_count', v_evidence_count,
|
||||
'turn_count', v_turn_count
|
||||
),
|
||||
'turns', v_turns,
|
||||
'evidence', v_evidence,
|
||||
'latest_result', case
|
||||
when v_result.id is null then null
|
||||
else jsonb_build_object(
|
||||
'result_id', v_result.id,
|
||||
'candidates', v_result.candidates,
|
||||
'overall_confidence', v_result.overall_confidence,
|
||||
'selection_allowed', v_result.selection_allowed,
|
||||
'confirmation_allowed', v_result.confirmation_allowed,
|
||||
'representative_time', v_result.representative_time,
|
||||
'selected_time', v_result.selected_time,
|
||||
'selection_kind', v_result.selection_kind,
|
||||
'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint,
|
||||
'candidate_range_fingerprint', v_result.candidate_range_fingerprint,
|
||||
'skill_version', v_result.skill_version,
|
||||
'algorithm_version', v_result.algorithm_version,
|
||||
'created_at', v_result.created_at,
|
||||
'invalidated_at', v_result.invalidated_at
|
||||
)
|
||||
end
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,147 @@
|
||||
begin;
|
||||
|
||||
-- BUG-177: starting birth-time rectification from the homepage is an explicit
|
||||
-- create action. Existing unfinished cases remain resumable through their
|
||||
-- exact history/session entry, but no longer block a separate new case.
|
||||
drop index if exists public.agentic_rectification_cases_one_resumable_per_user;
|
||||
|
||||
create or replace function public.open_agentic_rectification_case(
|
||||
p_user_id uuid,
|
||||
p_request_id uuid,
|
||||
p_intent text,
|
||||
p_session_id uuid,
|
||||
p_skill_name text,
|
||||
p_skill_version text,
|
||||
p_baseline_profile_fingerprint text,
|
||||
p_baseline_birth_snapshot jsonb,
|
||||
p_candidate_range jsonb
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_session public.chat_sessions%rowtype;
|
||||
v_session_id uuid;
|
||||
begin
|
||||
if p_user_id is null or p_request_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_intent not in ('homepage', 'session', 'new') then
|
||||
raise exception 'agentic_rectification_invalid_intent' using errcode = 'P0001';
|
||||
end if;
|
||||
if length(btrim(p_skill_name)) = 0 or length(btrim(p_skill_version)) = 0
|
||||
or length(btrim(coalesce(p_baseline_profile_fingerprint, ''))) = 0 then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
-- Serialize same-user opens so a repeated requestId is deterministic. A
|
||||
-- separate click carries a separate requestId and is allowed to create.
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtext('agentic_rectification_open:' || p_user_id::text)
|
||||
);
|
||||
|
||||
select c.* into v_case
|
||||
from public.agentic_rectification_cases c
|
||||
join public.agentic_rectification_open_ledger l
|
||||
on l.case_id = c.id and l.session_id = c.session_id
|
||||
where l.user_id = p_user_id and l.request_id = p_request_id
|
||||
limit 1;
|
||||
|
||||
if found then
|
||||
return jsonb_build_object(
|
||||
'disposition', case
|
||||
when v_case.status = any (public.agentic_rectification_resumable_statuses()) then 'resumed'
|
||||
else 'readonly'
|
||||
end,
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_case.session_id,
|
||||
'status', v_case.status,
|
||||
'should_start_opening', false,
|
||||
'skill_version', v_case.skill_version
|
||||
);
|
||||
end if;
|
||||
|
||||
if p_intent = 'session' then
|
||||
if p_session_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_session
|
||||
from public.chat_sessions
|
||||
where id = p_session_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_session_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_session.session_type <> 'birth_time_rectification' then
|
||||
raise exception 'agentic_rectification_session_not_rectification' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where session_id = p_session_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
return jsonb_build_object(
|
||||
'disposition', case
|
||||
when v_case.status = any (public.agentic_rectification_resumable_statuses()) then 'resumed'
|
||||
else 'readonly'
|
||||
end,
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_case.session_id,
|
||||
'status', v_case.status,
|
||||
'should_start_opening', false,
|
||||
'skill_version', v_case.skill_version
|
||||
);
|
||||
end if;
|
||||
|
||||
if p_baseline_birth_snapshot is null or jsonb_typeof(p_baseline_birth_snapshot) <> 'object'
|
||||
or p_baseline_birth_snapshot ->> 'birth_date' is null
|
||||
or p_baseline_birth_snapshot ->> 'latitude' is null
|
||||
or p_baseline_birth_snapshot ->> 'longitude' is null
|
||||
or p_baseline_birth_snapshot ->> 'timezone_offset' is null
|
||||
or length(btrim(coalesce(p_baseline_birth_snapshot ->> 'birth_time_source', ''))) = 0 then
|
||||
raise exception 'agentic_rectification_profile_incomplete' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_candidate_range is null or jsonb_typeof(p_candidate_range) <> 'object'
|
||||
or not (p_candidate_range ->> 'start_time') ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
||||
or not (p_candidate_range ->> 'end_time') ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then
|
||||
raise exception 'agentic_rectification_invalid_range' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
insert into public.chat_sessions (user_id, title, theme, session_type, messages)
|
||||
values (p_user_id, '生时校正', 'general', 'birth_time_rectification', '[]'::jsonb)
|
||||
returning id into v_session_id;
|
||||
|
||||
insert into public.agentic_rectification_cases (
|
||||
user_id, session_id, status, skill_name, skill_version,
|
||||
baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range
|
||||
) values (
|
||||
p_user_id, v_session_id, 'draft', p_skill_name, p_skill_version,
|
||||
p_baseline_profile_fingerprint, p_baseline_birth_snapshot, p_candidate_range
|
||||
) returning * into v_case;
|
||||
|
||||
insert into public.agentic_rectification_open_ledger (
|
||||
request_id, user_id, case_id, session_id, intent
|
||||
) values (
|
||||
p_request_id, p_user_id, v_case.id, v_session_id, p_intent
|
||||
);
|
||||
|
||||
return jsonb_build_object(
|
||||
'disposition', 'created',
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_session_id,
|
||||
'status', v_case.status,
|
||||
'should_start_opening', true,
|
||||
'skill_version', v_case.skill_version
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.open_agentic_rectification_case(uuid, uuid, text, uuid, text, text, text, jsonb, jsonb)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.open_agentic_rectification_case(uuid, uuid, text, uuid, text, text, text, jsonb, jsonb)
|
||||
to service_role;
|
||||
|
||||
commit;
|
||||
@@ -154,11 +154,12 @@ test("selecting a rectification session resumes it through the exact-session ope
|
||||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||||
});
|
||||
|
||||
test("homepage restart and sidebar selection both resolve through the server open API", () => {
|
||||
test("homepage creation and sidebar selection resolve through distinct server intents", () => {
|
||||
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");
|
||||
|
||||
assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/);
|
||||
assert.match(page, /openRectificationCase\(\"homepage\", null, pendingConsultationQuestion\)/);
|
||||
assert.match(page, /openRectificationCase\(\"new\", null, null\)/);
|
||||
assert.doesNotMatch(page, /sourceSession\.sessionType === "birth_time_rectification"/);
|
||||
assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
|
||||
|
||||
@@ -278,10 +278,8 @@ test("homepage CTA is server-driven from the entry summary, not the session list
|
||||
assert.doesNotMatch(page, /hasRectificationSession/);
|
||||
assert.doesNotMatch(page, /sessions\.some\([\s\S]{0,120}birth_time_rectification/);
|
||||
assert.match(page, /resolveRectificationEntryAction/);
|
||||
assert.match(page, /rectificationCardAction === "resume"/);
|
||||
assert.match(page, /rectificationCardAction === "restart"/);
|
||||
assert.match(entryLib, /开始生时校正/);
|
||||
assert.match(entryLib, /继续上次校正/);
|
||||
assert.match(entryLib, /开始新的生时校正/);
|
||||
assert.match(entryLib, /再次校正/);
|
||||
});
|
||||
|
||||
|
||||
@@ -146,15 +146,15 @@ test("homepage open with no history creates one case and one session", async ()
|
||||
assert.equal(response.shouldStartOpening, true);
|
||||
});
|
||||
|
||||
test("homepage open with a resumable case resumes without creating a session", async () => {
|
||||
test("homepage open may create a new case even when resumable history exists", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: openRpc({
|
||||
disposition: "resumed",
|
||||
disposition: "created",
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "collecting_evidence",
|
||||
should_start_opening: false,
|
||||
status: "draft",
|
||||
should_start_opening: true,
|
||||
skill_version: "9.0.0",
|
||||
}),
|
||||
});
|
||||
@@ -162,8 +162,8 @@ test("homepage open with a resumable case resumes without creating a session", a
|
||||
intent: "homepage",
|
||||
requestId,
|
||||
});
|
||||
assert.equal(response.disposition, "resumed");
|
||||
assert.equal(response.shouldStartOpening, false);
|
||||
assert.equal(response.disposition, "created");
|
||||
assert.equal(response.shouldStartOpening, true);
|
||||
});
|
||||
|
||||
test("session intent passes the exact sessionId and never the profile snapshot", async () => {
|
||||
@@ -212,7 +212,7 @@ test("non-owner session opens surface a safe not-found error", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("intent new with an active case surfaces a safe conflict, never silent abandon", async () => {
|
||||
test("legacy active-case conflicts still map to a safe public error", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: async () => ({ data: null, error: { message: "agentic_rectification_active_case_conflict" } }),
|
||||
@@ -224,7 +224,7 @@ test("intent new with an active case surfaces a safe conflict, never silent aban
|
||||
);
|
||||
});
|
||||
|
||||
test("entry summary projects resume-or-create truth for the homepage card", async () => {
|
||||
test("entry summary projects resumable history for homepage context", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
rpc: async (fn) => {
|
||||
assert.equal(fn, "get_agentic_rectification_entry_summary");
|
||||
|
||||
@@ -66,7 +66,7 @@ test("v9 migration applies on a fresh database and re-applies idempotently", { s
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: skipWithoutDocker }, async () => {
|
||||
test("v9 open is atomic, idempotent and allows separate homepage cases", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
@@ -188,8 +188,9 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip:
|
||||
"1",
|
||||
);
|
||||
|
||||
// A second request with a fresh requestId resumes the same case.
|
||||
const resume = await service.rpc("open_agentic_rectification_case", {
|
||||
// A fresh homepage click creates a separate case and session while the
|
||||
// previous unfinished case remains available through exact-session history.
|
||||
const secondOpen = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "22222222-2222-4222-8222-222222222222",
|
||||
p_intent: "homepage",
|
||||
@@ -200,17 +201,24 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip:
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(resume.error, null, rpcError(resume.error));
|
||||
assert.equal((resume.data as Record<string, unknown>).disposition, "resumed");
|
||||
assert.equal((resume.data as Record<string, unknown>).should_start_opening, false);
|
||||
assert.equal((resume.data as Record<string, unknown>).case_id, caseId);
|
||||
assert.equal(secondOpen.error, null, rpcError(secondOpen.error));
|
||||
assert.equal((secondOpen.data as Record<string, unknown>).disposition, "created");
|
||||
assert.equal((secondOpen.data as Record<string, unknown>).should_start_opening, true);
|
||||
const secondCaseId = String((secondOpen.data as Record<string, unknown>).case_id);
|
||||
const secondSessionId = String((secondOpen.data as Record<string, unknown>).session_id);
|
||||
assert.notEqual(secondCaseId, caseId);
|
||||
assert.notEqual(secondSessionId, sessionId);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${userId}' and status = any (public.agentic_rectification_resumable_statuses())`),
|
||||
"2",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
"2",
|
||||
);
|
||||
|
||||
// intent new while an active case exists must fail safely.
|
||||
const conflict = await service.rpc("open_agentic_rectification_case", {
|
||||
// The explicit new intent has the same create semantics.
|
||||
const thirdOpen = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "33333333-3333-4333-8333-333333333333",
|
||||
p_intent: "new",
|
||||
@@ -221,10 +229,11 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip:
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.match(rpcError(conflict.error), /agentic_rectification_active_case_conflict/);
|
||||
assert.equal(thirdOpen.error, null, rpcError(thirdOpen.error));
|
||||
assert.equal((thirdOpen.data as Record<string, unknown>).disposition, "created");
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${userId}' and status = any (public.agentic_rectification_resumable_statuses())`),
|
||||
"3",
|
||||
);
|
||||
|
||||
// Session intent opens the exact session.
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
|
||||
const uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
|
||||
test("homepage entry with no history requests a create and shows 开始生时校正", () => {
|
||||
test("homepage entry requests a new case and shows 开始新的生时校正", () => {
|
||||
const body = openRectificationRequestBody("homepage", null);
|
||||
assert.equal(body.intent, "homepage");
|
||||
assert.ok(typeof body.requestId === "string" && body.requestId.length > 0);
|
||||
@@ -41,10 +41,10 @@ test("homepage entry with no history requests a create and shows 开始生时校
|
||||
latest_terminal: null,
|
||||
});
|
||||
assert.equal(resolveRectificationEntryAction(summary), "start");
|
||||
assert.equal(rectificationEntryLabels.start, "开始生时校正");
|
||||
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
|
||||
});
|
||||
|
||||
test("homepage entry with a resumable case resumes and shows 继续上次校正", () => {
|
||||
test("homepage entry with a resumable case still offers a separate new correction", () => {
|
||||
const summary = entrySummaryFromResponse({
|
||||
has_resumable_case: true,
|
||||
has_terminal_case_with_time: false,
|
||||
@@ -55,18 +55,19 @@ test("homepage entry with a resumable case resumes and shows 继续上次校正"
|
||||
},
|
||||
latest_terminal: null,
|
||||
});
|
||||
assert.equal(resolveRectificationEntryAction(summary), "resume");
|
||||
assert.equal(rectificationEntryLabels.resume, "继续上次校正");
|
||||
assert.equal(resolveRectificationEntryAction(summary), "start");
|
||||
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
|
||||
|
||||
const opened = openResponseFromPayload({
|
||||
disposition: "resumed",
|
||||
disposition: "created",
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
status: "collecting_evidence",
|
||||
shouldStartOpening: false,
|
||||
status: "draft",
|
||||
shouldStartOpening: true,
|
||||
skillVersion: "9.0.0",
|
||||
});
|
||||
assert.equal(opened?.shouldStartOpening, false);
|
||||
assert.equal(opened?.disposition, "created");
|
||||
assert.equal(opened?.shouldStartOpening, true);
|
||||
assert.equal(opened?.caseId, CASE_ID);
|
||||
assert.equal(opened?.sessionId, SESSION_ID);
|
||||
});
|
||||
@@ -184,9 +185,9 @@ test("shouldStartOpening is server-owned: only freshly created never-started cas
|
||||
});
|
||||
|
||||
test("empty client message cache never repeats opening when the case has turns", () => {
|
||||
// The client messages array may be empty after refresh, but the server
|
||||
// returned a resumed case: shouldStartOpening stays false, so the opening
|
||||
// turn is not re-emitted.
|
||||
// The client messages array may be empty after refresh, but exact-session
|
||||
// recovery stays resumed: shouldStartOpening is false, so the opening turn
|
||||
// is not re-emitted.
|
||||
const opened = openResponseFromPayload({
|
||||
disposition: "resumed",
|
||||
caseId: CASE_ID,
|
||||
@@ -195,6 +196,7 @@ test("empty client message cache never repeats opening when the case has turns",
|
||||
shouldStartOpening: false,
|
||||
skillVersion: "9.0.0",
|
||||
});
|
||||
assert.equal(opened?.disposition, "resumed");
|
||||
assert.equal(opened?.shouldStartOpening, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -399,3 +399,110 @@ test("birth-context activity migration stays out of the identity migration tree"
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 20260813030000_rectification_turn_message_projection.sql
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const turnMessageProjectionMigration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260813030000_rectification_turn_message_projection.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const turnMessageProjectionMigrationCopy = fileURLToPath(
|
||||
new URL(
|
||||
"../db/migrations/20260813030000_rectification_turn_message_projection.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("rectification turn projection migration follows the V9 activity migration", () => {
|
||||
assert.ok(
|
||||
"20260813030000_rectification_turn_message_projection.sql" >
|
||||
"20260813020000_rectification_birth_context_activity.sql",
|
||||
);
|
||||
assert.match(turnMessageProjectionMigration, /^-- Preserve both logical messages[\s\S]*\nbegin;[\s\S]*^commit;$/m);
|
||||
});
|
||||
|
||||
test("rectification dossier expands each physical turn into user and assistant messages", () => {
|
||||
assert.match(
|
||||
turnMessageProjectionMigration,
|
||||
/cross join lateral \(\s*values\s*\(1, 'user'::text, t\.user_message\),\s*\(2, 'assistant'::text, t\.assistant_message\)\s*\) as message\(ordinal, role, text\)/,
|
||||
);
|
||||
assert.match(turnMessageProjectionMigration, /'role', message\.role/);
|
||||
assert.match(turnMessageProjectionMigration, /'text', message\.text/);
|
||||
assert.match(turnMessageProjectionMigration, /order by t\.created_at, message\.ordinal/);
|
||||
assert.match(turnMessageProjectionMigration, /and message\.text is not null/);
|
||||
assert.doesNotMatch(
|
||||
turnMessageProjectionMigration,
|
||||
/coalesce\(t\.user_message, t\.assistant_message\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rectification turn projection migration stays out of the identity migration tree", () => {
|
||||
assert.equal(
|
||||
existsSync(turnMessageProjectionMigrationCopy),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 20260813040000_allow_parallel_rectification_cases.sql
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const parallelCasesMigration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260813040000_allow_parallel_rectification_cases.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const parallelCasesMigrationCopy = fileURLToPath(
|
||||
new URL(
|
||||
"../db/migrations/20260813040000_allow_parallel_rectification_cases.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("parallel rectification migration follows the turn projection and stays transactional", () => {
|
||||
assert.ok(
|
||||
"20260813040000_allow_parallel_rectification_cases.sql" >
|
||||
"20260813030000_rectification_turn_message_projection.sql",
|
||||
);
|
||||
assert.match(parallelCasesMigration, /^begin;[\s\S]*^commit;$/m);
|
||||
});
|
||||
|
||||
test("parallel rectification migration removes the single-active-case restriction", () => {
|
||||
assert.match(
|
||||
parallelCasesMigration,
|
||||
/drop index if exists public\.agentic_rectification_cases_one_resumable_per_user/,
|
||||
);
|
||||
assert.doesNotMatch(parallelCasesMigration, /agentic_rectification_active_case_conflict/);
|
||||
assert.doesNotMatch(
|
||||
parallelCasesMigration,
|
||||
/if p_intent = 'homepage'[\s\S]*?'disposition', 'resumed'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parallel rectification open keeps request idempotency and exact-session recovery", () => {
|
||||
assert.match(parallelCasesMigration, /pg_catalog\.pg_advisory_xact_lock\(/);
|
||||
assert.match(parallelCasesMigration, /l\.request_id = p_request_id/);
|
||||
assert.match(parallelCasesMigration, /if p_intent = 'session' then/);
|
||||
assert.match(parallelCasesMigration, /where id = p_session_id and user_id = p_user_id/);
|
||||
assert.match(parallelCasesMigration, /insert into public\.chat_sessions/);
|
||||
assert.match(parallelCasesMigration, /insert into public\.agentic_rectification_cases/);
|
||||
assert.match(parallelCasesMigration, /insert into public\.agentic_rectification_open_ledger/);
|
||||
});
|
||||
|
||||
test("parallel rectification migration stays out of the identity migration tree", () => {
|
||||
assert.equal(
|
||||
existsSync(parallelCasesMigrationCopy),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user