feat(hub): session transcript 连续性 + slash commands (/new /resume /reset)

修复每次 @bot 都是失忆状态的问题。
- transcript.ts: JSONL 文件存 session 对话消息(workspace/.cph/sessions/
  <id>.jsonl),append-only;和 Claude Code 一致;ADR-0003 说的'不存全
  历史'是群聊历史,session 对话是 Hub 自己产生的,不冲突
- runner.ts: RunRequest 加 transcriptPath;run 开始时读历史拼进初始
  messages(system prompt 只在首条);run 结束时 append 本轮新消息
  (loadedCount 区分历史 vs 新增,不重复写);best-effort(写失败不丢
  run 结果)
- trigger.ts: slash commands 在 lock 检查之前(/new /resume /reset
  不创建 run 不需要锁);transcriptPath 传给 runner
  /new = 归档当前 session,下次 @bot 自动建新的
  /resume = 恢复最近归档的 session
  /reset = 同 /new
  未知 /cmd 透传给 agent 当普通 prompt
- unit: transcript.test.ts 读写回环/append 累积/损坏行恢复/路径(6)
- integration: trigger.test.ts 加 /new /resume /reset /unknown(4)
修了 slash command 被 lock 挡住的问题(提到 lock 之前)。
vitest run 45/45 通过;tsc rc=0。
This commit is contained in:
2026-07-07 17:50:05 +08:00
parent e60aa43731
commit 0fe6cabc68
5 changed files with 283 additions and 7 deletions
+75
View File
@@ -123,6 +123,81 @@ describe("trigger full lifecycle (integration)", () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("/new archives current session (no run created)", async () => {
await seedProject("proj-6", "chat-6");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
// First @bot creates a session + run.
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
// /new archives the session.
await trigger(makeEvent("chat-6", "@_user_1 /new"), rt);
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.archivedAt).not.toBeNull();
// No new run created for /new.
expect(await prisma.agentRun.findMany()).toHaveLength(1);
});
it("/resume un-archives the most recent session", async () => {
await seedProject("proj-7", "chat-7");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
// Create + archive a session via /new.
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
expect(await prisma.agentRun.findMany()).toHaveLength(1);
});
await trigger(makeEvent("chat-7", "@_user_1 /new"), rt);
// /resume un-archives.
await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt);
expect(rt.sentTexts).toContain("已恢复上一个会话。");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.archivedAt).toBeNull();
});
it("/reset archives current session", async () => {
await seedProject("proj-8", "chat-8");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt);
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.archivedAt).not.toBeNull();
});
it("unknown slash command falls through to agent", async () => {
await seedProject("proj-9", "chat-9");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
// Should create a run (falls through as a normal prompt).
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.prompt).toBe("/unknown");
});
});
});
afterAll(async () => {