From 149e1ec4c3b60d7d38acf609ee207c3c7219298d Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 16 Sep 2026 07:22:29 +0800 Subject: [PATCH] fix(consult): drop traces, budget checkpoints, silent summary inherit BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot. BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000). BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id. --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 48 ++++++++++ ...SS-consultation-context-memory-20260915.md | 60 +++++++++++++ docs/tasks/README.md | 2 +- frontend/src/app/api/consult/route.ts | 8 +- frontend/src/app/api/sessions/route.ts | 35 ++++++-- frontend/src/hooks/use-consultation-run.ts | 2 +- frontend/src/hooks/use-session-management.ts | 24 ++++- .../src/lib/chat-session-write-contract.ts | 31 +++++++ .../src/lib/consultation-session-history.ts | 29 +++++- frontend/src/lib/session-context-summary.ts | 28 ++++-- frontend/tests/chat-session-authority.test.ts | 6 ++ frontend/tests/chat-session-url.test.ts | 2 +- frontend/tests/chat-session-write.test.ts | 90 ++++++++++++++++++- .../tests/composer-isolation-contract.test.ts | 2 +- ...onsultation-context-cache-contract.test.ts | 7 ++ .../consultation-session-history.test.ts | 58 ++++++++++++ .../tests/session-context-summary.test.ts | 46 +++++++++- 18 files changed, 451 insertions(+), 31 deletions(-) create mode 100644 docs/tasks/PROGRESS-consultation-context-memory-20260915.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b58001..e028bfed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 对话写满后开新对话会静默带上之前的摘要 + +普通咨询写满、点「开新对话」之后,服务端会把上一场的会话摘要拷进新对话,界面上看不出差别。进不了窗口的更早几轮,模型会看到省略说明,不再当成没发生过。Skill 版本不变。 + ## 2026-09-16 — 同一天再问同一张盘,不再重复等外部证据 普通聊天同一张盘、同一天里再问,外部证据直接用已经取到的,不再每轮等外网。隔了一天会先用最多七天内的旧证据马上回答,后台再刷新;「深入看今日」仍然只要当天的。西洋盘整包不再塞进每一轮的回答里,压缩后的西洋层还在。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 183685d1..b6b114c2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11332,3 +11332,51 @@ - 相关记录:BUG-727 - 复发自:无 - 修复版本:待发布 + +## BUG-729 | 咨询历史丢掉整轮时模型看不见任何痕迹 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`consultationHistoryWindow`、`consultationUserTurnContent`、`POST /api/consult` +- 用户现象:历史超过模型预算后,追问「刚才你说的那个时间」时模型当成从没说过。单条超长会写「省略 N 字」,整轮被丢掉时什么都不留。 +- 触发条件:普通咨询多轮之后,尾巴字符数超过当前模型的历史预算,窗口从最旧整条丢弃。 +- 根因:`droppedCount` 算出来了,`route.ts` 只取 `.tail`。BUG-555 的防复发只写了「不得再按固定 12 条 × 头部截断静默砍结论」,整轮丢弃不在字面里,所以没拦住。 +- 修复:`droppedCount > 0` 时在摘要槽追加与 `omissionMarker` 同风格的说明。有摘要时写「更早的 N 轮问答已并入上面的会话摘要」;没有摘要时诚实写结论尚未并入。`droppedCount === 0` 不加这句话。 +- 验证:超预算历史的模型可见文本含丢弃说明且轮数等于 `droppedCount`;零丢弃不加这句话;源码合同断言 `route.ts` 读取 `historyWindow.droppedCount`。 +- 防复发:咨询历史任何形式的丢弃(截断单条、丢整轮)都必须在模型可见文本里留痕。 +- 相关记录:BUG-555 +- 复发自:BUG-555(防复发只覆盖头部截断) +- 修复版本:待发布 + +## BUG-730 | 写摘要的阈值写死 16,000,追不上按窗口算出的历史预算 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`historyBudgetChars`、`consultationHistoryCheckpointChars`、`shouldCheckpoint`、`checkpointSessionContextSummary` +- 用户现象:后台上架中等上下文窗口的模型后,每轮静默丢掉最老的几轮问答,摘要却还没开始写。 +- 触发条件:模型 `context_window` 低于约 70,667(例如 64k / 32k)。历史预算已经小于写死的 16,000 字阈值。 +- 根因:`shouldCheckpoint` 用常量 16,000,`consultationHistoryWindow` 用 `historyBudgetChars()`。两个数各写各的,没有「阈值必须低于预算」的断言。 +- 修复:阈值改为预算的 0.4(默认 128k 窗口仍是 16,000)。运行时断言阈值 < 预算。检查点把会话模型的 `contextWindow` 传进去。 +- 验证:表驱动覆盖 200k / 128k / 64k / 32k / null,逐条 `checkpoint < budget`;128k 仍为 16,000。 +- 防复发:触发摘要的阈值必须由历史预算派生,并由一条断言钉死「阈值 < 预算」在所有合法上下文窗口下成立。 +- 相关记录:BUG-555 +- 复发自:BUG-555(检查点阈值与窗口预算未绑在一起) +- 修复版本:待发布 + +## BUG-731 | 对话写满后开新对话不继承服务端已有的会话摘要 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`chatSessionCreateSchema`、`POST /api/sessions`、`continueInNewChat`、`startNewChat` +- 用户现象:这段对话已写满、点「开新对话」之后,模型对刚才的结论一无所知,用户被要求从零开始。 +- 触发条件:`append_consultation_question` 返回 `session_full`,客户端走「开新对话」。 +- 根因:新会话是空的。`context_summary` 不在列表 GET 列里,创建合同也不接收它。即便前端想带,也没有合法入口。 +- 修复:创建合同增加可选 `continued_from_session_id`(保持 `.strict()`,不加 `context_summary`)。服务端按当前用户读源会话摘要,读到才写入新行;读不到、不属于该用户、或为空都静默跳过,仍返回 201。写满出口把当前会话 id 带进创建请求。界面不加「接着上次聊」之类提示。 +- 验证:带来源 id 时新行摘要等于源会话;源会话属于别人时新行无摘要且 201;源码合同断言创建 schema 没有 `context_summary` 字段;写满后「开新对话」没有新增提示文案。 +- 防复发:会话满员后的「开新对话」出口必须由服务端继承 `context_summary`;摘要文本任何时候都不得由客户端提供。不得把 `messages` 加回列表 GET。 +- 相关记录:BUG-464、BUG-555 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-consultation-context-memory-20260915.md b/docs/tasks/PROGRESS-consultation-context-memory-20260915.md new file mode 100644 index 00000000..87b61524 --- /dev/null +++ b/docs/tasks/PROGRESS-consultation-context-memory-20260915.md @@ -0,0 +1,60 @@ +# PROGRESS · 普通聊天的三条记忆缺口(2026-09-16) + +工作树:`.worktrees/consultation-context-memory-20260915` +分支:`codex/consultation-context-memory-20260915` +任务书基线:`origin/staging` @ `6b3248bf`;开工时本 worktree 在 `11893c7f`(任务书已合入 staging)。 +本机 Windows。Skill **未 bump**。未改 `frontend/src/app/page.tsx`、数据库、依赖。 + +开工核对:`docs/BUG_HISTORY.md` 最大号 **BUG-720**。校正四单预占 721–726、external-evidence-cache 预占 727/728,本单使用预占 **BUG-729 / 730 / 731**,无冲突。 + +## 任务状态 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 5.2 写摘要阈值跟着预算走 | 完成 | BUG-730 | +| 5.1 丢整轮必须留痕 | 完成 | BUG-729 | +| 5.3 写满时静默继承摘要 | 完成 | BUG-731 | +| 5.4 Bug 历史 | 完成 | 729/730/731 | + +## 实现要点 + +- **BUG-730**:删除写死的 `CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000`。新函数 `consultationHistoryCheckpointChars(window)` = `floor(historyBudgetChars(window) × 0.4)`,并运行时断言阈值 < 预算。128k 仍是 16,000。`shouldCheckpoint` / `checkpointSessionContextSummary` 吃 `contextWindow`;consult 把会话模型窗口传进去。 +- **BUG-729**:`droppedCount > 0` 时在 `consultationUserTurnContent` 的摘要槽追加与 `omissionMarker` 同风格的说明。有摘要:「更早的 N 轮问答已并入上面的会话摘要」;无摘要:「更早的 N 轮问答未能进入本轮上下文,结论尚未并入会话摘要」。`droppedCount === 0` 不加。`route.ts` 四条用户回合都读 `historyWindow.droppedCount`。 +- **BUG-731**:`chatSessionCreateSchema` 增加可选 `continued_from_session_id`(保持 `.strict()`,**不加** `context_summary`)。`POST /api/sessions` 按当前用户读源会话摘要,读到才写入新行;读不到 / 别人的 / 空都静默跳过,仍 201。`continueInNewChat` 把当前会话 id 传给 `startNewChat`。界面无新文案。 + +未改摘要机制本身(800 汉字、15 秒 ref 计时器、乐观并发、结算后异步)。未用 `AbortSignal.timeout()` 替换摘要超时。未把 `messages` 加回列表 GET。未动数据库。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `session-context-summary.test.ts` | `CONSULTATION_HISTORY_TAIL_MAX_CHARS === 16_000`;`shouldCheckpoint` 只测 15_999 / 16_001 | `consultationHistoryCheckpointChars(128_000) === 16_000`;另测 64k 阈值 2_400 | BUG-730:阈值由预算派生,128k 行为与改前一致 | +| `chat-session-write.test.ts` 源码合同 | `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data` | 另拆 `continued_from_session_id`,不随 values 插入 | 该字段不是表列 | +| `chat-session-url.test.ts` / `composer-isolation-contract.test.ts` | 切片起点 `async function startNewChat()` | `async function startNewChat(` | 可带来源会话 id | +| `consultation-context-cache-contract.test.ts` | 检查点不传窗口 | 传 `sessionContextWindow`;历史文件不得再出现字面量 `16_000` | BUG-730 | + +128k 检查点阈值:**原值 16,000 / 新值 16,000 / 原因** 比例 0.4 × 预算 40,000。未弱化其它既有断言。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `./node_modules/.bin/tsc --noEmit` | **0 错** | +| `npm run lint` | **0 error**(119 条既有 warning;本单未新增 error) | +| 本单相关 `npx tsx --test`(history / summary / cache contract / chat-session-write / authority / url / composer-isolation / consultation-context) | **69 pass / 0 fail** | +| `npx tsx --test tests/consultation*.test.ts tests/chat-session-*.test.ts tests/session-*.test.ts` | 264 项 / 252 pass / **12 fail**:3 个文件级失败 + 9 条 methodology,全是 Windows `SkillPackageRegistryError`(EPERM symlink),与本单文件无关 | +| `npm test`(`tests/*.test.ts tests/*.test.tsx`) | `# tests 3110 / # pass 3023 / # fail 73 / # skipped 14`。失败清单不含本单文件。对照近期同机 `PROGRESS-chart-page-blocking-open-20260915.md` 的 fail **73**,**零新增失败** | +| `page.tsx` | 未改 | +| `npm run build` | compile + TypeScript 过;Collecting page data 死在既有 `SkillPackageRegistryError`(EPERM symlink `/api/daily-starlanguage`),与本单无关。未能从本机构建表确认 `/` 的 `○ Static` 与首屏 gzip。源码:`page.tsx` 无 `force-dynamic`;既有合同「home stays a client-read query on a static route」本单相关套件已绿 | + +## 环境缺口 + +- 本机 Windows 无开发者模式 symlink:`skill-package-registry` 建 live runtime alias 报 EPERM。因此 `next build` 收集页面数据失败;若干 consult/methodology/skill-binding 测试整文件红。Linux CI / staging 不受影响。 +- 无 Docker:`tests/database-*.test.ts` 照常红,与基线同类。 +- 无登录态、无 Chrome:写满后「开新对话」的静默继承未做浏览器走查。界面无新文案,源码合同已锁「开新对话」按钮与禁止「接着上次聊」类句子。 + +## 收尾限制 + +- 摘要失败仍只 `console.warn("session context summary failed")`,不打印摘要正文。 +- 侧栏「新建对话」不带 `continued_from_session_id`,只有写满出口的「开新对话」会静默继承。 +- 源会话属于别人或没有摘要时新行 `context_summary` 为空,接口仍 201。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f9211371..1a3c1248 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -239,7 +239,7 @@ | `TASK-rectification-settled-render-split-20260915.md` | — | **前端性能单(独占校正会话组件,可并行)**:`rectification-agentic-chat.tsx` 1973 行、`useMemo` 0 个、`memo` 0 个,`messages.map` 内联在组件体里且逐条新建时间轴数组与 choice card,`ChatMessageRow` 无 memo、结算态 Markdown 走没有缓存的 `renderProse`。流式每帧(~60/s)重渲整条会话并重跑每条已结算消息的 Markdown。BUG-473 在本文件只落地了 `stream-frame-buffer`,咨询面的 `SettledMessageList` + `HistoryMessageEntry` 拆分没有跟过来。**零行为变化**;验收必须有按帧驱动的渲染计数断言(照 `home-streaming-render-split.test.ts`)。BUG 段 725 | 待领取 | — | | `TASK-rectification-request-dossier-cache-20260915.md` | — | **低风险单,串行在 failure-attribution 之后(同改 `route.ts`)**:一轮 Agent 对话实测取 3.44 次整份 Case 档案(点选题 2.07 次),全仓约 40 个调用点、请求内零缓存;档案是「最近 50 轮 turns + 全部 evidence + 合成收据」的大 jsonb。做法是包装 `accounting` 客户端做**写即失效**的请求作用域缓存(两个只读投影命中缓存,其余任何 RPC 先清空再转发),**零调用点改动**。不得做成「请求内只读一次」——档案在请求内会变。BUG 段 726 | 待领取 | — | | `TASK-consultation-external-evidence-cache-20260915.md` | `PROGRESS-consultation-external-evidence-cache-20260915.md` | **普通聊天性能单(Python;2026-09-15 产品拍板改为排在 api-server-decomposition 之前)**:每轮每域同步等外网,cProfile 前三名全是 `api.vedastro.org` 的 HTTPS 往返(0.801 + 0.786 + 0.206 s),本地 swisseph 只有 0.022 s。三个护栏数字凑不齐:前台等 1.5 s、后台跑 8 s、线程池只有 2 个 worker,且超时**不 cancel** → 每 4 秒一轮就长期饱和,之后每轮白等再拿 `official_blocked`(BUG-727)。另 `western_evidence_packet` 122 KB 前端零读取点(BUG-728)。**产品定案**:按「出生数据+岁差+交点+UTC 日期」缓存(与引擎 `_official_snapshot_reference_date` 同键,否决自定 TTL),同日 0 等待 / 跨日先用旧的(≤7 天)后台刷新 / `daily_starlanguage` 要求当天 / 冷启动才走 1.5 s。**不许「干脆不调」——那会重开 BUG-301。** 另含 staging 单域耗时实测单(代码注释里的 21 s 与本机 0.5 s 差 40 倍,三域上限就是从它推的)。BUG 段 727–728 | 待验收 | `codex/consultation-external-evidence-cache-20260915` | -| `TASK-consultation-context-memory-20260915.md` | — | **记忆三缺口(TS,可并行)**:历史超预算时从最老整轮丢弃,`droppedCount` 算了却**全仓零读取点**,模型不知道少看了几轮——单条截断有「省略 N 字」标记,整轮丢弃没有(BUG-729,BUG-555 防复发只写了「头部截断」所以漏网);写摘要阈值写死 16,000,历史预算却是 `clamp((窗口−60k)×1.5, 4k, 40k)`,窗口 < **70,667** 时预算低于阈值 → 每轮静默丢(BUG-730,后台上架中等窗口模型即触发);写满时服务端存着摘要,`continueInNewChat` 只带问题不带摘要,而 `context_summary` 根本不在任何会话接口的列里(BUG-731)。**产品定案:静默继承**,且摘要文本永远不许由客户端提供(`chatSessionCreateSchema` 只收来源会话 uuid)。BUG 段 729–731 | 待领取 | — | +| `TASK-consultation-context-memory-20260915.md` | `PROGRESS-consultation-context-memory-20260915.md` | **记忆三缺口(TS,可并行)**:历史超预算时从最老整轮丢弃,`droppedCount` 算了却**全仓零读取点**,模型不知道少看了几轮——单条截断有「省略 N 字」标记,整轮丢弃没有(BUG-729,BUG-555 防复发只写了「头部截断」所以漏网);写摘要阈值写死 16,000,历史预算却是 `clamp((窗口−60k)×1.5, 4k, 40k)`,窗口 < **70,667** 时预算低于阈值 → 每轮静默丢(BUG-730,后台上架中等窗口模型即触发);写满时服务端存着摘要,`continueInNewChat` 只带问题不带摘要,而 `context_summary` 根本不在任何会话接口的列里(BUG-731)。**产品定案:静默继承**,且摘要文本永远不许由客户端提供(`chatSessionCreateSchema` 只收来源会话 uuid)。BUG 段 729–731 | 待验收 | `codex/consultation-context-memory-20260915` | | `TASK-consultation-session-capacity-20260915.md` | — | **对话上限单(一份迁移,可并行;不碰 route.ts)**:`append_consultation_question` 的 200,000 字符额度里,`thinkingText`(≤4,000) + `thinkingSections`(实测 1,521/2,243/2,977) 占一半以上,而 `techniqueTruth`/`workflowReceipt`/`agentExecutionReceipt` 照样入库却不计入——同一条上限身兼二职且两职都没做好,约 **19 轮** 就「已写满」(200 条那档永远碰不到)。**产品定案:思考文本不计入**,额度只数用户读得到的正文(约 19 → 约 50 轮),另设一条按 `length(elem::text)` 把全部字段算全的物理上限(算式取 1,000,000,写进迁移注释)护住数据库行;两档都返回同一个 `session_full`。保留 advisory lock / 幂等 / 满员拒绝(BUG-464 防复发)。BUG 段 732 | 待领取 | — | | `TASK-freeze-metric-change-20260915.md` | — | **规则单(后面两单的前置,无 BUG 号)**:两条增长冻结余量都用完(`page.tsx` 1,951/1,951 余 **0**;`jyotish_api_server.py` 11,334/11,363 余 **29**),冻结从「逼新代码往外走」退化成「拦路」。实证:`page.tsx` 行数砍 59% 但 `Home()` 的 `useState` 从 56 涨到 **66**(拆的是代码不是状态);api server **225 个类方法只有 12 处真碰 HTTP 上下文**,4 处 `__new__` 伪造空壳就是这么来的。**产品拍板换口径**:主门改成「`Home()` 的 useState/useRef 不得增长」与「类方法数 + `__new__` 计数不得增长」,行数降级为粗护栏;**同时推翻 §6「参数式 hook 内部保持 0 个 React hook」**(那正是状态搬不走的原因)。改 `AGENTS.md` §6 + 两个合同测试,不碰业务代码 | 待领取 | — | | `TASK-home-state-lowering-20260915.md` | — | **page.tsx 状态下沉第一簇(串行在 freeze-metric-change + C2 + R3 之后)**:66 个 state 里 `rectification*` 占 **15** 个,而它们服务的 `` 本来就是 `dynamic()` 懒加载子树、挂着 24 个 props;`useRectificationSurface` 要解构约 56 个参数。把这簇搬进子树,`Home()` 的 useState 从 66 降到 ≤ 53。**零行为变化**;第一步必须先把 15 个逐个分类(只服务子树 / 外壳也要读)。产品否决了 Context Provider 与外部 store 两条路。不占 BUG 号 | 待领取 | — | diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 258ce8df..c7199d9e 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -330,6 +330,7 @@ export async function POST(request: Request) { { status: 503 }, ); } + const sessionContextWindow = sessionModel.contextWindow; if (parsed.data.entrypoint === "birth_time_rectification") { return NextResponse.json( @@ -353,7 +354,7 @@ export async function POST(request: Request) { // Client `history` stays in the request schema for old bundles and is not read. const contextSummary = parseSessionContextSummary(chatSession.context_summary); const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, { - contextWindow: sessionModel.contextWindow, + contextWindow: sessionContextWindow, }); const storedHistory = historyWindow.tail; const userControlledPrompt = [ @@ -596,6 +597,7 @@ export async function POST(request: Request) { await checkpointSessionContextSummary({ messages: sessionRow.messages, summary: sessionRow.context_summary, + contextWindow: sessionContextWindow, generateText: (prompt, signal) => generateSessionContextSummaryText(summaryModel, prompt, signal), update: async (summary, seenUpdatedAt) => { let query = supabase.from("chat_sessions") @@ -875,6 +877,7 @@ export async function POST(request: Request) { instruction: modeInstruction, extra: generalDailyContextPrompt(generalDailyContext), summaryText: historyWindow.summaryText, + droppedCount: historyWindow.droppedCount, question: resolvedQuestion.modelQuestion, }), }, @@ -1271,6 +1274,7 @@ export async function POST(request: Request) { instruction: generalNoMinuteInstruction(Boolean(generalDailyContext)), extra: generalDailyContextPrompt(generalDailyContext), summaryText: historyWindow.summaryText, + droppedCount: historyWindow.droppedCount, question: resolvedQuestion.modelQuestion, }), }, @@ -1355,6 +1359,7 @@ export async function POST(request: Request) { name, instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", summaryText: historyWindow.summaryText, + droppedCount: historyWindow.droppedCount, question: resolvedQuestion.modelQuestion, }), }, @@ -1379,6 +1384,7 @@ export async function POST(request: Request) { name, instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", summaryText: historyWindow.summaryText, + droppedCount: historyWindow.droppedCount, question: resolvedQuestion.modelQuestion, }), }, diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 16f60452..6c944067 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from "next/server"; -import { chatSessionCreateSchema, ChatSessionBodyTooLargeError, readChatSessionJson } from "@/lib/chat-session-write-contract"; +import { + chatSessionCreateInsertRow, + chatSessionCreateSchema, + ChatSessionBodyTooLargeError, + readChatSessionJson, +} from "@/lib/chat-session-write-contract"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; +import { resolveInheritedContextSummary } from "@/lib/session-context-summary"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { @@ -94,13 +100,28 @@ export async function POST(request: Request) { } const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request)); if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); - const { id, updated_at: _ignoredClientClock, ...values } = parsed.data; - const { error } = await supabase.from("chat_sessions").insert({ - id, - user_id: user.id, - ...values, - updated_at: new Date().toISOString(), + const { id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, ...values } = parsed.data; + const inheritedSummary = await resolveInheritedContextSummary({ + continuedFromSessionId, + loadOwnedSummary: async (sourceId) => { + const { data } = await supabase + .from("chat_sessions") + .select("context_summary") + .eq("id", sourceId) + .eq("user_id", user.id) + .maybeSingle(); + return data?.context_summary ?? null; + }, }); + const { error } = await supabase.from("chat_sessions").insert( + chatSessionCreateInsertRow({ + id, + userId: user.id, + values, + inheritedSummary, + updatedAt: new Date().toISOString(), + }), + ); if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); return NextResponse.json({ ok: true }, { status: 201 }); } catch (error) { diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 39970123..2d5fa971 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -135,7 +135,7 @@ export type ConsultationRunParams = { uiPreviewMode: MutableRefObject; persistSession: (session: ChatSession, mode?: "create" | "update") => Promise; updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void; - startNewChat: () => Promise; + startNewChat: (options?: { continuedFromSessionId?: string }) => Promise; continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise; refreshAccount: () => Promise; openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void; diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 40d4c29f..43a9de62 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -139,7 +139,11 @@ export function useSessionManagement(params: SessionManagementParams) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); } - async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { + async function persistSession( + session: ChatSession, + mode: "create" | "update" = "update", + options?: { continuedFromSessionId?: string }, + ) { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; const values = mode === "create" @@ -153,6 +157,9 @@ export function useSessionManagement(params: SessionManagementParams) { chart_profile_id: session.chartProfileId, chart_profile_name: session.chartProfileName, chart_profile_role: session.chartProfileRole, + ...(options?.continuedFromSessionId + ? { continued_from_session_id: options.continuedFromSessionId } + : {}), } : { title: session.title, @@ -192,7 +199,10 @@ export function useSessionManagement(params: SessionManagementParams) { async function continueInNewChat(prompt: { question: string; theme: Theme }) { setSessionFullPrompt(null); - const created = await startNewChat(); + const sourceSessionId = activeSessionId; + const created = await startNewChat( + sourceSessionId ? { continuedFromSessionId: sourceSessionId } : undefined, + ); if (!created) return; setDraft(prompt.question); setDraftTheme(prompt.theme); @@ -294,7 +304,7 @@ export function useSessionManagement(params: SessionManagementParams) { } } - async function startNewChat(): Promise { + async function startNewChat(options?: { continuedFromSessionId?: string }): Promise { if (!account || !modelCatalog || creatingSession) return null; const nextSession = { ...createSession(modelCatalog.defaultModelId), @@ -312,7 +322,13 @@ export function useSessionManagement(params: SessionManagementParams) { setComposerNotice(""); setRequestError(null); try { - await persistSession(nextSession, "create"); + await persistSession( + nextSession, + "create", + options?.continuedFromSessionId + ? { continuedFromSessionId: options.continuedFromSessionId } + : undefined, + ); return nextSession; } catch (caught) { setSessions((current) => current.filter((session) => session.id !== nextSession.id)); diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index b912b14b..711af6d2 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -59,6 +59,7 @@ export const chatSessionCreateSchema = z.object({ messages: z.array(chatMessageSchema).max(0), session_type: z.enum(["consultation", "birth_time_rectification"]), rectification_case_id: z.string().uuid().nullable(), + continued_from_session_id: z.string().uuid().optional(), ...chartBindingSchema, updated_at: z.string().datetime().optional(), }).strict(); @@ -148,11 +149,41 @@ export type ChatSessionCreate = Readonly<{ messages: readonly []; session_type: "consultation" | "birth_time_rectification"; rectification_case_id: string | null; + continued_from_session_id?: string; chart_profile_id?: string | null; chart_profile_name?: string | null; chart_profile_role?: "self" | "other" | null; }>; +export type ChatSessionCreateInsertValues = Readonly<{ + title: string; + theme: ConsultationDomain; + model_id: string; + messages: readonly unknown[]; + session_type: "consultation" | "birth_time_rectification"; + rectification_case_id: string | null; + chart_profile_id?: string | null; + chart_profile_name?: string | null; + chart_profile_role?: "self" | "other" | null; +}>; + +export function chatSessionCreateInsertRow(input: { + id: string; + userId: string; + values: ChatSessionCreateInsertValues; + inheritedSummary?: unknown; + updatedAt: string; +}): Record { + const row: Record = { + id: input.id, + user_id: input.userId, + ...input.values, + updated_at: input.updatedAt, + }; + if (input.inheritedSummary) row.context_summary = input.inheritedSummary; + return row; +} + export type ChatSessionWrite = Readonly<{ title: string; theme: ConsultationDomain; diff --git a/frontend/src/lib/consultation-session-history.ts b/frontend/src/lib/consultation-session-history.ts index 5905c2c0..55d50d13 100644 --- a/frontend/src/lib/consultation-session-history.ts +++ b/frontend/src/lib/consultation-session-history.ts @@ -1,11 +1,14 @@ export const CONSULTATION_HISTORY_LIMIT = 12; export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000; -export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000; export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000; export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5; export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000; export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000; export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000; +// 0.4 of the history budget. At the default 128k window the budget is 40,000, +// so the checkpoint fires at 16,000 — the previous hard-coded threshold. +// Smaller windows then checkpoint before the tail can exceed the budget. +export const CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO = 0.4; export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】"; @@ -51,6 +54,15 @@ export function historyBudgetChars(contextWindow: number | null | undefined): nu ); } +export function consultationHistoryCheckpointChars(contextWindow: number | null | undefined): number { + const budget = historyBudgetChars(contextWindow); + const threshold = Math.floor(budget * CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO); + if (!(threshold < budget)) { + throw new Error("consultation history checkpoint threshold must stay below the history budget"); + } + return threshold; +} + export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const row = value as Record; @@ -74,6 +86,13 @@ export function omissionMarker(omittedChars: number): string { return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`; } +export function droppedRoundsMarker(droppedCount: number, hasSummary: boolean): string { + if (droppedCount <= 0) return ""; + return hasSummary + ? `……(更早的 ${droppedCount} 轮问答已并入上面的会话摘要)` + : `……(更早的 ${droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要)`; +} + export function clipConsultationHistoryText(text: string): string { if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text; const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS; @@ -165,16 +184,18 @@ export function consultationUserTurnContent(input: { instruction: string; extra?: string; summaryText?: string | null; + droppedCount?: number; question: string; }): string { + const summary = input.summaryText?.trim() ?? ""; + const dropped = droppedRoundsMarker(input.droppedCount ?? 0, Boolean(summary)); return [ input.currentTime, input.name ? `用户称呼:${input.name}` : "", input.instruction, input.extra ?? "", - input.summaryText?.trim() - ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}` - : "", + summary ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${summary}` : "", + dropped, input.question, ].filter(Boolean).join("\n"); } diff --git a/frontend/src/lib/session-context-summary.ts b/frontend/src/lib/session-context-summary.ts index 2df462d3..cc49ae9a 100644 --- a/frontend/src/lib/session-context-summary.ts +++ b/frontend/src/lib/session-context-summary.ts @@ -1,7 +1,7 @@ import { Agent } from "@mastra/core/agent"; import { - CONSULTATION_HISTORY_TAIL_MAX_CHARS, + consultationHistoryCheckpointChars, lastConsultationPair, parseSessionContextSummary, storedConsultationTurns, @@ -98,7 +98,7 @@ export function sanitizeSessionContextSummary(raw: string): string | null { export function tailCharCount( messages: unknown, summary: SessionContextSummaryV1 | null, - options: { excludeRequestId?: string } = {}, + options: { excludeRequestId?: string; contextWindow?: number | null } = {}, ): number { const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId }); const tail = summary @@ -110,9 +110,10 @@ export function tailCharCount( export function shouldCheckpoint( messages: unknown, summary: SessionContextSummaryV1 | null, - options: { excludeRequestId?: string } = {}, + options: { excludeRequestId?: string; contextWindow?: number | null } = {}, ): boolean { - return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS; + return tailCharCount(messages, summary, options) + > consultationHistoryCheckpointChars(options.contextWindow); } export function messagesForSummaryInput( @@ -215,6 +216,19 @@ export function nextSessionContextSummary( }; } +export async function resolveInheritedContextSummary(input: { + continuedFromSessionId?: string | null; + loadOwnedSummary: (sessionId: string) => Promise; +}): Promise { + const sourceId = input.continuedFromSessionId?.trim(); + if (!sourceId) return null; + try { + return parseSessionContextSummary(await input.loadOwnedSummary(sourceId)); + } catch { + return null; + } +} + export async function writeSessionContextSummary(input: { seenUpdatedAt: string | null; summary: SessionContextSummaryV1; @@ -228,13 +242,17 @@ export async function checkpointSessionContextSummary(input: { messages: unknown; summary: unknown; excludeRequestId?: string; + contextWindow?: number | null; now?: () => Date; generateText: (prompt: string, signal?: AbortSignal) => Promise; update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise; timeoutMs?: number; }): Promise<"written" | "skipped" | "abandoned" | "failed"> { const previous = parseSessionContextSummary(input.summary); - if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) { + if (!shouldCheckpoint(input.messages, previous, { + excludeRequestId: input.excludeRequestId, + contextWindow: input.contextWindow, + })) { return "skipped"; } try { diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index 1cea6ce4..1f7efd51 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -20,6 +20,10 @@ test("session list GET omits messages while detail GET returns them", () => { /SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/, ); assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/); + assert.doesNotMatch( + listRoute, + /SESSION_LIST_COLUMNS = "[^"]*context_summary/, + ); assert.match(itemRoute, /export async function GET/); assert.match( itemRoute, @@ -52,6 +56,8 @@ test("consult appends the user question after reserve and returns session_full", assert.match(sendSource, /caught\.code === "session_full"/); assert.match(sendSource, /label: "开新对话"/); assert.match(sendSource, /continueInNewChat\(\{ question: originalQuestion, theme \}\)/); + assert.match(page, /continuedFromSessionId: sourceSessionId/); + assert.doesNotMatch(page, /接着上次|继续上次聊|继承会话摘要|从上次对话继续/); }); test("PATCH compatibility accepts and ignores a legacy messages write", () => { diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index 83df39a0..de01fdd1 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -173,7 +173,7 @@ test("popstate to a missing session query reuses selectSession side effects for }); test("creating and leaving a session keep the address bar in sync", () => { - const startNewChat = sourceBetween(page, "async function startNewChat()", "function selectSession("); + const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession("); assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/); assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index f674c0f2..4e6c87de 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; +import { + chatSessionCreateInsertRow, + chatSessionCreateSchema, + chatSessionMetadataPatchSchema, + chatSessionWriteSchema, + writeChatSession, + type ChatSessionWrite, +} from "../src/lib/chat-session-write-contract.ts"; import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts"; import { homeSurface } from "./home-surface.ts"; @@ -41,6 +48,61 @@ test("create schema keeps the client-generated session id after transcript limit assert.equal(parsed.messages.length, 0); }); +test("create schema accepts continued_from_session_id and rejects client-supplied context_summary", () => { + const parsed = chatSessionCreateSchema.parse({ + id: sessionId, + ...createValues, + continued_from_session_id: "22222222-2222-4222-8222-222222222222", + }); + assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222"); + assert.equal("context_summary" in parsed, false); + assert.equal("context_summary" in chatSessionCreateSchema.shape, false); + + const withSummary = chatSessionCreateSchema.safeParse({ + id: sessionId, + ...createValues, + context_summary: { version: 1, text: "injected" }, + }); + assert.equal(withSummary.success, false); + + const badId = chatSessionCreateSchema.safeParse({ + id: sessionId, + ...createValues, + continued_from_session_id: "not-a-uuid", + }); + assert.equal(badId.success, false); +}); + +test("create insert copies an owned summary and omits a missing one", () => { + const source = { + version: 1 as const, + text: "已问过的问题\n事业时机", + throughRequestId: "a1", + throughMessageIndex: 3, + messageCount: 4, + updatedAt: "2026-09-15T00:00:00.000Z", + }; + const copied = chatSessionCreateInsertRow({ + id: sessionId, + userId: "owner", + values: createValues, + inheritedSummary: source, + updatedAt: "2026-09-16T00:00:00.000Z", + }); + assert.deepEqual(copied.context_summary, source); + assert.equal("continued_from_session_id" in copied, false); + + const skipped = chatSessionCreateInsertRow({ + id: sessionId, + userId: "owner", + values: createValues, + inheritedSummary: null, + updatedAt: "2026-09-16T00:00:00.000Z", + }); + assert.equal("context_summary" in skipped, false); + assert.equal(skipped.user_id, "owner"); +}); + test("create schema rejects a non-empty transcript", () => { // Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a // history-import path. Create is now only an empty session. @@ -122,6 +184,19 @@ test("metadata patch accepts pin and archive fields without a transcript", () => assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null }); }); +test("create write may carry continued_from_session_id and never context_summary", async () => { + const calls: Array> = []; + await writeChatSession(sessionId, { + ...createValues, + continued_from_session_id: "22222222-2222-4222-8222-222222222222", + }, "create", async (_url, init) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json({ ok: true }, { status: 201 }); + }); + assert.equal(calls[0]?.continued_from_session_id, "22222222-2222-4222-8222-222222222222"); + assert.equal("context_summary" in (calls[0] ?? {}), false); +}); + test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => { @@ -205,8 +280,17 @@ test("session API owns create and update while answer UI keeps sync failures out assert.match(itemRoute, /readChatSessionJson/); assert.match(collectionRoute, /ChatSessionBodyTooLargeError/); assert.match(itemRoute, /ChatSessionBodyTooLargeError/); - // Former value: `const { id, ...values } = parsed.data` trusted the client clock. - assert.match(collectionRoute, /const \{ id, updated_at: _ignoredClientClock, \.\.\.values \} = parsed\.data/); + // Former value: `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data` + // New value: also peel `continued_from_session_id` so it is never inserted as a column. + assert.match( + collectionRoute, + /const \{ id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, \.\.\.values \} = parsed\.data/, + ); + assert.match(collectionRoute, /resolveInheritedContextSummary/); + assert.match(collectionRoute, /chatSessionCreateInsertRow/); + assert.match(collectionRoute, /\.eq\("user_id", user\.id\)/); + assert.match(collectionRoute, /status: 201/); + assert.doesNotMatch(collectionRoute, /context_summary: parsed/); assert.match(contract, /function limitTranscriptSize \}>/); assert.match(contract, /\): z\.ZodType \{/); assert.match(page, /chartSnapshotForSession/); diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 316b1245..3668a603 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -91,7 +91,7 @@ test("every external draft writer keeps working through the page-owned setters", // When: each existing write path is inspected. const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "async function startSuggestedConsultation"); - const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession("); + const startNewChat = sourceBetween(pageSource, "async function startNewChat(", "function selectSession("); const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel"); const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth"); const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface"); diff --git a/frontend/tests/consultation-context-cache-contract.test.ts b/frontend/tests/consultation-context-cache-contract.test.ts index 23f45aa8..0c1045ec 100644 --- a/frontend/tests/consultation-context-cache-contract.test.ts +++ b/frontend/tests/consultation-context-cache-contract.test.ts @@ -14,7 +14,10 @@ test("consult history uses the checkpoint tail and puts the summary after the ti assert.match(consultRoute, /consultationHistoryWindow\(chatSession\.messages, contextSummary/); assert.match(consultRoute, /SESSION_CONTEXT_SUMMARY_HEADING|consultationUserTurnContent/); assert.match(consultRoute, /summaryText: historyWindow\.summaryText/); + assert.match(consultRoute, /droppedCount: historyWindow\.droppedCount/); assert.match(history, /【会话摘要(服务端维护)】/); + assert.match(history, /droppedRoundsMarker/); + assert.doesNotMatch(history, /16_000|16000/); const helper = history.slice(history.indexOf("export function consultationUserTurnContent")); const timeLine = helper.indexOf("input.currentTime"); const summaryLine = helper.indexOf("SESSION_CONTEXT_SUMMARY_HEADING"); @@ -33,9 +36,13 @@ test("consult retries once on context overflow with summary plus the last pair", test("consult checkpoints the session summary after a successful completion", () => { assert.match(consultRoute, /void checkpointConsultationContext\(\)/); assert.match(consultRoute, /checkpointSessionContextSummary/); + assert.match(consultRoute, /contextWindow: sessionContextWindow/); assert.match(consultRoute, /context_summary ->>updatedAt|context_summary->>updatedAt/); assert.match(consultRoute, /console\.warn\("session context summary failed"/); assert.doesNotMatch(consultRoute, /AbortSignal\.timeout\(\s*15/); + const summary = readFileSync(new URL("../src/lib/session-context-summary.ts", import.meta.url), "utf8"); + assert.match(summary, /consultationHistoryCheckpointChars/); + assert.doesNotMatch(summary, /CONSULTATION_HISTORY_TAIL_MAX_CHARS/); }); test("the context summary migration only adds one jsonb column", () => { diff --git a/frontend/tests/consultation-session-history.test.ts b/frontend/tests/consultation-session-history.test.ts index 04338ceb..4b1ff83c 100644 --- a/frontend/tests/consultation-session-history.test.ts +++ b/frontend/tests/consultation-session-history.test.ts @@ -2,11 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO, CONSULTATION_HISTORY_MESSAGE_CHARS, clipConsultationHistoryText, + consultationHistoryCheckpointChars, consultationHistoryFromStoredMessages, consultationHistoryWindow, consultationUserTurnContent, + droppedRoundsMarker, historyBudgetChars, isContextOverflowError, omissionMarker, @@ -77,6 +80,61 @@ test("historyBudgetChars uses 64k, 32k, and null-as-128k windows", () => { assert.equal(historyBudgetChars(undefined), 40_000); }); +test("checkpoint threshold stays below the history budget for legal context windows", () => { + assert.equal(CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO, 0.4); + const windows = [200_000, 128_000, 64_000, 32_000, null] as const; + for (const window of windows) { + const budget = historyBudgetChars(window); + const threshold = consultationHistoryCheckpointChars(window); + assert.ok(threshold < budget, `window=${window}`); + } + // 128k keeps the previous 16,000-character checkpoint. + assert.equal(consultationHistoryCheckpointChars(128_000), 16_000); + assert.equal(consultationHistoryCheckpointChars(null), 16_000); + assert.equal(consultationHistoryCheckpointChars(64_000), 2_400); + assert.equal(consultationHistoryCheckpointChars(32_000), 1_600); +}); + +test("dropped whole turns leave an omission marker in the user-turn summary slot", () => { + const body = "x".repeat(3_000); + const history = consultationHistoryWindow(numberedMessages(20, () => body), null, { + contextWindow: 128_000, + }); + assert.ok(history.droppedCount > 0); + const withoutSummary = consultationUserTurnContent({ + currentTime: "当前时间:2026-09-16 12:00(中国)", + instruction: "先加载 Jyotish Skill", + summaryText: history.summaryText, + droppedCount: history.droppedCount, + question: "刚才你说的那个时间", + }); + assert.match( + withoutSummary, + new RegExp(`更早的 ${history.droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要`), + ); + assert.equal(withoutSummary.includes(SESSION_CONTEXT_SUMMARY_HEADING), false); + + const withSummary = consultationUserTurnContent({ + currentTime: "当前时间:2026-09-16 12:00(中国)", + instruction: "先加载 Jyotish Skill", + summaryText: "先前结论", + droppedCount: history.droppedCount, + question: "刚才你说的那个时间", + }); + assert.match(withSummary, new RegExp(`更早的 ${history.droppedCount} 轮问答已并入上面的会话摘要`)); + assert.ok(withSummary.indexOf(SESSION_CONTEXT_SUMMARY_HEADING) < withSummary.indexOf("刚才你说的那个时间")); + + const kept = consultationUserTurnContent({ + currentTime: "当前时间:2026-09-16 12:00(中国)", + instruction: "先加载 Jyotish Skill", + summaryText: "先前结论", + droppedCount: 0, + question: "刚才你说的那个时间", + }); + assert.equal(kept.includes("更早的"), false); + assert.equal(droppedRoundsMarker(0, true), ""); +}); + test("stored consultation history can skip the in-flight request id", () => { const history = consultationHistoryFromStoredMessages([ { role: "user", text: "old", requestId: "keep" }, diff --git a/frontend/tests/session-context-summary.test.ts b/frontend/tests/session-context-summary.test.ts index 8a8b5836..76e182ef 100644 --- a/frontend/tests/session-context-summary.test.ts +++ b/frontend/tests/session-context-summary.test.ts @@ -3,13 +3,14 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { - CONSULTATION_HISTORY_TAIL_MAX_CHARS, + consultationHistoryCheckpointChars, } from "../src/lib/consultation-session-history.ts"; import { buildSummaryPrompt, checkpointSessionContextSummary, generateSessionContextSummary, messagesForSummaryInput, + resolveInheritedContextSummary, sanitizeSessionContextSummary, shouldCheckpoint, writeSessionContextSummary, @@ -24,10 +25,14 @@ function overBudgetConversation() { ]; } -test("checkpoint triggers only when the tail exceeds 16_000 characters", () => { - assert.equal(CONSULTATION_HISTORY_TAIL_MAX_CHARS, 16_000); +test("checkpoint triggers only when the tail exceeds the derived threshold", () => { + // Former value: hard-coded CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000. + // 128k still checkpoints at 16_000 (0.4 × 40_000 budget). + assert.equal(consultationHistoryCheckpointChars(128_000), 16_000); assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(15_999) }], null), false); assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(16_001) }], null), true); + assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_400) }], null, { contextWindow: 64_000 }), false); + assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_401) }], null, { contextWindow: 64_000 }), true); }); test("checkpoint prompt omits the last question-answer pair", () => { @@ -108,6 +113,41 @@ test("writeSessionContextSummary abandons when updatedAt does not match", async assert.equal(result, "abandoned"); }); +test("inherited context summary copies owned text and skips foreign or empty sources", async () => { + const source = { + version: 1 as const, + text: "已问过的问题\n事业时机", + throughRequestId: "a1", + throughMessageIndex: 3, + messageCount: 4, + updatedAt: "2026-09-15T00:00:00.000Z", + }; + const copied = await resolveInheritedContextSummary({ + continuedFromSessionId: "11111111-1111-4111-8111-111111111111", + loadOwnedSummary: async () => source, + }); + assert.deepEqual(copied, source); + + const foreign = await resolveInheritedContextSummary({ + continuedFromSessionId: "22222222-2222-4222-8222-222222222222", + loadOwnedSummary: async () => null, + }); + assert.equal(foreign, null); + + const empty = await resolveInheritedContextSummary({ + continuedFromSessionId: "11111111-1111-4111-8111-111111111111", + loadOwnedSummary: async () => ({ version: 1, text: " " }), + }); + assert.equal(empty, null); + + const skipped = await resolveInheritedContextSummary({ + loadOwnedSummary: async () => { + throw new Error("should not load"); + }, + }); + assert.equal(skipped, null); +}); + test("checkpoint writes a new summary when the tail is over budget", async () => { const result = await checkpointSessionContextSummary({ messages: overBudgetConversation(),