diff --git a/docs/INDEX.md b/docs/INDEX.md index 690e40c4..9db13510 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -172,7 +172,7 @@ src/ | 文件/目录 | 职责 | |---|---| -| `main.tsx` / `App.tsx` | 入口 / 视图切换(chat/projects)+ 事件桥:onEvent → `EventConflator` rAF 合流 → transcript store(桥在 `hooks/use-session-event-bridge.ts`)。订阅纪律:App 只订阅原始值(子树无 memo,订阅 transcript 对象会随每条流式 delta 全级联) | +| `main.tsx` / `App.tsx` | 入口 / 视图切换(chat/projects)+ 事件桥:onEvent → `EventConflator` rAF 合流 → transcript store(桥在 `hooks/use-session-event-bridge.ts`;`session_info_changed` 同步 sessions + projects 两份名称投影,防 GC 后标题回退)。订阅纪律:App 只订阅原始值(子树无 memo,订阅 transcript 对象会随每条流式 delta 全级联) | | `bootstrap-theme.ts` | 首帧前写 `data-theme`(读 `?theme=` query,防开屏闪色) | | `monaco-contribs.ts` | monaco worker 接管 + 懒加载服务补注册(出现新 UNKNOWN service 报错时按同法在此补模块) | | `splash-dom.ts` / `splash.ts` / `styles/splash.css` | 开屏动画三件:DOM/粒子参数(`DOT_COUNT`)→ 时长与单次标记(sessionStorage)→ 全部样式与收场编排(改视觉只动这三个文件) | @@ -229,7 +229,7 @@ src/ | show_image 发图 | 工具本体 `backend/src/tools/show-image.ts`;实时 = shared reducer(pendingImages 缓冲,turn_end 固化排 assistant 之后);历史 = `toSessionMessages` 的 `role:"image"`;渲染 = MessageItem image 分支(缩略图按数量分档) | | subagent 独立行 | 工具 `backend/src/tools/subagent/`;提取 shared `src/subagent.ts`(extractSubagentRuns,结构检测不依赖工具名);互斥通知 `subagent_mutex` → reducer system 消息(dedup by extensionPath);渲染 `chat/SubagentRunCard.tsx`(行内 11px ink-faint 展示实际思考档位;有 `sessionFile` 的行可点 → `openFromHistory` 打开**只读**检视页)。**检视会话只从导航投影过滤**(`lib/session-visibility.ts`):它留在 store `sessions` 里,但不进左栏分组/计数/搜索与顶栏胶囊 | | 斜杠命令 | 面板 `composer/SlashMenu.tsx` + `slash-filter.ts` + `use-slash-menu.ts`;命令表 backend `slash-commands.ts`(**新会话页(无 active)按 `cwd` 走 `listSlashCommandsForCwd`,真实会话按 `sessionId`**);**模板/skill/扩展命令 SDK 原生展开无需代码**;`/settings` 定位走 settings store `openWith()` | -| 上下文压缩 UI | compaction_start/end → shared reducer 生成 system 消息 + compacting 位(**压缩期间 Composer 禁发**,SDK 拒绝压缩中的 prompt);渲染 `chat/SystemMessage.tsx`(分割线,done 可展开摘要);手动 `/compact [focus]`(focus 拼入摘要 prompt);**压缩后 UI 历史完整保留**(SDK 只裁 LLM 上下文,jsonl 完整,reducer 只追加分界线) | +| 上下文压缩 UI | compaction_start/end → shared reducer 生成 system 消息 + compacting 位(**压缩期间 Composer 禁发**,SDK 拒绝压缩中的 prompt);渲染 `chat/SystemMessage.tsx`(分割线,done 可展开摘要);手动 `/compact [focus]`(focus 拼入摘要 prompt);**压缩后 UI 历史完整保留**:live reducer 只追加分界线;重开回放由 backend `toBranchSessionMessages(sessionManager.getBranch())` 读取 JSONL 当前分支完整 message entries,禁止读取已被压缩的 `AgentSession.messages` 模型上下文 | | 上下文蒸发 | backend `tools/context-evaporation/`(见 backend 表);开关 = 设置 GeneralPanel 二态(默认蒸发,写 settings.json 单 key,2s 生效免重开);调参 `scripts/replay-evaporation.mts`;观测 = log `context-evaporation` 行 + trace_custom | | 上下文用量圆环 | `composer/ContextRing.tsx` + `hooks/use-context-usage.ts`(事件驱动刷新,与插件 host API 共用)→ IPC getContextUsage → SDK `session.getContextUsage()`(percent null 或无消息不渲染;<60% 灰 / 60-85% 琥珀 / >85% 红) | | 每轮计时行 + 文件变更 chip + diff 侧栏 | 计时 shared `transcript/turn-timings.ts`(deriveTurnTimings;分量 = reducer 盖戳的 `UIToolCall.endedAt` + `runEndedAt` 定格 + 历史回放透传 toolResult timestamp);变更 shared `transcript/turn-files.ts`(deriveTurnChanges)+ `chat-rows.ts`(行定位:每轮必有计时行,lan-web 不传 timings 保持旧行为);渲染 `chat/TurnDiffChip.tsx`(timer 恒在首位)+ `diff/DiffSidebar.tsx`(含 BranchRow);开关 = SessionTabBar 的 DiffIcon 按钮 + `stores/ui.ts` diffSidebarOpen | diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 1e5d8c41..5ca32e87 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -67,6 +67,8 @@ | 同一会话文件并发 open 后订阅/扩展/trace 翻倍、旧实例泄漏 | 二 · openSession 幂等:registry 短路 + single-flight + add 不静默覆盖(2026-09-20) | | 复制/恢复过会话文件后,它在列表里的时间/位置全变了 | 二 · 同章节「birthtime 不是会话创建时间」 | | 新建的会话过一阵突然从左侧栏消失(点「+」/重启后又回来) | 四 · 会话目录写穿:行存不存在不能依赖内存(2026-09-21) | +| 左栏会话标题在项目目录名(如 `percho`)与首条用户消息之间反复切换 | 四 · 同章节「名称也要写回目录投影」(2026-09-22) | +| 会话压缩后当时历史还在,过段时间重新打开却只剩压缩后的内容 | 四 · UI 历史不能读取被压缩的模型上下文(2026-09-22) | | 给 store 加模块级订阅后,某些入口报 `Cannot read properties of undefined (reading 'subscribe')` | 四 · 同章节「renderer 模块图不许有环」(2026-09-21) | | 反复被 GC 卸载的已置顶会话,顶栏胶囊也一起消失了 | 四 · 同章节「写穿」:胶囊与左栏同源(tabs → 目录兜底) | @@ -547,6 +549,8 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b 对策(已落地):**目录是存在性的唯一来源,会话一进内存就写穿进去**。`stores/projects.ts` 底部一处 `useSessionsStore.subscribe`(会话一进内存就补进目录,**只补缺不覆盖**,磁盘权威的时间字段不被内存 meta 盖掉)+ `load()` 结尾再补一次(`0` 消息会话还没有会话文件,重拉的快照必然没有它)。内存 `sessions` 自此只负责运行态;GC 卸载 = 只撤运行态,不撤存在性。单测:`stores/projects.test.ts` 的「会话目录写穿」四例(含「卸载后左栏行仍在」)+ `lib/sidebar-groups.test.ts`。 +**名称也要写回目录投影(2026-09-22)**:新建会话的首份 meta 尚未命名,写穿目录后才由首条用户消息触发 `session_info_changed`。若事件只更新内存 `sessions`,会话在内存时标题正确;GC 卸载后只剩目录里的 `name: undefined`,`sessionTitle()` 就回退到 cwd 末级名(本次为 `percho`);再次打开后内存 meta 从磁盘读到正确名称,于是标题又变回首条消息,形成反复切换。修复是在事件桥同时更新 `sessions` 与 `projects.allSessions`;显式重命名原本就采用同样的双投影同步。回归用例覆盖「自动命名写回后 GC 卸载仍保留名称」。 + 连带两个教训: - **推导链条里任何一环「旧」,整条结论都不可信**:spec 里曾写着「左栏列的是磁盘历史,所以卸载不会让任何列表缺项」——这句话在快照新鲜时成立,而快照并不保证新鲜(`session-memory-policy.md` §1 已标注修正)。审查这类断言时要问「这份数据的**新鲜度**由谁保证」。 @@ -554,6 +558,14 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b 复现手法(可复用):dev 实例 + CDP 直接驱 store 走真实入口(`.local/dev-logs/repro-session-catalog.mjs`):`createSession()`(真实 promotion)→ `activateNewSessionDraft()` 切走 → `unloadSession(id)`(GC 真实入口)→ 断言 `inMemory=false` 但 `inCatalog=true` 且 `document.querySelector('[data-session-id=…]')` 仍在。**侧栏按组渲染,断言前要先展开该会话所在项目的组**(`setExpandedGroups([...groups, cwd])`),否则“行不在 DOM 里”是假阴性。 +### UI 历史不能读取被压缩的模型上下文(2026-09-22) + +症状:执行 compaction 后,当前界面的旧消息仍完整;过一段时间切走再回来,压缩前的大段内容却消失。目标会话文件有 1319 行、当前分支 1253 条 message entry,但两次压缩后 `AgentSession.messages` 只剩 251 条上下文消息;日志中的 GC close/open 正好对应“过段时间”这个触发点。 + +根因:实时 `compaction_end` 已刻意只追加分割线、不重置 UI,所以当下正常;但 GC 卸载再打开时,`getSessionMessages()` 错把 `AgentSession.messages` 当历史数据源。这个属性是**给模型下一轮请求使用的裁剪上下文**,不是完整会话历史。完整历史一直在 JSONL 会话树当前分支里,并未丢失。 + +对策:UI 回放统一读取 `sessionManager.getBranch()` 的全部 `message` entry,再转换并配对 entryId;模型调用仍使用 SDK 自己的 `session.messages`,两种语义不混用。文件只读透视也复用同一个 branch 转换函数。回归测试必须真实插入 compaction entry,并证明模型上下文缩短的同时 UI 历史仍包含压缩前消息。 + ## 五、工程纪律 ### renderer 单测跑在 node 环境:测不了 i18n 与返回 JSX 的模块函数(2026-09-20) diff --git a/packages/backend/src/pi-backend.ts b/packages/backend/src/pi-backend.ts index 15ccc594..194e29dd 100644 --- a/packages/backend/src/pi-backend.ts +++ b/packages/backend/src/pi-backend.ts @@ -71,14 +71,13 @@ import { addAllowedPattern, addWorkspaceRoot } from "./project/workspace-store"; import { slimBulkyEvent, slimMessageUpdate } from "./session/event-slim"; import { ExtensionDialogHost } from "./session/extension-dialog-host"; import { - assignEntryIds, blockImages, blockText, type RawMessage, readSessionMessagesFromContent, resolveForkEntryId, resolveRecallEntryId, - toSessionMessages, + toBranchSessionMessages, } from "./session/messages"; import { autoNameSession } from "./session/naming"; import { EventRateTracker } from "./session/rates"; @@ -867,13 +866,10 @@ export class PiBackend { return format === "html" ? entry.session.exportToHtml() : entry.session.exportToJsonl(); } - /** 读取会话历史消息(打开历史会话时回放给 UI) */ + /** 读取会话树当前分支的完整历史(compaction 只裁模型上下文,不裁 UI 历史) */ async getSessionMessages(sessionId: string): Promise { const entry = this.requireSession(sessionId); - const messages = toSessionMessages(entry.session.messages); - // 配对消息与会话树 entry id(assistant 供 fork 定位、user 供撤回定位) - assignEntryIds(messages, entry.session.sessionManager.getBranch()); - return messages; + return toBranchSessionMessages(entry.session.sessionManager.getBranch()); } /** diff --git a/packages/backend/src/session/messages.ts b/packages/backend/src/session/messages.ts index db14d416..ecbcbf81 100644 --- a/packages/backend/src/session/messages.ts +++ b/packages/backend/src/session/messages.ts @@ -180,16 +180,26 @@ export function readSessionMessagesFromContent(content: string): SessionMessage[ for (const entry of entries) { if (entry.type !== "session") byId.set(entry.id, entry); } - const branch: (typeof entries)[number][] = []; + const branch: SessionEntry[] = []; let cursor = entries.length > 0 ? entries[entries.length - 1] : null; while (cursor && cursor.type !== "session") { branch.unshift(cursor); cursor = cursor.parentId ? (byId.get(cursor.parentId) ?? null) : null; } + return toBranchSessionMessages(branch); +} + +/** + * 当前会话树分支 → UI 历史消息。必须读取 getBranch() 的完整持久历史,而不是 + * AgentSession.messages(后者是发给模型的上下文,compaction 后会裁掉旧消息)。 + */ +export function toBranchSessionMessages(branch: readonly SessionEntry[]): SessionMessage[] { const raw = branch .filter((entry): entry is SessionMessageEntry => entry.type === "message") .map((entry) => entry.message); - return toSessionMessages(raw); + const messages = toSessionMessages(raw); + assignEntryIds(messages, branch); + return messages; } /** diff --git a/packages/backend/test/recall.test.ts b/packages/backend/test/recall.test.ts index edfd5970..be937361 100644 --- a/packages/backend/test/recall.test.ts +++ b/packages/backend/test/recall.test.ts @@ -1,7 +1,7 @@ import type { Message } from "@earendil-works/pi-ai"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { describe, expect, it } from "vitest"; -import { resolveRecallEntryId } from "../src/session/messages"; +import { resolveRecallEntryId, toBranchSessionMessages } from "../src/session/messages"; /** 构造带两条用户消息(+一条回复在中间)的内存会话,返回 manager 与 entry id */ function makeSession() { @@ -60,6 +60,31 @@ describe("resolveRecallEntryId(撤回目标解析)", () => { }); }); +describe("压缩后的 UI 历史", () => { + it("读取完整会话树分支,而不是被 compaction 裁剪的模型上下文", () => { + const sm = SessionManager.inMemory(); + sm.appendMessage({ role: "user", content: "压缩前第一条", timestamp: 1 } satisfies Message); + sm.appendMessage({ role: "user", content: "压缩前第二条", timestamp: 2 } satisfies Message); + const kept = sm.appendMessage({ + role: "user", + content: "压缩前保留条目", + timestamp: 3, + } satisfies Message); + sm.appendCompaction("模型上下文摘要", kept, 10_000); + sm.appendMessage({ role: "user", content: "压缩后消息", timestamp: 4 } satisfies Message); + + const uiHistory = toBranchSessionMessages(sm.getBranch()); + expect(uiHistory.map((message) => message.text)).toEqual([ + "压缩前第一条", + "压缩前第二条", + "压缩前保留条目", + "压缩后消息", + ]); + // SDK 上下文确实已缩短,证明测试没有把两种数据源混为一谈。 + expect(sm.buildSessionContext().messages.length).toBeLessThan(4); + }); +}); + describe("撤回持久化机制(SessionManager 层语义)", () => { it("branch + custom entry 标记后,上下文排除被撤回消息且 leaf 落在标记上", () => { const sm = SessionManager.inMemory(); diff --git a/packages/desktop/src/renderer/src/hooks/use-session-event-bridge.ts b/packages/desktop/src/renderer/src/hooks/use-session-event-bridge.ts index 2ba3561a..b1e5231d 100644 --- a/packages/desktop/src/renderer/src/hooks/use-session-event-bridge.ts +++ b/packages/desktop/src/renderer/src/hooks/use-session-event-bridge.ts @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { getPi } from "../api"; import { useDraftStore } from "../stores/drafts"; import { EventConflator } from "../stores/event-conflator"; +import { useProjectsStore } from "../stores/projects"; import { useSessionsStore } from "../stores/sessions"; import { pushExtensionToast } from "../stores/toasts"; import { useTranscriptStore } from "../stores/transcript"; @@ -32,7 +33,10 @@ export function useSessionEventBridge({ const offEvent = pi.onEvent(({ sessionId, event }: { sessionId: string; event: SessionEvent }) => { if (event.type === "session_info_changed") { useSessionsStore.getState().updateSessionName(sessionId, event.name); - return; // 会话名走 sessions store;reducer 对该类型本就无操作 + // 新会话先以「未命名」meta 写穿进目录,首条消息随后才触发自动命名。 + // 目录也必须同步,否则 GC 卸载内存项后,左栏会退回 cwd 末级名(如 percho)。 + useProjectsStore.getState().applySessionName(sessionId, event.name); + return; // 会话名走 sessions + projects 两份投影;reducer 对该类型本就无操作 } conflator.push(sessionId, event); }); diff --git a/packages/desktop/src/renderer/src/stores/projects.test.ts b/packages/desktop/src/renderer/src/stores/projects.test.ts index 220aa0fb..e8e80170 100644 --- a/packages/desktop/src/renderer/src/stores/projects.test.ts +++ b/packages/desktop/src/renderer/src/stores/projects.test.ts @@ -162,6 +162,17 @@ describe("会话目录写穿(存在性只依赖目录)", () => { const rows = nav.projects.flatMap((project) => project.sessions.map((row) => row.session.sessionId)); expect(rows).toEqual(["new-1"]); }); + + it("自动命名写回目录后,GC 卸载不会退回项目目录名", () => { + const unnamed = { ...freshMeta("new-1", "/work/percho"), name: undefined }; + useSessionsStore.setState({ sessions: [unnamed] }); + + // 对应 session_info_changed 的目录投影同步。 + useProjectsStore.getState().applySessionName("new-1", "用户第一条消息"); + useSessionsStore.setState({ sessions: [] }); + + expect(useProjectsStore.getState().allSessions[0]?.name).toBe("用户第一条消息"); + }); }); describe("projects.load latest-wins(spec D6)", () => { diff --git a/packages/desktop/src/renderer/src/stores/projects.ts b/packages/desktop/src/renderer/src/stores/projects.ts index a49befc6..1e8d37ee 100644 --- a/packages/desktop/src/renderer/src/stores/projects.ts +++ b/packages/desktop/src/renderer/src/stores/projects.ts @@ -43,7 +43,7 @@ interface ProjectsStore { loaded: boolean; load: () => Promise; /** 重命名成功后同步历史列表(见实现处注释) */ - applySessionName: (sessionId: string, name: string) => void; + applySessionName: (sessionId: string, name: string | undefined) => void; select: (cwd: string | null) => void; setSearch: (search: string) => void; addProject: () => Promise; diff --git a/packages/desktop/src/renderer/src/stores/transcript.ts b/packages/desktop/src/renderer/src/stores/transcript.ts index 27c452e4..1d368e7d 100644 --- a/packages/desktop/src/renderer/src/stores/transcript.ts +++ b/packages/desktop/src/renderer/src/stores/transcript.ts @@ -219,7 +219,7 @@ export const useTranscriptStore = create((set) => ({ unseenCompletion: false, compacting: false, followUpQueue: current?.followUpQueue ?? [], - // loadHistory 只换消息流(compaction 后对齐 pi 裁剪),todo 列表保留 + // loadHistory 只换消息流(backend 回放会话树完整分支),todo 列表保留 todos: current?.todos ?? [], // 错误卡派生自消息流本身,pending 状态不穿越回放 pendingLlmError: null,