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
+36 -3
View File
@@ -11,6 +11,7 @@
*/
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js";
import type { ToolContext, ToolRegistry } from "./tools.js";
import { readTranscript, appendTranscript } from "./transcript.js";
/** What the runner needs about the project to seed and bound a run. */
export interface ProjectContext {
@@ -29,6 +30,9 @@ export interface RunRequest {
readonly systemPrompt?: string;
/** Iteration cap before the run is returned as `length`. Default 25. */
readonly maxIterations?: number;
/** Path to the session transcript JSONL. If set, prior messages are loaded
* from it before the run, and new messages are appended after. */
readonly transcriptPath?: string;
}
export type RunStatus = "completed" | "length" | "failed";
@@ -59,9 +63,18 @@ export async function runAgent(
boundChatId: req.project.boundChatId,
workspaceDir: req.project.workspaceDir,
};
const messages: Message[] = [];
if (req.systemPrompt !== undefined) {
// Load prior session messages from transcript (continuity across @bot calls
// in the same session). ADR-0003: session messages ≠ group chat history.
let loadedCount = 0;
if (req.transcriptPath !== undefined) {
const prior = await readTranscript(req.transcriptPath);
loadedCount = prior.length;
messages.push(...prior);
}
if (req.systemPrompt !== undefined && messages.length === 0) {
// System prompt only at the very start of a session; on resume it's
// already in the transcript.
messages.push({ role: "system", parts: [{ type: "text", text: req.systemPrompt }] });
}
messages.push({ role: "user", parts: [{ type: "text", text: req.prompt }] });
@@ -79,12 +92,14 @@ export async function runAgent(
tools: tools.specs(),
});
} catch (e) {
return {
const result: RunResult = {
status: "failed",
messages,
usage: { inputTokens, outputTokens },
error: e instanceof Error ? e.message : String(e),
};
await appendTranscriptIfSet(req, messages, loadedCount);
return result;
}
messages.push(res.message);
@@ -94,9 +109,11 @@ export async function runAgent(
}
if (res.finishReason === "stop") {
await appendTranscriptIfSet(req, messages, loadedCount);
return { status: "completed", messages, usage: { inputTokens, outputTokens } };
}
if (res.finishReason !== "tool_calls") {
await appendTranscriptIfSet(req, messages, loadedCount);
return { status: "length", messages, usage: { inputTokens, outputTokens } };
}
@@ -111,6 +128,7 @@ export async function runAgent(
}
}
await appendTranscriptIfSet(req, messages, loadedCount);
return {
status: "length",
messages,
@@ -119,6 +137,21 @@ export async function runAgent(
};
}
/// Append only the messages produced this run (skip the `loadedCount` prior
/// ones already in the file). Errors are swallowed — a transcript write
/// failure must not lose the run result.
async function appendTranscriptIfSet(req: RunRequest, messages: readonly Message[], loadedCount: number): Promise<void> {
if (req.transcriptPath === undefined) return;
const newMessages = messages.slice(loadedCount);
if (newMessages.length === 0) return;
try {
await appendTranscript(req.transcriptPath, newMessages);
} catch {
// Swallow — transcript is best-effort persistence; the run result is
// returned regardless. ADR-0002 lock ensures no concurrent writer.
}
}
function toolCallsOfAssistant(msg: Message): readonly ToolCallPart[] {
if (msg.role !== "assistant") return [];
return msg.parts.filter((p): p is ToolCallPart => p.type === "tool_call");