From 83f909250fd96db77cb1f6a805fd295761486f7c Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Sun, 12 Jul 2026 01:10:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(mention):=20@=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=8C=89=E5=85=B1=E5=90=8C=E5=8E=86=E5=8F=B2=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=80=89=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一 fork 树(有共同历史)的会话被 @ 引用时,注入共享水位之后的 全部 turn(不做预算截断,共享前缀不重复);无共同历史的会话维持 原有 4KB 预算摘要注入。用户无需在 summary/transcript 之间做选择。 - mention.ts 新增 sharedHistoryWatermark:沿 forkedFrom 链找共同 祖先,取沿途 throughSeq running-min 的较小者作为共同历史水位 - buildSessionContext 增加 fromSessionId 选项,按关系选投影 - expandMentions 增加 currentSessionId 参数;TUI/CLI 调用点传入 - 新增水位计算与两种投影路径的测试 --- src/cli/main.ts | 4 +- src/context/mention.ts | 106 +++++++++++++++++++++++++++++++++++++---- src/tui/protocol.ts | 2 +- tests/mention.test.ts | 85 +++++++++++++++++++++++++++++++-- 4 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index e7fa5e8..9022183 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -108,9 +108,9 @@ async function main(): Promise { continue; } - // @bs_xxx 急切展开:把被引用会话的紧凑摘要拼进 prompt(design §5.6) + // @bs_xxx 急切展开:把被引用会话的上下文拼进 prompt(design §5.6;投影方式按有无共同历史自动选择) session.setPreviewIfEmpty(line); - const { prompt, mentions } = expandMentions(store, line, config.mentionBudgetChars); + const { prompt, mentions } = expandMentions(store, line, config.mentionBudgetChars, session.id); if (mentions.length) stdout.write(`(injected context summaries from ${mentions.length} session(s))\n`); const turnId = newId("t"); diff --git a/src/context/mention.ts b/src/context/mention.ts index 4446345..fdba643 100644 --- a/src/context/mention.ts +++ b/src/context/mention.ts @@ -1,8 +1,15 @@ -// @ 引用的急切解析(MVP,design §5.6):发送时把目标会话的 turn-summary 压成紧凑文本, -// 以"用户提供的材料"身份拼进目标 agent 的 prompt。二期换 mention:// 句柄 + CLI 惰性回查。 +// @ 引用的急切解析(MVP,design §5.6):发送时把目标会话的内容拼进目标 agent 的 prompt, +// 以"用户提供的材料"身份出现。二期换 mention:// 句柄 + CLI 惰性回查。 +// 投影方式由"有无共同历史"决定,用户无需选择:同一 fork 树 → 全量注入共享水位后的增量; +// 无共同历史 → 预算内的紧凑摘要。 import type { TurnSummary } from "../events/types.ts"; -import { sessionDisplayTitle, type SessionHandle, type SessionStore } from "../store/store.ts"; +import { + sessionDisplayTitle, + type SessionForkOrigin, + type SessionHandle, + type SessionStore, +} from "../store/store.ts"; /** @bs_:MVP 只支持引用整个 BatonSession */ const MENTION_PATTERN = /@(bs_[0-9A-HJKMNP-TV-Z]{26})/g; @@ -49,18 +56,96 @@ function turnBlock(s: TurnSummary, index: number): string { } /** - * 目标会话的紧凑摘要。数据源是 turn-summary 事件(写入时已压缩,见 store.summarizeTurn)。 - * 预算内优先保最近的 turn:从最新往回装,装不下即停,再恢复时间序。 + * 沿 forkedFrom 链收集「祖先 id → 与该祖先共享历史的最大 seq」。 + * fork 复制保留原 seq(同一段逻辑历史,见 store.forkSession),所以沿途 throughSeq + * 的 running-min 就是相对更远祖先仍然共享的水位;自身条目的水位为 Infinity。 + */ +function lineageCuts(store: SessionStore, batonSessionId: string): Map { + const cuts = new Map(); + let cur = batonSessionId; + let cut = Infinity; + while (!cuts.has(cur)) { + cuts.set(cur, cut); + let forkedFrom: SessionForkOrigin | undefined; + try { + forkedFrom = store.openSession(cur).meta.forkedFrom; + } catch { + break; // 祖先会话可能已被删除;其 id 已入表,仍可参与共同祖先匹配 + } + if (!forkedFrom) break; + cut = Math.min(cut, forkedFrom.throughSeq); + cur = forkedFrom.batonSessionId; + } + return cuts; +} + +/** + * 两个会话的共同历史水位(seq):在 fork 谱系上找共同祖先,取双方相对它的共享水位 + * 中较小者;链上多个共同祖先取最近的(水位最大)。无共同历史返回 null。 + * 引用自己时返回 Infinity(全部共享,没有增量可注入)。 + */ +export function sharedHistoryWatermark( + store: SessionStore, + aSessionId: string, + bSessionId: string, +): number | null { + const a = lineageCuts(store, aSessionId); + const b = lineageCuts(store, bSessionId); + let best: number | null = null; + for (const [ancestor, cutB] of b) { + const cutA = a.get(ancestor); + if (cutA === undefined) continue; + const shared = Math.min(cutA, cutB); + if (best === null || shared > best) best = shared; + } + return best; +} + +/** turn-summary 事件带 seq 读出,供共同历史水位过滤(reduce 的 turnSummaries 不带 seq) */ +function summariesWithSeq(handle: SessionHandle): Array<{ seq: number; summary: TurnSummary }> { + return handle + .readEvents() + .filter((e) => e.kind === "_baton_turn_summary") + .map((e) => ({ seq: e.seq, summary: e.payload as TurnSummary })); +} + +/** + * 目标会话注入上下文。投影方式由与 fromSessionId 的关系决定(用户不选择): + * - 有共同历史(同一 fork 树):注入共享水位之后的全部 turn,不做预算截断—— + * 共享前缀双方都有无需重复,增量通常小,截断反而丢掉引用的关键内容。 + * - 无共同历史(或未提供 fromSessionId):预算内的紧凑摘要,从最新往回装, + * 装不下即停,再恢复时间序。 + * 数据源都是 turn-summary 事件(写入时已压缩,见 store.summarizeTurn)。 */ export function buildSessionContext( store: SessionStore, batonSessionId: string, budgetChars: number = DEFAULT_MENTION_BUDGET_CHARS, + opts: { fromSessionId?: string } = {}, ): string { const session = store.openSession(batonSessionId); - const summaries = session.loadState().turnSummaries; + const entries = summariesWithSeq(session); + const summaries = entries.map((e) => e.summary); const title = sessionDisplayTitle(session.meta); const providers = Object.keys(session.meta.providerSessions).join(", ") || "unknown"; + + const watermark = opts.fromSessionId + ? sharedHistoryWatermark(store, opts.fromSessionId, batonSessionId) + : null; + if (watermark !== null) { + const header = `# Session context: ${title} (id: ${batonSessionId}, agent: ${providers}, shares history with this session)`; + const fresh = entries.filter((e) => e.seq > watermark); + if (fresh.length === 0) { + return `${header}\n(no new turns beyond the shared history)`; + } + const parts = [header]; + const sharedCount = entries.length - fresh.length; + if (sharedCount > 0) parts.push(`(${sharedCount} turns of shared history omitted)`); + // Turn 编号沿用该会话自己的序号,便于和共享前缀对齐 + parts.push(...fresh.map((e) => turnBlock(e.summary, entries.indexOf(e)))); + return parts.join("\n\n"); + } + const header = `# Session summary: ${title} (id: ${batonSessionId}, agent: ${providers})`; if (summaries.length === 0) { @@ -169,19 +254,24 @@ export function buildProviderCatchUpContext( /** * 展开输入里的所有 @ 引用:返回最终发给 agent 的文本。 * 注入内容以"用户提供的只读参考材料"身份出现,归属清晰(design §5.4:不伪造对方记忆)。 + * currentSessionId 用于判断引用会话与当前会话有无共同历史(见 buildSessionContext); + * 预算只约束摘要投影,同树增量注入不受预算限制。 */ export function expandMentions( store: SessionStore, text: string, budgetChars: number = DEFAULT_MENTION_BUDGET_CHARS, + currentSessionId?: string, ): { prompt: string; mentions: ParsedMention[] } { const mentions = parseMentions(text); if (mentions.length === 0) return { prompt: text, mentions }; const perMentionBudget = Math.floor(budgetChars / mentions.length); - const contexts = mentions.map((m) => buildSessionContext(store, m.batonSessionId, perMentionBudget)); + const contexts = mentions.map((m) => + buildSessionContext(store, m.batonSessionId, perMentionBudget, { fromSessionId: currentSessionId }), + ); const prompt = [ "", - "Summaries of other agent sessions referenced by the user, provided as background context only:", + "Content from other agent sessions referenced by the user, provided as background context only:", ...contexts, "", "", diff --git a/src/tui/protocol.ts b/src/tui/protocol.ts index e21d433..f01a7f3 100644 --- a/src/tui/protocol.ts +++ b/src/tui/protocol.ts @@ -144,7 +144,7 @@ export class BatonChatProtocol implements ChatProtocol { this.status = null; this.commandOutput = null; this.session.setPreviewIfEmpty(text); - const { prompt } = expandMentions(this.store, text, this.config.mentionBudgetChars); + const { prompt } = expandMentions(this.store, text, this.config.mentionBudgetChars, this.session.id); if (this.runtime.isBusy || this.runtime.queueLength > 0) { this.status = { text: `${target} turn queued`, tone: "info" }; } diff --git a/tests/mention.test.ts b/tests/mention.test.ts index 7474803..004198a 100644 --- a/tests/mention.test.ts +++ b/tests/mention.test.ts @@ -3,7 +3,12 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildSessionContext, expandMentions, parseMentions } from "../src/context/mention.ts"; +import { + buildSessionContext, + expandMentions, + parseMentions, + sharedHistoryWatermark, +} from "../src/context/mention.ts"; import { SessionStore, type SessionHandle } from "../src/store/store.ts"; let root: string; @@ -18,10 +23,13 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); }); -function sessionWithTurns(turnTexts: Array<{ user: string; agent: string }>): SessionHandle { - const h = store.createSession({ cwd: "/tmp", title: "demo" }); +function sessionWithTurns( + turnTexts: Array<{ user: string; agent: string }>, + handle?: SessionHandle, +): SessionHandle { + const h = handle ?? store.createSession({ cwd: "/tmp", title: "demo" }); turnTexts.forEach((t, i) => { - const turnId = `t_${i}`; + const turnId = `t_${h.id}_${i}`; h.append({ kind: "user_message", provider: "codex", turnId, payload: { messageId: `${turnId}_u`, content: [{ type: "text", text: t.user }] } }); h.append({ kind: "agent_message", provider: "codex", turnId, payload: { messageId: `${turnId}_a`, content: [{ type: "text", text: t.agent }] } }); h.append({ kind: "state_update", provider: "codex", turnId, payload: { state: "idle", stopReason: "end_turn" } }); @@ -86,3 +94,72 @@ describe("expandMentions", () => { expect(prompt.trimEnd().endsWith(input)).toBe(true); }); }); + +describe("sharedHistoryWatermark", () => { + test("unrelated sessions have no watermark", () => { + const a = sessionWithTurns([{ user: "qa", agent: "aa" }]); + const b = sessionWithTurns([{ user: "qb", agent: "ab" }]); + expect(sharedHistoryWatermark(store, a.id, b.id)).toBeNull(); + }); + + test("parent/child watermark is the fork boundary, both directions", () => { + const parent = sessionWithTurns([{ user: "q0", agent: "a0" }]); + const child = store.forkSession(parent.id); + const boundary = child.meta.forkedFrom!.throughSeq; + expect(sharedHistoryWatermark(store, parent.id, child.id)).toBe(boundary); + expect(sharedHistoryWatermark(store, child.id, parent.id)).toBe(boundary); + }); + + test("siblings share via common ancestor with the smaller cut", () => { + const root = sessionWithTurns([{ user: "q0", agent: "a0" }]); + const early = store.forkSession(root.id); // 先 fork:水位小 + sessionWithTurns([{ user: "q1", agent: "a1" }], root); // root 继续长 + const late = store.forkSession(root.id); // 后 fork:水位大 + const expected = Math.min(early.meta.forkedFrom!.throughSeq, late.meta.forkedFrom!.throughSeq); + expect(sharedHistoryWatermark(store, early.id, late.id)).toBe(expected); + }); +}); + +describe("related-session injection", () => { + test("related mention injects only turns beyond shared history, uncapped", () => { + const parent = sessionWithTurns([{ user: "旧问题", agent: "旧答案" }]); + const child = store.forkSession(parent.id); + sessionWithTurns([{ user: `新问题 ${"x".repeat(300)}`, agent: "新答案" }], child); + // 预算给到极小:同树注入不受预算截断 + const ctx = buildSessionContext(store, child.id, 100, { fromSessionId: parent.id }); + expect(ctx).toContain("shares history with this session"); + expect(ctx).toContain("新答案"); + expect(ctx).not.toContain("旧答案"); // 共享前缀不重复注入 + expect(ctx).toContain("1 turns of shared history omitted"); + }); + + test("related mention with no new turns says so", () => { + const parent = sessionWithTurns([{ user: "q", agent: "a" }]); + const child = store.forkSession(parent.id); + const ctx = buildSessionContext(store, child.id, undefined, { fromSessionId: parent.id }); + expect(ctx).toContain("no new turns beyond the shared history"); + }); + + test("unrelated mention still gets budgeted summary", () => { + const current = sessionWithTurns([{ user: "qa", agent: "aa" }]); + const other = sessionWithTurns( + Array.from({ length: 10 }, (_, i) => ({ user: `q${i} ${"x".repeat(200)}`, agent: `a${i}` })), + ); + const ctx = buildSessionContext(store, other.id, 600, { fromSessionId: current.id }); + expect(ctx).toContain("# Session summary:"); + expect(ctx).toMatch(/\d+ earlier turns omitted for length/); + }); + + test("expandMentions with currentSessionId picks projection per relation", () => { + const parent = sessionWithTurns([{ user: "q0", agent: "a0" }]); + const child = store.forkSession(parent.id); + sessionWithTurns([{ user: "fork 里的新进展", agent: "fork 结论" }], child); + const stranger = sessionWithTurns([{ user: "无关问题", agent: "无关答案" }]); + const input = `综合 @${child.id} 和 @${stranger.id}`; + const { prompt } = expandMentions(store, input, undefined, parent.id); + expect(prompt).toContain("shares history with this session"); + expect(prompt).toContain("fork 结论"); + expect(prompt).toContain("# Session summary:"); + expect(prompt).toContain("无关答案"); + }); +});