From 746fdc184b17c1dbcc8301ac1705f17dcfe0d790 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 12 Aug 2026 15:33:04 +0800 Subject: [PATCH] fix(rectification): preserve assistant turns in dossier --- docs/BUG_HISTORY.md | 15 ++ ..._rectification_turn_message_projection.sql | 133 ++++++++++++++++++ .../tests/rectification-v9-migration.test.ts | 50 +++++++ 3 files changed, 198 insertions(+) create mode 100644 frontend/supabase/migrations/20260813030000_rectification_turn_message_projection.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 01b07f3c..63c56b0b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3005,3 +3005,18 @@ - 防复发:服务器确认路径与额外对话轮次必须分开建模;任何 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 以发布记录为准) diff --git a/frontend/supabase/migrations/20260813030000_rectification_turn_message_projection.sql b/frontend/supabase/migrations/20260813030000_rectification_turn_message_projection.sql new file mode 100644 index 00000000..463d4e8c --- /dev/null +++ b/frontend/supabase/migrations/20260813030000_rectification_turn_message_projection.sql @@ -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; diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 5aa6df6e..023faefc 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -399,3 +399,53 @@ 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)", + ); +});