|
@@ -0,0 +1,1498 @@
|
|
|
|
|
+# Pi-Agent JSONL 持久化 Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** 把 InkOS pi-agent session 持久化改成单层 JSONL transcript,并让恢复后的 `Agent.state.messages` 保留 raw `AgentMessage`、toolResult 和 thinking。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** core 新增 transcript schema、codec、restore、legacy migration 四个小模块;`book-session-store` 从 transcript 派生旧 API 形状;`runAgentSession` 负责从 transcript 恢复 Agent、订阅 pi-agent-core `message_end` 写入 transcript,并用 `request_committed` 做恢复栅栏。Studio API 保持现有响应形状,但停止把 user/assistant 追加写入 legacy `BookSession` JSON。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** TypeScript、Zod、Vitest、Node `fs/promises`、`@mariozechner/pi-agent-core`、`@mariozechner/pi-ai`。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件结构
|
|
|
|
|
+
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript-schema.ts`
|
|
|
|
|
+ - 定义 transcript event schema、类型、role helper、raw `AgentMessage` 容器。
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript.ts`
|
|
|
|
|
+ - 负责 `.inkos/sessions/{sessionId}.jsonl` 路径、append/read、seq 分配、per-session append queue。
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript-restore.ts`
|
|
|
|
|
+ - 负责 committed request 恢复、模型合法性清理、UI `BookSession` 派生。
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript-legacy.ts`
|
|
|
|
|
+ - 负责 legacy `.json` 到 JSONL 的一次性兼容迁移。
|
|
|
|
|
+- Modify: `packages/core/src/interaction/book-session-store.ts`
|
|
|
|
|
+ - 保持原导出 API,但实现改为优先 JSONL,legacy JSON 只作为输入。
|
|
|
|
|
+- Modify: `packages/core/src/agent/agent-session.ts`
|
|
|
|
|
+ - 删除 plain message 恢复主路径,改为 raw transcript restore/write。
|
|
|
|
|
+- Modify: `packages/core/src/index.ts`
|
|
|
|
|
+ - 导出 transcript helper 和类型。
|
|
|
|
|
+- Modify: `packages/studio/src/api/server.ts`
|
|
|
|
|
+ - `/api/v1/agent` 不再构造 `initialMessages`,不再 append/persist legacy BookSession JSON。
|
|
|
|
|
+- Test: `packages/core/src/__tests__/session-transcript.test.ts`
|
|
|
|
|
+- Test: `packages/core/src/__tests__/session-transcript-restore.test.ts`
|
|
|
|
|
+- Test: `packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
+- Test: `packages/core/src/__tests__/agent-session.test.ts`
|
|
|
|
|
+- Test: `packages/studio/src/api/server.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: JSONL transcript schema 和 codec
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript-schema.ts`
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript.ts`
|
|
|
|
|
+- Create: `packages/core/src/__tests__/session-transcript.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/__tests__/session-transcript.test.ts` 新增:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
|
|
|
+import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { tmpdir } from "node:os";
|
|
|
|
|
+import {
|
|
|
|
|
+ appendTranscriptEvent,
|
|
|
|
|
+ nextTranscriptSeq,
|
|
|
|
|
+ readTranscriptEvents,
|
|
|
|
|
+ transcriptPath,
|
|
|
|
|
+} from "../interaction/session-transcript.js";
|
|
|
|
|
+import type { MessageEvent, RequestCommittedEvent, RequestStartedEvent } from "../interaction/session-transcript-schema.js";
|
|
|
|
|
+
|
|
|
|
|
+describe("session transcript codec", () => {
|
|
|
|
|
+ let projectRoot: string;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), "inkos-transcript-"));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("一行写入一个 JSON event 并保留 raw AgentMessage 字段", async () => {
|
|
|
|
|
+ const started: RequestStartedEvent = {
|
|
|
|
|
+ type: "request_started",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: "s1",
|
|
|
|
|
+ requestId: "r1",
|
|
|
|
|
+ seq: 1,
|
|
|
|
|
+ timestamp: 100,
|
|
|
|
|
+ input: "继续写",
|
|
|
|
|
+ };
|
|
|
|
|
+ const message: MessageEvent = {
|
|
|
|
|
+ type: "message",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: "s1",
|
|
|
|
|
+ requestId: "r1",
|
|
|
|
|
+ uuid: "m1",
|
|
|
|
|
+ parentUuid: null,
|
|
|
|
|
+ seq: 2,
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ timestamp: 101,
|
|
|
|
|
+ message: {
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ content: [
|
|
|
|
|
+ { type: "thinking", thinking: "推理", signature: "sig-1" },
|
|
|
|
|
+ { type: "text", text: "正文" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ provider: "anthropic",
|
|
|
|
|
+ api: "anthropic-messages",
|
|
|
|
|
+ model: "claude",
|
|
|
|
|
+ usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
|
|
|
+ stopReason: "stop",
|
|
|
|
|
+ timestamp: 101,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, started);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, message);
|
|
|
|
|
+
|
|
|
|
|
+ const raw = await readFile(transcriptPath(projectRoot, "s1"), "utf-8");
|
|
|
|
|
+ expect(raw.trim().split("\n")).toHaveLength(2);
|
|
|
|
|
+
|
|
|
|
|
+ const events = await readTranscriptEvents(projectRoot, "s1");
|
|
|
|
|
+ expect(events).toHaveLength(2);
|
|
|
|
|
+ expect((events[1] as MessageEvent).message).toMatchObject({
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ content: [
|
|
|
|
|
+ { type: "thinking", thinking: "推理", signature: "sig-1" },
|
|
|
|
|
+ { type: "text", text: "正文" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("跳过坏行并保留合法 event", async () => {
|
|
|
|
|
+ const dir = join(projectRoot, ".inkos", "sessions");
|
|
|
|
|
+ await mkdir(dir, { recursive: true });
|
|
|
|
|
+ await writeFile(
|
|
|
|
|
+ join(dir, "s1.jsonl"),
|
|
|
|
|
+ [
|
|
|
|
|
+ JSON.stringify({ type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "hi" }),
|
|
|
|
|
+ "{bad json",
|
|
|
|
|
+ JSON.stringify({ type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 2, timestamp: 2 }),
|
|
|
|
|
+ ].join("\n"),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const events = await readTranscriptEvents(projectRoot, "s1");
|
|
|
|
|
+ expect(events.map((event) => event.type)).toEqual(["request_started", "request_committed"]);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("按已有 transcript 分配单调递增 seq", async () => {
|
|
|
|
|
+ const committed: RequestCommittedEvent = {
|
|
|
|
|
+ type: "request_committed",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: "s1",
|
|
|
|
|
+ requestId: "r1",
|
|
|
|
|
+ seq: 7,
|
|
|
|
|
+ timestamp: 100,
|
|
|
|
|
+ };
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, committed);
|
|
|
|
|
+ await expect(nextTranscriptSeq(projectRoot, "s1")).resolves.toBe(8);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: FAIL,错误包含 `Cannot find module '../interaction/session-transcript.js'`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现 schema**
|
|
|
|
|
+
|
|
|
|
|
+创建 `packages/core/src/interaction/session-transcript-schema.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { z } from "zod";
|
|
|
|
|
+
|
|
|
|
|
+export const TranscriptRoleSchema = z.enum(["user", "assistant", "toolResult", "system"]);
|
|
|
|
|
+export type TranscriptRole = z.infer<typeof TranscriptRoleSchema>;
|
|
|
|
|
+
|
|
|
|
|
+const BaseEventSchema = z.object({
|
|
|
|
|
+ version: z.literal(1),
|
|
|
|
|
+ sessionId: z.string().min(1),
|
|
|
|
|
+ seq: z.number().int().nonnegative(),
|
|
|
|
|
+ timestamp: z.number().int().nonnegative(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const SessionCreatedEventSchema = BaseEventSchema.extend({
|
|
|
|
|
+ type: z.literal("session_created"),
|
|
|
|
|
+ bookId: z.string().nullable(),
|
|
|
|
|
+ title: z.string().nullable().default(null),
|
|
|
|
|
+ createdAt: z.number().int().nonnegative(),
|
|
|
|
|
+ updatedAt: z.number().int().nonnegative(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const SessionMetadataUpdatedEventSchema = BaseEventSchema.extend({
|
|
|
|
|
+ type: z.literal("session_metadata_updated"),
|
|
|
|
|
+ bookId: z.string().nullable().optional(),
|
|
|
|
|
+ title: z.string().nullable().optional(),
|
|
|
|
|
+ updatedAt: z.number().int().nonnegative(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const RequestStartedEventSchema = BaseEventSchema.extend({
|
|
|
|
|
+ type: z.literal("request_started"),
|
|
|
|
|
+ requestId: z.string().min(1),
|
|
|
|
|
+ input: z.string(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const RequestCommittedEventSchema = BaseEventSchema.extend({
|
|
|
|
|
+ type: z.literal("request_committed"),
|
|
|
|
|
+ requestId: z.string().min(1),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const RequestFailedEventSchema = BaseEventSchema.extend({
|
|
|
|
|
+ type: z.literal("request_failed"),
|
|
|
|
|
+ requestId: z.string().min(1),
|
|
|
|
|
+ error: z.string(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const MessageEventSchema = BaseEventSchema.extend({
|
|
|
|
|
+ type: z.literal("message"),
|
|
|
|
|
+ requestId: z.string().min(1),
|
|
|
|
|
+ uuid: z.string().min(1),
|
|
|
|
|
+ parentUuid: z.string().min(1).nullable(),
|
|
|
|
|
+ role: TranscriptRoleSchema,
|
|
|
|
|
+ piTurnIndex: z.number().int().nonnegative().optional(),
|
|
|
|
|
+ toolCallId: z.string().min(1).optional(),
|
|
|
|
|
+ sourceToolAssistantUuid: z.string().min(1).optional(),
|
|
|
|
|
+ legacyDisplay: z.object({
|
|
|
|
|
+ thinking: z.string().optional(),
|
|
|
|
|
+ toolExecutions: z.array(z.unknown()).optional(),
|
|
|
|
|
+ }).optional(),
|
|
|
|
|
+ message: z.unknown(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export const TranscriptEventSchema = z.discriminatedUnion("type", [
|
|
|
|
|
+ SessionCreatedEventSchema,
|
|
|
|
|
+ SessionMetadataUpdatedEventSchema,
|
|
|
|
|
+ RequestStartedEventSchema,
|
|
|
|
|
+ RequestCommittedEventSchema,
|
|
|
|
|
+ RequestFailedEventSchema,
|
|
|
|
|
+ MessageEventSchema,
|
|
|
|
|
+]);
|
|
|
|
|
+
|
|
|
|
|
+export type SessionCreatedEvent = z.infer<typeof SessionCreatedEventSchema>;
|
|
|
|
|
+export type SessionMetadataUpdatedEvent = z.infer<typeof SessionMetadataUpdatedEventSchema>;
|
|
|
|
|
+export type RequestStartedEvent = z.infer<typeof RequestStartedEventSchema>;
|
|
|
|
|
+export type RequestCommittedEvent = z.infer<typeof RequestCommittedEventSchema>;
|
|
|
|
|
+export type RequestFailedEvent = z.infer<typeof RequestFailedEventSchema>;
|
|
|
|
|
+export type MessageEvent = z.infer<typeof MessageEventSchema>;
|
|
|
|
|
+export type TranscriptEvent = z.infer<typeof TranscriptEventSchema>;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 实现 codec**
|
|
|
|
|
+
|
|
|
|
|
+创建 `packages/core/src/interaction/session-transcript.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { TranscriptEventSchema, type TranscriptEvent } from "./session-transcript-schema.js";
|
|
|
|
|
+
|
|
|
|
|
+const SESSIONS_DIR = ".inkos/sessions";
|
|
|
|
|
+const appendQueues = new Map<string, Promise<void>>();
|
|
|
|
|
+
|
|
|
|
|
+export function sessionsDir(projectRoot: string): string {
|
|
|
|
|
+ return join(projectRoot, SESSIONS_DIR);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function transcriptPath(projectRoot: string, sessionId: string): string {
|
|
|
|
|
+ return join(sessionsDir(projectRoot), `${sessionId}.jsonl`);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function legacyBookSessionPath(projectRoot: string, sessionId: string): string {
|
|
|
|
|
+ return join(sessionsDir(projectRoot), `${sessionId}.json`);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function readTranscriptEvents(projectRoot: string, sessionId: string): Promise<TranscriptEvent[]> {
|
|
|
|
|
+ let raw: string;
|
|
|
|
|
+ try {
|
|
|
|
|
+ raw = await readFile(transcriptPath(projectRoot, sessionId), "utf-8");
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ const events: TranscriptEvent[] = [];
|
|
|
|
|
+ for (const line of raw.split(/\r?\n/)) {
|
|
|
|
|
+ if (!line.trim()) continue;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = TranscriptEventSchema.safeParse(JSON.parse(line));
|
|
|
|
|
+ if (parsed.success) events.push(parsed.data);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return events.sort((a, b) => a.seq - b.seq);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function nextTranscriptSeq(projectRoot: string, sessionId: string): Promise<number> {
|
|
|
|
|
+ const events = await readTranscriptEvents(projectRoot, sessionId);
|
|
|
|
|
+ return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function appendTranscriptEvent(projectRoot: string, event: TranscriptEvent): Promise<void> {
|
|
|
|
|
+ const key = `${projectRoot}:${event.sessionId}`;
|
|
|
|
|
+ const previous = appendQueues.get(key) ?? Promise.resolve();
|
|
|
|
|
+ const next = previous.then(async () => {
|
|
|
|
|
+ await mkdir(sessionsDir(projectRoot), { recursive: true });
|
|
|
|
|
+ await appendFile(transcriptPath(projectRoot, event.sessionId), `${JSON.stringify(event)}\n`, "utf-8");
|
|
|
|
|
+ });
|
|
|
|
|
+ appendQueues.set(key, next.catch(() => undefined));
|
|
|
|
|
+ await next;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/core/src/interaction/session-transcript-schema.ts packages/core/src/interaction/session-transcript.ts packages/core/src/__tests__/session-transcript.test.ts
|
|
|
|
|
+git commit -m "feat: add session transcript codec"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: committed restore、thinking 和 toolResult 清理
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript-restore.ts`
|
|
|
|
|
+- Create: `packages/core/src/__tests__/session-transcript-restore.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+创建 `packages/core/src/__tests__/session-transcript-restore.test.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
|
|
|
+import { mkdtemp, rm } from "node:fs/promises";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { tmpdir } from "node:os";
|
|
|
|
|
+import { appendTranscriptEvent } from "../interaction/session-transcript.js";
|
|
|
|
|
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
|
|
|
|
|
+import type { MessageEvent } from "../interaction/session-transcript-schema.js";
|
|
|
|
|
+
|
|
|
|
|
+const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
|
|
|
+
|
|
|
|
|
+describe("session transcript restore", () => {
|
|
|
|
|
+ let projectRoot: string;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), "inkos-restore-"));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("只恢复已 committed request 内的 message", async () => {
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "hi" });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "u1", parentUuid: null, seq: 2, role: "user", timestamp: 2, message: { role: "user", content: "hi", timestamp: 2 } } as MessageEvent);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 3, timestamp: 3 });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r2", seq: 4, timestamp: 4, input: "lost" });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId: "s1", requestId: "r2", uuid: "u2", parentUuid: "u1", seq: 5, role: "user", timestamp: 5, message: { role: "user", content: "lost", timestamp: 5 } } as MessageEvent);
|
|
|
|
|
+
|
|
|
|
|
+ const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
|
|
|
|
|
+ expect(restored).toHaveLength(1);
|
|
|
|
|
+ expect(restored[0]).toMatchObject({ role: "user", content: "hi" });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("保留 committed toolResult 和 assistant thinking signature", async () => {
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "tool" });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "a1", parentUuid: null, seq: 2, role: "assistant", timestamp: 2,
|
|
|
|
|
+ toolCallId: "tool-1",
|
|
|
|
|
+ message: { role: "assistant", content: [{ type: "thinking", thinking: "需要查资料", signature: "sig" }, { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } }], api: "anthropic-messages", provider: "anthropic", model: "claude", usage, stopReason: "tool_use", timestamp: 2 },
|
|
|
|
|
+ } as MessageEvent);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "t1", parentUuid: "a1", seq: 3, role: "toolResult", timestamp: 3,
|
|
|
|
|
+ toolCallId: "tool-1",
|
|
|
|
|
+ sourceToolAssistantUuid: "a1",
|
|
|
|
|
+ message: { role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "资料" }], details: { path: "a.md" }, isError: false, timestamp: 3 },
|
|
|
|
|
+ } as MessageEvent);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 4, timestamp: 4 });
|
|
|
|
|
+
|
|
|
|
|
+ const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
|
|
|
|
|
+ expect(restored).toHaveLength(2);
|
|
|
|
|
+ expect(restored[0]).toMatchObject({ role: "assistant", content: [{ type: "thinking", thinking: "需要查资料", signature: "sig" }] });
|
|
|
|
|
+ expect(restored[1]).toMatchObject({ role: "toolResult", toolCallId: "tool-1", toolName: "read" });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("移除最后 assistant message 的 trailing thinking block", async () => {
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 1, timestamp: 1, input: "hi" });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "a1", parentUuid: null, seq: 2, role: "assistant", timestamp: 2,
|
|
|
|
|
+ message: { role: "assistant", content: [{ type: "text", text: "回答" }, { type: "thinking", thinking: "尾部", signature: "sig" }], api: "anthropic-messages", provider: "anthropic", model: "claude", usage, stopReason: "stop", timestamp: 2 },
|
|
|
|
|
+ } as MessageEvent);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 3, timestamp: 3 });
|
|
|
|
|
+
|
|
|
|
|
+ const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
|
|
|
|
|
+ expect((restored[0] as any).content).toEqual([{ type: "text", text: "回答" }]);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: FAIL,错误包含 `Cannot find module '../interaction/session-transcript-restore.js'`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现 restore 和清理**
|
|
|
|
|
+
|
|
|
|
|
+创建 `packages/core/src/interaction/session-transcript-restore.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
|
|
|
+import { readTranscriptEvents } from "./session-transcript.js";
|
|
|
|
|
+import type { MessageEvent, TranscriptEvent } from "./session-transcript-schema.js";
|
|
|
|
|
+
|
|
|
|
|
+function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
+ return !!value && typeof value === "object";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function hasTextContent(message: Record<string, unknown>): boolean {
|
|
|
|
|
+ const content = message.content;
|
|
|
|
|
+ return Array.isArray(content) && content.some((block) => isObject(block) && block.type === "text" && typeof block.text === "string" && block.text.length > 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function hasToolCallContent(message: Record<string, unknown>): boolean {
|
|
|
|
|
+ const content = message.content;
|
|
|
|
|
+ return Array.isArray(content) && content.some((block) => isObject(block) && block.type === "toolCall" && typeof block.id === "string");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toolCallIds(message: Record<string, unknown>): string[] {
|
|
|
|
|
+ const content = message.content;
|
|
|
|
|
+ if (!Array.isArray(content)) return [];
|
|
|
|
|
+ return content
|
|
|
|
|
+ .filter((block): block is Record<string, unknown> => isObject(block) && block.type === "toolCall" && typeof block.id === "string")
|
|
|
|
|
+ .map((block) => block.id as string);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function cleanRestoredAgentMessages(messages: AgentMessage[]): AgentMessage[] {
|
|
|
|
|
+ const availableToolCalls = new Set<string>();
|
|
|
|
|
+ for (const message of messages) {
|
|
|
|
|
+ if (isObject(message) && message.role === "assistant") {
|
|
|
|
|
+ for (const id of toolCallIds(message)) availableToolCalls.add(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const cleaned = messages.filter((message) => {
|
|
|
|
|
+ if (!isObject(message)) return false;
|
|
|
|
|
+ if (message.role === "toolResult") {
|
|
|
|
|
+ return typeof message.toolCallId === "string" && availableToolCalls.has(message.toolCallId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (message.role === "assistant") {
|
|
|
|
|
+ return hasTextContent(message) || hasToolCallContent(message);
|
|
|
|
|
+ }
|
|
|
|
|
+ return message.role === "user" || message.role === "system";
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = cleaned.length - 1; i >= 0; i--) {
|
|
|
|
|
+ const message = cleaned[i] as Record<string, unknown>;
|
|
|
|
|
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
|
|
|
+ const filtered = message.content.filter((block) => !(isObject(block) && (block.type === "thinking" || block.type === "redacted_thinking")));
|
|
|
|
|
+ cleaned[i] = { ...message, content: filtered } as AgentMessage;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return cleaned;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {
|
|
|
|
|
+ const committed = new Set(
|
|
|
|
|
+ events
|
|
|
|
|
+ .filter((event) => event.type === "request_committed")
|
|
|
|
|
+ .map((event) => event.requestId),
|
|
|
|
|
+ );
|
|
|
|
|
+ return events
|
|
|
|
|
+ .filter((event): event is MessageEvent => event.type === "message" && committed.has(event.requestId))
|
|
|
|
|
+ .sort((a, b) => a.seq - b.seq);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function restoreAgentMessagesFromTranscript(projectRoot: string, sessionId: string): Promise<AgentMessage[]> {
|
|
|
|
|
+ const events = await readTranscriptEvents(projectRoot, sessionId);
|
|
|
|
|
+ return cleanRestoredAgentMessages(committedMessageEvents(events).map((event) => event.message as AgentMessage));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/core/src/interaction/session-transcript-restore.ts packages/core/src/__tests__/session-transcript-restore.test.ts
|
|
|
|
|
+git commit -m "feat: restore committed transcript messages"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: 从 transcript 派生 BookSession 视图并迁移 legacy JSON
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `packages/core/src/interaction/session-transcript-restore.ts`
|
|
|
|
|
+- Create: `packages/core/src/interaction/session-transcript-legacy.ts`
|
|
|
|
|
+- Modify: `packages/core/src/__tests__/session-transcript-restore.test.ts`
|
|
|
|
|
+- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/__tests__/session-transcript-restore.test.ts` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { deriveBookSessionFromTranscript } from "../interaction/session-transcript-restore.js";
|
|
|
|
|
+
|
|
|
|
|
+it("从 transcript 派生 BookSession UI 视图", async () => {
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "session_created", version: 1, sessionId: "s1", seq: 1, timestamp: 1, bookId: "book-a", title: null, createdAt: 1, updatedAt: 1 });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: "s1", requestId: "r1", seq: 2, timestamp: 2, input: "第一条问题" });
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "u1", parentUuid: null, seq: 3, role: "user", timestamp: 3, message: { role: "user", content: "第一条问题", timestamp: 3 } } as MessageEvent);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "message", version: 1, sessionId: "s1", requestId: "r1", uuid: "a1", parentUuid: "u1", seq: 4, role: "assistant", timestamp: 4,
|
|
|
|
|
+ message: { role: "assistant", content: [{ type: "thinking", thinking: "思考" }, { type: "text", text: "回答" }], api: "anthropic-messages", provider: "anthropic", model: "claude", usage, stopReason: "stop", timestamp: 4 },
|
|
|
|
|
+ } as MessageEvent);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: "s1", requestId: "r1", seq: 5, timestamp: 5 });
|
|
|
|
|
+
|
|
|
|
|
+ const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
|
|
|
|
|
+ expect(session).toMatchObject({
|
|
|
|
|
+ sessionId: "s1",
|
|
|
|
|
+ bookId: "book-a",
|
|
|
|
|
+ title: "第一条问题",
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "user", content: "第一条问题" },
|
|
|
|
|
+ { role: "assistant", content: "回答", thinking: "思考" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/__tests__/book-session-store.test.ts` 增加 legacy migration 断言:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+it("读取 legacy JSON 时迁移为 JSONL 并保留 UI thinking", async () => {
|
|
|
|
|
+ const session = {
|
|
|
|
|
+ ...createBookSession("book-a", "legacy-1"),
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "user" as const, content: "继续", timestamp: 10 },
|
|
|
|
|
+ { role: "assistant" as const, content: "好的", thinking: "旧思考", timestamp: 11 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+ await persistBookSession(tempDir, session);
|
|
|
|
|
+
|
|
|
|
|
+ const loaded = await loadBookSession(tempDir, "legacy-1");
|
|
|
|
|
+ expect(loaded).toMatchObject({
|
|
|
|
|
+ sessionId: "legacy-1",
|
|
|
|
|
+ bookId: "book-a",
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "user", content: "继续" },
|
|
|
|
|
+ { role: "assistant", content: "好的", thinking: "旧思考" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+ await expect(readFile(join(tempDir, ".inkos", "sessions", "legacy-1.jsonl"), "utf-8")).resolves.toContain("request_committed");
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts book-session-store.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: FAIL,错误包含 `deriveBookSessionFromTranscript is not a function`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现 BookSession 派生**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/interaction/session-transcript-restore.ts` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { BookSessionSchema, type BookSession, type InteractionMessage, type ToolExecution } from "./session.js";
|
|
|
|
|
+
|
|
|
|
|
+function textFromContent(content: unknown): string {
|
|
|
|
|
+ if (typeof content === "string") return content;
|
|
|
|
|
+ if (!Array.isArray(content)) return "";
|
|
|
|
|
+ return content
|
|
|
|
|
+ .filter((block): block is { type: string; text: string } => isObject(block) && block.type === "text" && typeof block.text === "string")
|
|
|
|
|
+ .map((block) => block.text)
|
|
|
|
|
+ .join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function thinkingFromContent(content: unknown): string | undefined {
|
|
|
|
|
+ if (!Array.isArray(content)) return undefined;
|
|
|
|
|
+ const value = content
|
|
|
|
|
+ .filter((block): block is Record<string, unknown> => isObject(block) && block.type === "thinking")
|
|
|
|
|
+ .map((block) => typeof block.thinking === "string" ? block.thinking : "")
|
|
|
|
|
+ .join("");
|
|
|
|
|
+ return value || undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
|
|
|
|
|
+ for (const message of messages) {
|
|
|
|
|
+ if (message.role !== "user") continue;
|
|
|
|
|
+ const oneLine = message.content.trim().replace(/\s+/g, " ");
|
|
|
|
|
+ if (!oneLine) return null;
|
|
|
|
|
+ return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function messageEventToInteractionMessage(event: MessageEvent): InteractionMessage | null {
|
|
|
|
|
+ const raw = event.message as Record<string, unknown>;
|
|
|
|
|
+ if (!isObject(raw)) return null;
|
|
|
|
|
+ if (event.role === "toolResult") return null;
|
|
|
|
|
+ if (event.role === "user") {
|
|
|
|
|
+ const content = textFromContent(raw.content);
|
|
|
|
|
+ return content ? { role: "user", content, timestamp: event.timestamp } : null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (event.role === "assistant") {
|
|
|
|
|
+ const content = textFromContent(raw.content);
|
|
|
|
|
+ const thinking = thinkingFromContent(raw.content) ?? event.legacyDisplay?.thinking;
|
|
|
|
|
+ if (!content && !thinking) return null;
|
|
|
|
|
+ return {
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ content,
|
|
|
|
|
+ ...(thinking ? { thinking } : {}),
|
|
|
|
|
+ ...(event.legacyDisplay?.toolExecutions ? { toolExecutions: event.legacyDisplay.toolExecutions as ToolExecution[] } : {}),
|
|
|
|
|
+ timestamp: event.timestamp,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function deriveBookSessionFromTranscript(projectRoot: string, sessionId: string): Promise<BookSession | null> {
|
|
|
|
|
+ const events = await readTranscriptEvents(projectRoot, sessionId);
|
|
|
|
|
+ if (events.length === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const created = events.find((event) => event.type === "session_created");
|
|
|
|
|
+ let bookId = created?.type === "session_created" ? created.bookId : null;
|
|
|
|
|
+ let title = created?.type === "session_created" ? created.title : null;
|
|
|
|
|
+ let createdAt = created?.type === "session_created" ? created.createdAt : events[0]?.timestamp ?? Date.now();
|
|
|
|
|
+ let updatedAt = created?.type === "session_created" ? created.updatedAt : events[events.length - 1]?.timestamp ?? createdAt;
|
|
|
|
|
+
|
|
|
|
|
+ for (const event of events) {
|
|
|
|
|
+ if (event.type === "session_metadata_updated") {
|
|
|
|
|
+ if ("bookId" in event) bookId = event.bookId ?? bookId;
|
|
|
|
|
+ if ("title" in event) title = event.title ?? title;
|
|
|
|
|
+ updatedAt = event.updatedAt;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const messages = committedMessageEvents(events)
|
|
|
|
|
+ .map(messageEventToInteractionMessage)
|
|
|
|
|
+ .filter((message): message is InteractionMessage => message !== null)
|
|
|
|
|
+ .sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
|
+
|
|
|
|
|
+ if (title === null) {
|
|
|
|
|
+ title = firstUserMessageTitle(messages);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return BookSessionSchema.parse({
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ bookId,
|
|
|
|
|
+ title,
|
|
|
|
|
+ messages,
|
|
|
|
|
+ draftRounds: [],
|
|
|
|
|
+ events: [],
|
|
|
|
|
+ createdAt,
|
|
|
|
|
+ updatedAt,
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 实现 legacy 转换**
|
|
|
|
|
+
|
|
|
|
|
+创建 `packages/core/src/interaction/session-transcript-legacy.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { readFile } from "node:fs/promises";
|
|
|
|
|
+import { randomUUID } from "node:crypto";
|
|
|
|
|
+import { BookSessionSchema, type BookSession } from "./session.js";
|
|
|
|
|
+import { appendTranscriptEvent, legacyBookSessionPath, nextTranscriptSeq, readTranscriptEvents } from "./session-transcript.js";
|
|
|
|
|
+import type { MessageEvent } from "./session-transcript-schema.js";
|
|
|
|
|
+
|
|
|
|
|
+const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
|
|
|
+
|
|
|
|
|
+export async function readLegacyBookSession(projectRoot: string, sessionId: string): Promise<BookSession | null> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const raw = await readFile(legacyBookSessionPath(projectRoot, sessionId), "utf-8");
|
|
|
|
|
+ return BookSessionSchema.parse(JSON.parse(raw));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function migrateLegacyBookSessionToTranscript(projectRoot: string, session: BookSession): Promise<void> {
|
|
|
|
|
+ const existing = await readTranscriptEvents(projectRoot, session.sessionId);
|
|
|
|
|
+ if (existing.length > 0) return;
|
|
|
|
|
+ let seq = await nextTranscriptSeq(projectRoot, session.sessionId);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "session_created",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: session.sessionId,
|
|
|
|
|
+ seq: seq++,
|
|
|
|
|
+ timestamp: session.createdAt,
|
|
|
|
|
+ bookId: session.bookId,
|
|
|
|
|
+ title: session.title,
|
|
|
|
|
+ createdAt: session.createdAt,
|
|
|
|
|
+ updatedAt: session.updatedAt,
|
|
|
|
|
+ });
|
|
|
|
|
+ const requestId = `legacy-${session.sessionId}`;
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId: session.sessionId, requestId, seq: seq++, timestamp: session.createdAt, input: "" });
|
|
|
|
|
+ let parentUuid: string | null = null;
|
|
|
|
|
+ for (const legacyMessage of session.messages) {
|
|
|
|
|
+ const uuid = randomUUID();
|
|
|
|
|
+ const message = legacyMessage.role === "assistant"
|
|
|
|
|
+ ? { role: "assistant", content: [{ type: "text", text: legacyMessage.content }], api: "anthropic-messages", provider: "legacy", model: "unknown", usage, stopReason: "stop", timestamp: legacyMessage.timestamp }
|
|
|
|
|
+ : { role: legacyMessage.role, content: legacyMessage.content, timestamp: legacyMessage.timestamp };
|
|
|
|
|
+ const event: MessageEvent = {
|
|
|
|
|
+ type: "message",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: session.sessionId,
|
|
|
|
|
+ requestId,
|
|
|
|
|
+ uuid,
|
|
|
|
|
+ parentUuid,
|
|
|
|
|
+ seq: seq++,
|
|
|
|
|
+ role: legacyMessage.role === "assistant" ? "assistant" : legacyMessage.role,
|
|
|
|
|
+ timestamp: legacyMessage.timestamp,
|
|
|
|
|
+ legacyDisplay: legacyMessage.role === "assistant" && legacyMessage.thinking ? { thinking: legacyMessage.thinking } : undefined,
|
|
|
|
|
+ message,
|
|
|
|
|
+ };
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, event);
|
|
|
|
|
+ parentUuid = uuid;
|
|
|
|
|
+ }
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId: session.sessionId, requestId, seq: seq++, timestamp: session.updatedAt });
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript-restore.test.ts book-session-store.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/core/src/interaction/session-transcript-restore.ts packages/core/src/interaction/session-transcript-legacy.ts packages/core/src/__tests__/session-transcript-restore.test.ts packages/core/src/__tests__/book-session-store.test.ts
|
|
|
|
|
+git commit -m "feat: derive sessions from transcripts"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: book-session-store 切换到 JSONL canonical path
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `packages/core/src/interaction/book-session-store.ts`
|
|
|
|
|
+- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/__tests__/book-session-store.test.ts` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+it("createAndPersistBookSession 为新 session 写 JSONL 而不是 legacy JSON", async () => {
|
|
|
|
|
+ const session = await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
|
|
|
|
|
+ expect(session.sessionId).toBe("123456-abcdef");
|
|
|
|
|
+
|
|
|
|
|
+ await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.jsonl"), "utf-8")).resolves.toContain("session_created");
|
|
|
|
|
+ await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.json"), "utf-8")).rejects.toThrow();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+it("renameBookSession 追加 metadata event 并从 JSONL 派生新标题", async () => {
|
|
|
|
|
+ await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
|
|
|
|
|
+ const renamed = await renameBookSession(tempDir, "123456-abcdef", "新标题");
|
|
|
|
|
+ expect(renamed!.title).toBe("新标题");
|
|
|
|
|
+
|
|
|
|
|
+ const loaded = await loadBookSession(tempDir, "123456-abcdef");
|
|
|
|
|
+ expect(loaded!.title).toBe("新标题");
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+it("listBookSessions 同时列出 JSONL session 和未迁移 legacy JSON session", async () => {
|
|
|
|
|
+ await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
|
|
|
|
|
+ const legacy = { ...createBookSession("book-a", "legacy-1"), updatedAt: 999 };
|
|
|
|
|
+ await writeFile(join(tempDir, ".inkos", "sessions", "legacy-1.json"), JSON.stringify(legacy));
|
|
|
|
|
+
|
|
|
|
|
+ const list = await listBookSessions(tempDir, "book-a");
|
|
|
|
|
+ expect(list.map((entry) => entry.sessionId).sort()).toEqual(["123456-abcdef", "legacy-1"]);
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- book-session-store.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: FAIL,新 session 仍然写 `.json`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 改写 store**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/interaction/book-session-store.ts` 中保留导出名,改为调用 transcript helper。核心实现形状:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { readdir, unlink } from "node:fs/promises";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { createBookSession, type BookSession } from "./session.js";
|
|
|
|
|
+import { appendTranscriptEvent, legacyBookSessionPath, nextTranscriptSeq, readTranscriptEvents, sessionsDir, transcriptPath } from "./session-transcript.js";
|
|
|
|
|
+import { deriveBookSessionFromTranscript } from "./session-transcript-restore.js";
|
|
|
|
|
+import { migrateLegacyBookSessionToTranscript, readLegacyBookSession } from "./session-transcript-legacy.js";
|
|
|
|
|
+
|
|
|
|
|
+export async function loadBookSession(projectRoot: string, sessionId: string): Promise<BookSession | null> {
|
|
|
|
|
+ const fromTranscript = await deriveBookSessionFromTranscript(projectRoot, sessionId);
|
|
|
|
|
+ if (fromTranscript) return fromTranscript;
|
|
|
|
|
+ const legacy = await readLegacyBookSession(projectRoot, sessionId);
|
|
|
|
|
+ if (!legacy) return null;
|
|
|
|
|
+ await migrateLegacyBookSessionToTranscript(projectRoot, legacy);
|
|
|
|
|
+ return deriveBookSessionFromTranscript(projectRoot, sessionId);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function persistBookSession(projectRoot: string, session: BookSession): Promise<void> {
|
|
|
|
|
+ const existing = await readTranscriptEvents(projectRoot, session.sessionId);
|
|
|
|
|
+ if (existing.length === 0) {
|
|
|
|
|
+ await migrateLegacyBookSessionToTranscript(projectRoot, session);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const seq = await nextTranscriptSeq(projectRoot, session.sessionId);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "session_metadata_updated",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: session.sessionId,
|
|
|
|
|
+ seq,
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
|
+ bookId: session.bookId,
|
|
|
|
|
+ title: session.title,
|
|
|
|
|
+ updatedAt: session.updatedAt,
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+然后逐个改写:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export async function createAndPersistBookSession(projectRoot: string, bookId: string | null, sessionId?: string): Promise<BookSession> {
|
|
|
|
|
+ if (sessionId) {
|
|
|
|
|
+ const existing = await loadBookSession(projectRoot, sessionId);
|
|
|
|
|
+ if (existing) return existing;
|
|
|
|
|
+ }
|
|
|
|
|
+ const session = createBookSession(bookId, sessionId);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "session_created",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId: session.sessionId,
|
|
|
|
|
+ seq: 1,
|
|
|
|
|
+ timestamp: session.createdAt,
|
|
|
|
|
+ bookId,
|
|
|
|
|
+ title: session.title,
|
|
|
|
|
+ createdAt: session.createdAt,
|
|
|
|
|
+ updatedAt: session.updatedAt,
|
|
|
|
|
+ });
|
|
|
|
|
+ return session;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function renameBookSession(projectRoot: string, sessionId: string, title: string): Promise<BookSession | null> {
|
|
|
|
|
+ const session = await loadBookSession(projectRoot, sessionId);
|
|
|
|
|
+ if (!session) return null;
|
|
|
|
|
+ const updatedAt = Date.now();
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "session_metadata_updated",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ seq: await nextTranscriptSeq(projectRoot, sessionId),
|
|
|
|
|
+ timestamp: updatedAt,
|
|
|
|
|
+ title,
|
|
|
|
|
+ updatedAt,
|
|
|
|
|
+ });
|
|
|
|
|
+ return loadBookSession(projectRoot, sessionId);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`listBookSessions()` 扫描 `.jsonl` 和 `.json`,对 `.json` 调用 `loadBookSession()` 触发迁移后再派生 summary;`deleteBookSession()` 同时删除 `.jsonl` 和 `.json`;`migrateBookSession()` 写 `session_metadata_updated` 的 `bookId`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- book-session-store.test.ts session-transcript.test.ts session-transcript-restore.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts
|
|
|
|
|
+git commit -m "feat: use jsonl for book sessions"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: runAgentSession 从 JSONL 恢复 raw AgentMessage 并写入 transcript
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `packages/core/src/agent/agent-session.ts`
|
|
|
|
|
+- Modify: `packages/core/src/__tests__/agent-session.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/__tests__/agent-session.test.ts` 的 FakeAgent 中改造 `subscribe()`,让测试能模拟 pi-agent-core event:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+class FakeAgent {
|
|
|
|
|
+ listeners: Array<(event: any) => void> = [];
|
|
|
|
|
+ state: any;
|
|
|
|
|
+ transformContext: any;
|
|
|
|
|
+ streamFn: any;
|
|
|
|
|
+ getApiKey: any;
|
|
|
|
|
+ constructor(options: any) {
|
|
|
|
|
+ this.state = {
|
|
|
|
|
+ model: options.initialState?.model,
|
|
|
|
|
+ systemPrompt: options.initialState?.systemPrompt,
|
|
|
|
|
+ tools: options.initialState?.tools ?? [],
|
|
|
|
|
+ messages: options.initialState?.messages ?? [],
|
|
|
|
|
+ };
|
|
|
|
|
+ this.transformContext = options.transformContext;
|
|
|
|
|
+ this.streamFn = options.streamFn;
|
|
|
|
|
+ this.getApiKey = options.getApiKey;
|
|
|
|
|
+ agentInstances.push(this);
|
|
|
|
|
+ }
|
|
|
|
|
+ subscribe(listener: (event: any) => void) {
|
|
|
|
|
+ this.listeners.push(listener);
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ this.listeners = this.listeners.filter((item) => item !== listener);
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ emit(event: any) {
|
|
|
|
|
+ for (const listener of this.listeners) listener(event);
|
|
|
|
|
+ }
|
|
|
|
|
+ async prompt(userMessage: string) {
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ const user = { role: "user", content: userMessage, timestamp: now };
|
|
|
|
|
+ const assistant = {
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ content: [{ type: "text", text: "ok" }],
|
|
|
|
|
+ api: "anthropic-messages",
|
|
|
|
|
+ provider: "anthropic",
|
|
|
|
|
+ model: "fake",
|
|
|
|
|
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
|
|
|
+ stopReason: "stop",
|
|
|
|
|
+ timestamp: now + 1,
|
|
|
|
|
+ };
|
|
|
|
|
+ this.emit({ type: "turn_start" });
|
|
|
|
|
+ this.emit({ type: "message_end", message: user });
|
|
|
|
|
+ this.state.messages.push(user);
|
|
|
|
|
+ this.emit({ type: "message_end", message: assistant });
|
|
|
|
|
+ this.state.messages.push(assistant);
|
|
|
|
|
+ this.emit({ type: "turn_end", message: assistant, toolResults: [] });
|
|
|
|
|
+ this.emit({ type: "agent_end", messages: [user, assistant] });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+新增测试:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+it("把 message_end 写入 JSONL,并在 cache 失效后恢复 raw AgentMessage", async () => {
|
|
|
|
|
+ const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
|
|
|
|
|
+ const pipeline = {} as any;
|
|
|
|
|
+
|
|
|
|
|
+ await runAgentSession(
|
|
|
|
|
+ { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
|
|
|
|
|
+ "hi",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ evictAgentCache("s1");
|
|
|
|
|
+
|
|
|
|
|
+ await runAgentSession(
|
|
|
|
|
+ { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
|
|
|
|
|
+ "again",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ expect(agentInstances).toHaveLength(2);
|
|
|
|
|
+ expect(agentInstances[1].state.messages[0]).toMatchObject({ role: "user", content: "hi" });
|
|
|
|
|
+ expect(agentInstances[1].state.messages[1]).toMatchObject({ role: "assistant", content: [{ type: "text", text: "ok" }] });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+it("恢复 transcript 中的 toolResult message", async () => {
|
|
|
|
|
+ const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
|
|
|
|
|
+ const pipeline = {} as any;
|
|
|
|
|
+
|
|
|
|
|
+ await runAgentSession(
|
|
|
|
|
+ { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
|
|
|
|
|
+ "hi",
|
|
|
|
|
+ );
|
|
|
|
|
+ const first = agentInstances[0];
|
|
|
|
|
+ const assistant = {
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } }],
|
|
|
|
|
+ api: "anthropic-messages",
|
|
|
|
|
+ provider: "anthropic",
|
|
|
|
|
+ model: "fake",
|
|
|
|
|
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
|
|
|
+ stopReason: "tool_use",
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
|
+ };
|
|
|
|
|
+ const toolResult = { role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "file" }], details: { path: "a.md" }, isError: false, timestamp: Date.now() + 1 };
|
|
|
|
|
+ first.emit({ type: "message_end", message: assistant });
|
|
|
|
|
+ first.emit({ type: "message_end", message: toolResult });
|
|
|
|
|
+ first.state.messages.push(assistant, toolResult);
|
|
|
|
|
+
|
|
|
|
|
+ evictAgentCache("s1");
|
|
|
|
|
+
|
|
|
|
|
+ await runAgentSession(
|
|
|
|
|
+ { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
|
|
|
|
|
+ "again",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ expect(agentInstances[1].state.messages.some((message: any) => message.role === "toolResult" && message.toolCallId === "tool-1")).toBe(true);
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- agent-session.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: FAIL,第二个 Agent 没有从 JSONL 恢复历史。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 改 AgentSession 类型和 cache**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/agent/agent-session.ts` 中:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { randomUUID } from "node:crypto";
|
|
|
|
|
+import { appendTranscriptEvent, nextTranscriptSeq, readTranscriptEvents } from "../interaction/session-transcript.js";
|
|
|
|
|
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+调整接口:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export interface AgentSessionResult {
|
|
|
|
|
+ responseText: string;
|
|
|
|
|
+ messages: AgentMessage[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface CachedAgent {
|
|
|
|
|
+ agent: Agent;
|
|
|
|
|
+ bookId: string | null;
|
|
|
|
|
+ modelId: string | null;
|
|
|
|
|
+ lastCommittedSeq: number;
|
|
|
|
|
+ lastActive: number;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+新增 helper:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
|
|
|
|
|
+ const events = await readTranscriptEvents(projectRoot, sessionId);
|
|
|
|
|
+ return events
|
|
|
|
|
+ .filter((event) => event.type === "request_committed")
|
|
|
|
|
+ .reduce((max, event) => Math.max(max, event.seq), 0);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 实现恢复和写入**
|
|
|
|
|
+
|
|
|
|
|
+在创建 Agent 时改成:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+const restoredMessages = await restoreAgentMessagesFromTranscript(projectRoot, sessionId);
|
|
|
|
|
+const agent = new Agent({
|
|
|
|
|
+ initialState: {
|
|
|
|
|
+ model,
|
|
|
|
|
+ systemPrompt: buildAgentSystemPrompt(bookId, language),
|
|
|
|
|
+ tools: [...],
|
|
|
|
|
+ messages: restoredMessages,
|
|
|
|
|
+ },
|
|
|
|
|
+ transformContext: createBookContextTransform(bookId, projectRoot),
|
|
|
|
|
+ streamFn: streamSimple,
|
|
|
|
|
+ getApiKey: ...
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+执行 prompt 前后加入 request event 和 message event:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+const requestId = randomUUID();
|
|
|
|
|
+let seq = await nextTranscriptSeq(projectRoot, sessionId);
|
|
|
|
|
+await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "request_started",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ requestId,
|
|
|
|
|
+ seq: seq++,
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
|
+ input: userMessage,
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+let parentUuid: string | null = null;
|
|
|
|
|
+let piTurnIndex = 0;
|
|
|
|
|
+let lastAssistantUuid: string | null = null;
|
|
|
|
|
+
|
|
|
|
|
+const persistEvent = async (event: AgentEvent) => {
|
|
|
|
|
+ if (event.type === "turn_start") piTurnIndex += 1;
|
|
|
|
|
+ if (event.type !== "message_end") return;
|
|
|
|
|
+ const message = event.message as any;
|
|
|
|
|
+ const uuid = randomUUID();
|
|
|
|
|
+ const role = message.role;
|
|
|
|
|
+ const toolCallId = role === "toolResult" ? message.toolCallId : Array.isArray(message.content) ? message.content.find((block: any) => block.type === "toolCall")?.id : undefined;
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, {
|
|
|
|
|
+ type: "message",
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ requestId,
|
|
|
|
|
+ uuid,
|
|
|
|
|
+ parentUuid: role === "toolResult" && lastAssistantUuid ? lastAssistantUuid : parentUuid,
|
|
|
|
|
+ seq: seq++,
|
|
|
|
|
+ role,
|
|
|
|
|
+ timestamp: message.timestamp ?? Date.now(),
|
|
|
|
|
+ piTurnIndex,
|
|
|
|
|
+ ...(toolCallId ? { toolCallId } : {}),
|
|
|
|
|
+ ...(role === "toolResult" && lastAssistantUuid ? { sourceToolAssistantUuid: lastAssistantUuid } : {}),
|
|
|
|
|
+ message,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (role === "assistant") lastAssistantUuid = uuid;
|
|
|
|
|
+ parentUuid = uuid;
|
|
|
|
|
+};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+subscription 同时持久化和转发:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+unsubscribe = agent.subscribe(async (event: AgentEvent) => {
|
|
|
|
|
+ await persistEvent(event);
|
|
|
|
|
+ onEvent?.(event);
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+commit/fail:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+try {
|
|
|
|
|
+ await agent.prompt(userMessage);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now() });
|
|
|
|
|
+} catch (error) {
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_failed", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now(), error: error instanceof Error ? error.message : String(error) });
|
|
|
|
|
+ throw error;
|
|
|
|
|
+} finally {
|
|
|
|
|
+ unsubscribe?.();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+返回:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+return { responseText, messages: agent.state.messages.slice() };
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- agent-session.test.ts session-transcript.test.ts session-transcript-restore.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/core/src/agent/agent-session.ts packages/core/src/__tests__/agent-session.test.ts
|
|
|
|
|
+git commit -m "feat: persist agent messages to transcript"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 6: Studio API 停止写 legacy BookSession message
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `packages/studio/src/api/server.ts`
|
|
|
|
|
+- Modify: `packages/studio/src/api/server.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/studio/src/api/server.test.ts` 的 `/api/v1/agent` 测试里加入:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+it("does not append or persist legacy BookSession messages after agent success", async () => {
|
|
|
|
|
+ runAgentSessionMock.mockResolvedValueOnce({
|
|
|
|
|
+ responseText: "Agent response.",
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "user", content: "continue", timestamp: 1 },
|
|
|
|
|
+ { role: "assistant", content: [{ type: "text", text: "Agent response." }], timestamp: 2 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+ loadBookSessionMock
|
|
|
|
|
+ .mockResolvedValueOnce({
|
|
|
|
|
+ sessionId: "agent-session-1",
|
|
|
|
|
+ bookId: "demo-book",
|
|
|
|
|
+ title: null,
|
|
|
|
|
+ messages: [],
|
|
|
|
|
+ events: [],
|
|
|
|
|
+ draftRounds: [],
|
|
|
|
|
+ createdAt: 1,
|
|
|
|
|
+ updatedAt: 1,
|
|
|
|
|
+ })
|
|
|
|
|
+ .mockResolvedValueOnce({
|
|
|
|
|
+ sessionId: "agent-session-1",
|
|
|
|
|
+ bookId: "demo-book",
|
|
|
|
|
+ title: "continue",
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "user", content: "continue", timestamp: 1 },
|
|
|
|
|
+ { role: "assistant", content: "Agent response.", timestamp: 2 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ events: [],
|
|
|
|
|
+ draftRounds: [],
|
|
|
|
|
+ createdAt: 1,
|
|
|
|
|
+ updatedAt: 2,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const { createStudioServer } = await import("./server.js");
|
|
|
|
|
+ const app = createStudioServer(cloneProjectConfig() as never, root);
|
|
|
|
|
+
|
|
|
|
|
+ const response = await app.request("http://localhost/api/v1/agent", {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
|
|
+ body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(response.status).toBe(200);
|
|
|
|
|
+ expect(appendBookSessionMessageMock).not.toHaveBeenCalled();
|
|
|
|
|
+ expect(persistBookSessionMock).not.toHaveBeenCalled();
|
|
|
|
|
+ expect(runAgentSessionMock).toHaveBeenCalledWith(
|
|
|
|
|
+ expect.objectContaining({ sessionId: "agent-session-1" }),
|
|
|
|
|
+ "continue",
|
|
|
|
|
+ );
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-studio test -- server.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: FAIL,因为当前 server 会调用 `appendBookSessionMessage` 和 `persistBookSession`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 修改 `/api/v1/agent` 主路径**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/studio/src/api/server.ts`:
|
|
|
|
|
+
|
|
|
|
|
+1. 删除 `initialMessages` 构造。
|
|
|
|
|
+2. 调用 `runAgentSession(config, instruction)`,不传第三个参数。
|
|
|
|
|
+3. 删除 “Persist user + assistant messages to BookSession” 块。
|
|
|
|
|
+4. 成功后重新 `loadBookSession(root, bookSession.sessionId)` 获取 transcript 派生 session。
|
|
|
|
|
+5. 如果派生 session title 从 null 变为非空,继续 `broadcast("session:title", ...)`。
|
|
|
|
|
+
|
|
|
|
|
+核心形状:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+const beforeTitle = bookSession.title;
|
|
|
|
|
+const result = await runAgentSession(
|
|
|
|
|
+ {
|
|
|
|
|
+ model,
|
|
|
|
|
+ apiKey: agentApiKey,
|
|
|
|
|
+ pipeline,
|
|
|
|
|
+ projectRoot: root,
|
|
|
|
|
+ bookId: activeBookId ?? null,
|
|
|
|
|
+ sessionId: bookSession.sessionId,
|
|
|
|
|
+ language: config.language ?? "zh",
|
|
|
|
|
+ onEvent: ...
|
|
|
|
|
+ },
|
|
|
|
|
+ instruction,
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+bookSession = await loadBookSession(root, bookSession.sessionId) ?? bookSession;
|
|
|
|
|
+if (beforeTitle === null && bookSession.title) {
|
|
|
|
|
+ broadcast("session:title", { sessionId: bookSession.sessionId, title: bookSession.title });
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+fallback plain chat 分支需要写一个 synthetic assistant response。新增 helper 从 core 导出后在 server 调用:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+await appendManualSessionMessages(root, bookSession.sessionId, [
|
|
|
|
|
+ { role: "assistant", content: [{ type: "text", text: fallback.content }], timestamp: Date.now() },
|
|
|
|
|
+]);
|
|
|
|
|
+bookSession = await loadBookSession(root, bookSession.sessionId) ?? bookSession;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在本任务把 `appendManualSessionMessages` 补到 `session-transcript.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export async function appendManualSessionMessages(projectRoot: string, sessionId: string, messages: unknown[]): Promise<void> {
|
|
|
|
|
+ const requestId = randomUUID();
|
|
|
|
|
+ let seq = await nextTranscriptSeq(projectRoot, sessionId);
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_started", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now(), input: "" });
|
|
|
|
|
+ let parentUuid: string | null = null;
|
|
|
|
|
+ for (const message of messages) {
|
|
|
|
|
+ const record = message as { role: "user" | "assistant" | "toolResult" | "system"; timestamp?: number };
|
|
|
|
|
+ const uuid = randomUUID();
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "message", version: 1, sessionId, requestId, uuid, parentUuid, seq: seq++, role: record.role, timestamp: record.timestamp ?? Date.now(), message: record });
|
|
|
|
|
+ parentUuid = uuid;
|
|
|
|
|
+ }
|
|
|
|
|
+ await appendTranscriptEvent(projectRoot, { type: "request_committed", version: 1, sessionId, requestId, seq: seq++, timestamp: Date.now() });
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-studio test -- server.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/studio/src/api/server.ts packages/studio/src/api/server.test.ts packages/core/src/interaction/session-transcript.ts packages/core/src/index.ts
|
|
|
|
|
+git commit -m "feat: derive studio agent sessions from transcript"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 7: 导出 API、类型检查和回归测试
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `packages/core/src/index.ts`
|
|
|
|
|
+- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
+- Modify: `packages/studio/src/api/server.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写导出测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/__tests__/session-transcript.test.ts` 增加从 package index 导入的 smoke test:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+it("从 core index 导出 transcript helper", async () => {
|
|
|
|
|
+ const core = await import("../index.js");
|
|
|
|
|
+ expect(typeof core.readTranscriptEvents).toBe("function");
|
|
|
|
|
+ expect(typeof core.restoreAgentMessagesFromTranscript).toBe("function");
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 修改 index exports**
|
|
|
|
|
+
|
|
|
|
|
+在 `packages/core/src/index.ts` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export {
|
|
|
|
|
+ appendManualSessionMessages,
|
|
|
|
|
+ appendTranscriptEvent,
|
|
|
|
|
+ legacyBookSessionPath,
|
|
|
|
|
+ nextTranscriptSeq,
|
|
|
|
|
+ readTranscriptEvents,
|
|
|
|
|
+ sessionsDir,
|
|
|
|
|
+ transcriptPath,
|
|
|
|
|
+} from "./interaction/session-transcript.js";
|
|
|
|
|
+export {
|
|
|
|
|
+ cleanRestoredAgentMessages,
|
|
|
|
|
+ committedMessageEvents,
|
|
|
|
|
+ deriveBookSessionFromTranscript,
|
|
|
|
|
+ restoreAgentMessagesFromTranscript,
|
|
|
|
|
+} from "./interaction/session-transcript-restore.js";
|
|
|
|
|
+export {
|
|
|
|
|
+ MessageEventSchema,
|
|
|
|
|
+ RequestCommittedEventSchema,
|
|
|
|
|
+ RequestFailedEventSchema,
|
|
|
|
|
+ RequestStartedEventSchema,
|
|
|
|
|
+ SessionCreatedEventSchema,
|
|
|
|
|
+ SessionMetadataUpdatedEventSchema,
|
|
|
|
|
+ TranscriptEventSchema,
|
|
|
|
|
+ type MessageEvent,
|
|
|
|
|
+ type RequestCommittedEvent,
|
|
|
|
|
+ type RequestFailedEvent,
|
|
|
|
|
+ type RequestStartedEvent,
|
|
|
|
|
+ type SessionCreatedEvent,
|
|
|
|
|
+ type SessionMetadataUpdatedEvent,
|
|
|
|
|
+ type TranscriptEvent,
|
|
|
|
|
+} from "./interaction/session-transcript-schema.js";
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 运行 core 全量相关测试**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts session-transcript-restore.test.ts book-session-store.test.ts agent-session.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 运行 typecheck**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core typecheck
|
|
|
|
|
+pnpm --filter @actalk/inkos-studio typecheck
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add packages/core/src/index.ts packages/core/src/__tests__/session-transcript.test.ts
|
|
|
|
|
+git commit -m "chore: export transcript APIs"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 8: 最终验证和文档同步
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md`
|
|
|
|
|
+- Modify: `AGENTS.md`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 确认 SPEC 已记录 legacy display 和 fallback 补写**
|
|
|
|
|
+
|
|
|
|
|
+确认 `docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md` 包含以下两点:
|
|
|
|
|
+
|
|
|
|
|
+```md
|
|
|
|
|
+`MessageEvent.legacyDisplay` 只用于 legacy JSON 迁移后的 UI 派生,例如旧 `InteractionMessage.thinking`。它不进入模型恢复路径,避免把无 signature 的旧 thinking 伪造成可回放的 provider thinking block。
|
|
|
|
|
+
|
|
|
|
|
+如果 Studio plain chat fallback 需要补写 assistant 文本,使用 synthetic committed request 写入 transcript。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行完整相关测试**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+pnpm --filter @actalk/inkos-core test -- session-transcript.test.ts session-transcript-restore.test.ts book-session-store.test.ts agent-session.test.ts
|
|
|
|
|
+pnpm --filter @actalk/inkos-studio test -- server.test.ts
|
|
|
|
|
+pnpm --filter @actalk/inkos-core typecheck
|
|
|
|
|
+pnpm --filter @actalk/inkos-studio typecheck
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: 全部 PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 检查不会继续写 legacy JSON**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+rg -n "persistBookSession\\(|appendBookSessionMessage\\(" packages/studio/src/api/server.ts packages/core/src/agent packages/core/src/interaction
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: `packages/studio/src/api/server.ts` 不再有 agent 成功路径对 `appendBookSessionMessage()` 或 `persistBookSession()` 的调用;`book-session-store.ts` 可以保留兼容导出。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 查看 git diff**
|
|
|
|
|
+
|
|
|
|
|
+运行:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git diff --stat
|
|
|
|
|
+git status --short
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期: 只包含本计划涉及的 core、studio、测试和文档文件。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交最终文档同步**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md AGENTS.md
|
|
|
|
|
+git commit -m "docs: sync transcript persistence notes"
|
|
|
|
|
+```
|