forked from bai/curriculum-project-hub
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:
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readTranscript, appendTranscript, transcriptPath, TRANSCRIPT_DIR } from "../../src/agent/transcript.js";
|
||||
import type { Message } from "../../src/agent/provider.js";
|
||||
|
||||
const sample: Message = { role: "user", parts: [{ type: "text", text: "hello" }] };
|
||||
const reply: Message = { role: "assistant", parts: [{ type: "text", text: "hi" }] };
|
||||
|
||||
describe("transcript JSONL", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "hub-transcript-"));
|
||||
path = join(dir, "session.jsonl");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("readTranscript returns empty array for nonexistent file", async () => {
|
||||
expect(await readTranscript(path)).toEqual([]);
|
||||
});
|
||||
|
||||
it("appendTranscript creates parent dirs and writes JSONL", async () => {
|
||||
await appendTranscript(path, [sample]);
|
||||
const content = await readFile(path, "utf8");
|
||||
expect(content.trim()).toBe(JSON.stringify(sample));
|
||||
});
|
||||
|
||||
it("readTranscript reads back appended messages", async () => {
|
||||
await appendTranscript(path, [sample, reply]);
|
||||
const messages = await readTranscript(path);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(sample);
|
||||
expect(messages[1]).toEqual(reply);
|
||||
});
|
||||
|
||||
it("multiple appends accumulate", async () => {
|
||||
await appendTranscript(path, [sample]);
|
||||
await appendTranscript(path, [reply]);
|
||||
const messages = await readTranscript(path);
|
||||
expect(messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips corrupt lines (partial write recovery)", async () => {
|
||||
await appendTranscript(path, [sample]);
|
||||
// Append a corrupt line (simulating a crash mid-write).
|
||||
await writeFile(path, '{"broken json\n', { flag: "a" });
|
||||
const messages = await readTranscript(path);
|
||||
expect(messages).toHaveLength(1); // only the valid line
|
||||
});
|
||||
|
||||
it("transcriptPath builds the expected path", () => {
|
||||
const p = transcriptPath("/ws", "sess-1");
|
||||
expect(p).toBe(join("/ws", TRANSCRIPT_DIR, "sess-1.jsonl"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user