Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@ async function main(): Promise<void> {
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");
Expand Down
106 changes: 98 additions & 8 deletions src/context/mention.ts
Original file line number Diff line number Diff line change
@@ -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_<ULID>:MVP 只支持引用整个 BatonSession */
const MENTION_PATTERN = /@(bs_[0-9A-HJKMNP-TV-Z]{26})/g;
Expand Down Expand Up @@ -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<string, number> {
const cuts = new Map<string, number>();
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) {
Expand Down Expand Up @@ -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 = [
"<baton-context>",
"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,
"</baton-context>",
"",
Expand Down
2 changes: 1 addition & 1 deletion src/tui/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
Expand Down
85 changes: 81 additions & 4 deletions tests/mention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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" } });
Expand Down Expand Up @@ -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("无关答案");
});
});